feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)

本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
This commit is contained in:
Drsakura
2026-09-17 14:53:02 +08:00
parent 436519e0a6
commit 13d67c804f
77 changed files with 18407 additions and 955 deletions
+306 -1
View File
@@ -16,9 +16,25 @@ const db = new Database(DB_PATH);
// 启用外键约束
db.pragma('foreign_keys = ON');
// 性能优化
/* ---------------------------------------------------------------------------
* 并发与性能配置 —— 多人共用这套库的命脉,全部显式声明,不依赖驱动默认值。
*
* journal_mode=WAL 读写互不阻塞。这是多用户能同时用的前提,
* 否则一个写操作会锁住全库所有的读。
* synchronous=NORMAL 配合 WAL 的平衡点:断电最多丢最后一个事务,
* 但不会损坏数据库文件。
* busy_timeout=5000 写冲突时排队等 5 秒再报错,而不是立刻抛 SQLITE_BUSY。
* ⚠️ 这一项此前一直是 5000,但那是 better-sqlite3 的
* 驱动默认值——一旦换驱动、或哪天默认值变了,
* 「多人同时保存」就会开始直接报错。必须显式写死。
* 单次写事务只需 1–5ms,5 秒足够排完几十人的队列。
*
* mmap_size 刻意不设:本库走 SQLCipher 加密路径,内存映射在加密库上
* 历史有兼容性坑,而库只有几 MB,收益远小于风险。
* --------------------------------------------------------------------------- */
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('busy_timeout = 5000');
// 初始化数据库结构
function initDatabase() {
@@ -46,6 +62,190 @@ function ensureColumns() {
db.exec("ALTER TABLE todos ADD COLUMN list TEXT NOT NULL DEFAULT 'schedule'");
console.log('✓ 迁移:todos.list 列已补齐');
}
// 老数据库不会重新执行 schema.sql,这里只补新增表和新增列,不改动已有数据。
db.exec(`
CREATE TABLE IF NOT EXISTS inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_no TEXT NOT NULL UNIQUE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
inquiry_date TEXT NOT NULL,
title TEXT, status TEXT NOT NULL DEFAULT 'draft', currency TEXT DEFAULT 'USD',
source TEXT, notes TEXT, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS inquiry_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_id INTEGER NOT NULL REFERENCES inquiries(id), sku TEXT NOT NULL,
product_name TEXT, supplier_id INTEGER REFERENCES suppliers(id), supplier_name TEXT,
purchase_price REAL NOT NULL DEFAULT 0, currency TEXT NOT NULL DEFAULT 'CNY',
exchange_rate REAL NOT NULL DEFAULT 1, markup_rate REAL NOT NULL DEFAULT 0,
markup_factor REAL NOT NULL DEFAULT 1, exw REAL NOT NULL DEFAULT 0,
quantity INTEGER NOT NULL DEFAULT 1, packaging_snapshot TEXT, product_snapshot TEXT,
quoted_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id), activity_type TEXT NOT NULL,
content TEXT NOT NULL, next_action TEXT, next_action_date TEXT,
contact_id INTEGER REFERENCES contacts(id), inquiry_id INTEGER REFERENCES inquiries(id),
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS product_packaging (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE REFERENCES products(sku), inner_pack_qty INTEGER, carton_qty INTEGER,
carton_length REAL, carton_width REAL, carton_height REAL, carton_weight REAL,
unit_length REAL, unit_width REAL, unit_height REAL, unit_weight REAL,
packaging_material TEXT, notes TEXT, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_no TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'queued',
file_count INTEGER NOT NULL DEFAULT 0, source_files TEXT,
parse_method TEXT NOT NULL DEFAULT 'rules', rule_version TEXT NOT NULL DEFAULT 'mvp-v1',
notes TEXT, created_at TEXT NOT NULL, updated_at TEXT, completed_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES contract_import_jobs(id), filename TEXT NOT NULL,
row_no INTEGER, raw_text TEXT, sku TEXT, product_name TEXT, purchase_price REAL,
currency TEXT, quantity INTEGER, status TEXT NOT NULL DEFAULT 'pending_review',
review_note TEXT, reviewed_by INTEGER REFERENCES users(id), reviewed_at TEXT,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_inquiries_customer ON inquiries(customer_id);
CREATE INDEX IF NOT EXISTS idx_inquiries_status_date ON inquiries(status, inquiry_date);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_inquiry ON inquiry_items(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_sku ON inquiry_items(sku);
CREATE INDEX IF NOT EXISTS idx_follow_up_customer_date ON follow_up_records(customer_id, next_action_date);
CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku);
CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status);
/* 组织与权限组(多用户阶段):老库补表。organizations 必须建在 users 补列之前,
因为下面会给 users 加带外键的 org_id 列。 */
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, code TEXT, description TEXT,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, description TEXT, is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
permission_key TEXT NOT NULL, UNIQUE (group_id, permission_key)
);
CREATE TABLE IF NOT EXISTS role_permission_groups (
role TEXT PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES permission_groups(id), updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_pg_permissions_group ON permission_group_permissions(group_id);
/* 产品目录本地化(SKU 模块并入后):小推车 + 二进制存储。老库补表。
图片与合同原件单独成表,而不是给 product_images / contracts 加 BLOB 列 ——
那两张表都用 SELECT * 查,二进制挂在同一行会让每次列表查询都把整张图读进内存。 */
CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE, qty INTEGER, note TEXT,
sort INTEGER DEFAULT 0, added_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS product_image_files (
image_id INTEGER PRIMARY KEY, bytes BLOB NOT NULL, size INTEGER, stored_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_files (
contract_id INTEGER PRIMARY KEY, filename TEXT, mime TEXT, size INTEGER,
sha256 TEXT, bytes BLOB NOT NULL, stored_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_cart_items_sort ON cart_items(sort);
`);
const columnMigrations = {
inquiries: [
['inquiry_no', "TEXT NOT NULL DEFAULT ''"], ['customer_id', 'INTEGER'], ['inquiry_date', "TEXT NOT NULL DEFAULT ''"],
['title', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'draft'"], ['currency', "TEXT DEFAULT 'USD'"], ['source', 'TEXT'],
['notes', 'TEXT'], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
inquiry_items: [
['inquiry_id', 'INTEGER'], ['sku', "TEXT NOT NULL DEFAULT ''"], ['product_name', 'TEXT'], ['supplier_id', 'INTEGER'],
['supplier_name', 'TEXT'], ['purchase_price', 'REAL NOT NULL DEFAULT 0'], ['currency', "TEXT NOT NULL DEFAULT 'CNY'"],
['exchange_rate', 'REAL NOT NULL DEFAULT 1'], ['markup_rate', 'REAL NOT NULL DEFAULT 0'], ['markup_factor', 'REAL NOT NULL DEFAULT 1'],
['exw', 'REAL NOT NULL DEFAULT 0'], ['quantity', 'INTEGER NOT NULL DEFAULT 1'], ['packaging_snapshot', 'TEXT'],
['product_snapshot', 'TEXT'], ['quoted_at', "TEXT NOT NULL DEFAULT ''"], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
follow_up_records: [
['customer_id', 'INTEGER'], ['activity_type', "TEXT NOT NULL DEFAULT 'note'"], ['content', "TEXT NOT NULL DEFAULT ''"],
['next_action', 'TEXT'], ['next_action_date', 'TEXT'], ['contact_id', 'INTEGER'], ['inquiry_id', 'INTEGER'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
product_packaging: [
['sku', "TEXT NOT NULL DEFAULT ''"], ['inner_pack_qty', 'INTEGER'], ['carton_qty', 'INTEGER'], ['carton_length', 'REAL'],
['carton_width', 'REAL'], ['carton_height', 'REAL'], ['carton_weight', 'REAL'], ['unit_length', 'REAL'], ['unit_width', 'REAL'],
['unit_height', 'REAL'], ['unit_weight', 'REAL'], ['packaging_material', 'TEXT'], ['notes', 'TEXT'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
contract_import_jobs: [
['job_no', "TEXT NOT NULL DEFAULT ''"], ['status', "TEXT NOT NULL DEFAULT 'queued'"], ['file_count', 'INTEGER NOT NULL DEFAULT 0'],
['source_files', 'TEXT'], ['parse_method', "TEXT NOT NULL DEFAULT 'rules'"], ['rule_version', "TEXT NOT NULL DEFAULT 'mvp-v1'"],
// column_map 存本次认列结果(字段→列号/表头/得分):解析不对时要能自证认了哪几列
['column_map', 'TEXT'], ['parse_source', "TEXT DEFAULT 'rules'"],
['notes', 'TEXT'], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'], ['completed_at', 'TEXT']
],
contract_import_items: [
['job_id', 'INTEGER'], ['filename', "TEXT NOT NULL DEFAULT ''"], ['row_no', 'INTEGER'], ['raw_text', 'TEXT'], ['sku', 'TEXT'],
['product_name', 'TEXT'], ['spec', 'TEXT'], ['purchase_price', 'REAL'], ['currency', 'TEXT'], ['quantity', 'INTEGER'],
['moq', 'INTEGER'], ['parse_source', "TEXT DEFAULT 'rules'"],
['status', "TEXT NOT NULL DEFAULT 'pending_review'"], ['review_note', 'TEXT'], ['reviewed_by', 'INTEGER'], ['reviewed_at', 'TEXT'],
// 入库留痕:committed_at 记入库时间,product_sku 记实际落库货号(撞号会是 -COPY2)
['committed_at', 'TEXT'], ['product_sku', 'TEXT'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
// 报价单明细的成本口径快照(老库补列)
quotation_items: [
['purchase_price', 'REAL'], ['exchange_rate', 'REAL'], ['markup_rate', 'REAL']
],
// 报价单条款结构化字段(老库补列)。payment_terms / delivery_terms 继续存可读文本。
customer_quotations: [
['pi_no', 'TEXT'], ['payment_method', 'TEXT'], ['deposit_percent', 'REAL'], ['balance_percent', 'REAL'],
['delivery_method', 'TEXT'], ['delivery_port', 'TEXT'], ['validity_days', 'INTEGER'], ['internal_notes', 'TEXT']
],
// 个人资料与组织归属(老库补列)
users: [
['position', 'TEXT'], ['phone', 'TEXT'], ['address', 'TEXT'], ['avatar', 'TEXT'],
['org_id', 'INTEGER'], ['updated_at', 'TEXT']
],
// 产品目录本地化(SKU 模块并入):上传者 / 归属组织 / 撞号副本来源,以及调价人(老库补列)
products: [
['uploader', 'TEXT'], ['org_id', 'INTEGER'], ['duplicated_from', 'TEXT']
],
price_history: [
['changed_by', 'TEXT']
],
// 客户成交状态(新客开发页按 pending / won / lost 分栏)+ 归属与公海(老库补列)
customers: [
['deal_status', "TEXT DEFAULT 'pending'"], ['org_id', 'INTEGER'],
// created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕
['created_by', 'INTEGER'],
// pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领)
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT']
]
};
for (const [table, columns] of Object.entries(columnMigrations)) {
const existing = new Set(db.pragma(`table_info(${table})`).map(column => column.name));
for (const [name, definition] of columns) {
if (!existing.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
}
/* settings 是键值表,权限点自动补齐的台账记在这里;老库万一没有也兜一下 */
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)');
/* 依赖「补列之后才存在」的索引,放在这里建 */
db.exec('CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id)');
/* 客户列表现在是「按归属过滤」,owner_id / pool_status 都得有索引 */
db.exec('CREATE INDEX IF NOT EXISTS idx_customers_owner ON customers(owner_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_customers_pool ON customers(pool_status)');
}
ensureColumns();
@@ -75,4 +275,109 @@ function createDefaultAdmin() {
createDefaultAdmin();
// ---------------------------------------------------------------------------
// 组织与权限组的开箱数据:只在首次运行写入,之后一律以库里的为准。
// 老库(已有 admin 用户)也会走这里补默认组织与默认权限组。
// ---------------------------------------------------------------------------
function seedAccessControl() {
const now = new Date().toISOString();
const { DEFAULT_PERMISSION_GROUPS } = require('../lib/permissions');
/* 默认组织:让存量用户立刻有归属,「同组织」口径从一开始就成立 */
let org = db.prepare('SELECT id FROM organizations ORDER BY id LIMIT 1').get();
if (!org) {
const result = db.prepare('INSERT INTO organizations (name, code, description, created_at) VALUES (?, ?, ?, ?)')
.run('happy出口通', 'HAPPY', '默认组织 —— 团队共享的产品/客户资源以此为单位', now);
org = { id: result.lastInsertRowid };
console.log('✓ 默认组织已创建:happy出口通');
}
db.prepare('UPDATE users SET org_id = ?, updated_at = ? WHERE org_id IS NULL').run(org.id, now);
/* 默认权限组 + 角色指派 */
const groupCount = db.prepare('SELECT COUNT(*) AS c FROM permission_groups').get().c;
if (groupCount === 0) {
const insertGroup = db.prepare('INSERT INTO permission_groups (name, description, is_system, created_at) VALUES (?, ?, ?, ?)');
const insertPermission = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
const assignRole = db.prepare('INSERT OR REPLACE INTO role_permission_groups (role, group_id, updated_at) VALUES (?, ?, ?)');
for (const group of DEFAULT_PERMISSION_GROUPS) {
const id = insertGroup.run(group.name, group.description, group.is_system, now).lastInsertRowid;
for (const key of group.permissions) insertPermission.run(id, key);
for (const role of group.roles) assignRole.run(role, id, now);
}
console.log('✓ 默认权限组与角色指派已创建');
}
syncNewPermissions();
adoptOrphanCustomers(org.id, now);
}
/**
* 新增权限点的自动补齐。
*
* 权限组只在「首次建库」时按 DEFAULT_PERMISSION_GROUPS 灌一遍,之后库里的勾选状态
* 就是唯一事实 —— 所以后续版本新增的权限点,老库永远不会自动拿到(典型症状:
* 升级后某个功能对所有人都是「没有权限」)。这里把「全库都不认识的 key」
* 按默认配置补进对应组,补过就记在 settings 台账里,之后绝不再碰:
* Wayne 手工取消勾选的动作不会被这段代码在下次启动时倒回去。
*/
function syncNewPermissions() {
const { DEFAULT_PERMISSION_GROUPS, PERMISSION_KEYS } = require('../lib/permissions');
const LEDGER = 'perm_autogrant';
let granted = [];
try {
granted = JSON.parse(db.prepare('SELECT value FROM settings WHERE key = ?').get(LEDGER)?.value || '[]');
} catch {
granted = [];
}
const grantedSet = new Set(Array.isArray(granted) ? granted : []);
const known = new Set(
db.prepare('SELECT DISTINCT permission_key FROM permission_group_permissions').all()
.map(row => row.permission_key)
);
/* 本版本新增的 = 代码里有、但全库任何组都没拥有过、也没被自动补过 */
const fresh = PERMISSION_KEYS.filter(key => !known.has(key) && !grantedSet.has(key));
if (!fresh.length) return;
const groupByName = db.prepare('SELECT id FROM permission_groups WHERE name = ?');
const insert = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
const added = [];
for (const group of DEFAULT_PERMISSION_GROUPS) {
const row = groupByName.get(group.name);
if (!row) continue;
for (const key of group.permissions) {
if (!fresh.includes(key)) continue;
insert.run(row.id, key);
added.push(`${group.name}:${key}`);
}
}
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run(LEDGER, JSON.stringify([...grantedSet, ...fresh]));
if (added.length) console.log(`✓ 新增权限点已补齐:${added.join(', ')}`);
}
/**
* 存量客户归属回填。
*
* 「谁建的谁看得见」这条一旦生效,老库里 owner_id 为空的客户就会变成
* 「除了最高级别管理账号谁都看不见」—— 升个级把老板的客户弄丢是不可接受的。
* 所以统一认给最早的系统管理员,org 也一并补上。
*/
function adoptOrphanCustomers(orgId, now) {
const orphans = db.prepare('SELECT COUNT(*) AS c FROM customers WHERE owner_id IS NULL').get().c;
if (!orphans) return;
const owner = db.prepare("SELECT id, org_id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
if (!owner) return;
db.prepare(`UPDATE customers
SET owner_id = ?,
created_by = COALESCE(created_by, ?),
org_id = COALESCE(org_id, ?),
updated_at = COALESCE(updated_at, ?)
WHERE owner_id IS NULL`).run(owner.id, owner.id, owner.org_id || orgId, now);
console.log(`✓ 存量客户归属回填:${orphans} 条 → 用户 #${owner.id}`);
}
seedAccessControl();
module.exports = db;
+229
View File
@@ -51,6 +51,11 @@ CREATE TABLE IF NOT EXISTS products (
last_updated TEXT,
source_contract TEXT,
confidence TEXT DEFAULT 'high',
-- 上传者 / 归属组织 / 撞号副本来源:产品库是组织共享的,谁都能传自己那一版,
-- 「谁传的、复制自谁」必须可追,见 lib/skuCatalog.js 的 insertItem。
uploader TEXT,
org_id INTEGER,
duplicated_from TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
@@ -110,6 +115,8 @@ CREATE TABLE IF NOT EXISTS price_history (
supplier_id INTEGER REFERENCES suppliers(id),
source_contract TEXT,
confidence TEXT,
-- 谁改的价:报价单里的采购价要能追到出处,和 insertItem 的手工留痕同源
changed_by TEXT,
changed_at TEXT NOT NULL
);
@@ -142,6 +149,39 @@ CREATE TABLE IF NOT EXISTS product_images (
created_at TEXT NOT NULL
);
/* 小推车:产品页里挑出来的货号暂存区,出报价单前的中转台。
全局唯一一份(不按用户分车)—— 这是当前口径,改成分人分车要同时动前端。 */
CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
qty INTEGER,
note TEXT,
sort INTEGER DEFAULT 0,
added_at TEXT NOT NULL
);
/* ---------------------------------------------------------------------------
* 二进制存储:图片与合同原件存进库,不摊在磁盘上。
* 单独成表而不是给 product_images / contracts 加 BLOB 列 —— 那两张表都用
* SELECT * 查,把二进制挂在同一行会让每次列表查询都把整张图读进内存。
* --------------------------------------------------------------------------- */
CREATE TABLE IF NOT EXISTS product_image_files (
image_id INTEGER PRIMARY KEY REFERENCES product_images(id),
bytes BLOB NOT NULL,
size INTEGER,
stored_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_files (
contract_id INTEGER PRIMARY KEY REFERENCES contracts(id),
filename TEXT,
mime TEXT,
size INTEGER,
sha256 TEXT,
bytes BLOB NOT NULL,
stored_at TEXT
);
-- ================== 客户管理(CRM) ==================
CREATE TABLE IF NOT EXISTS customers (
@@ -156,7 +196,17 @@ CREATE TABLE IF NOT EXISTS customers (
email TEXT,
rating TEXT,
status TEXT DEFAULT 'active',
/* 开发结果:pending 未成交 / won 已成交 / lost 已流失(新客开发页按此分栏) */
deal_status TEXT DEFAULT 'pending',
/* 归属:谁建的归谁,默认只有归属人看得见;创建人主动丢进公海才对同组织开放 */
owner_id INTEGER REFERENCES users(id),
/* created_by 单独记「谁建的」—— 公海被领取后 owner_id 会换人,创建人这一笔仍要留痕 */
created_by INTEGER REFERENCES users(id),
org_id INTEGER REFERENCES organizations(id),
/* pool_status:owned 名下有主 / public 已丢入公海(同组织可见、可领取) */
pool_status TEXT DEFAULT 'owned',
pool_at TEXT,
pool_note TEXT,
source TEXT,
tags TEXT,
notes TEXT,
@@ -275,10 +325,20 @@ CREATE TABLE IF NOT EXISTS customer_quotations (
quote_date TEXT NOT NULL,
valid_until TEXT,
currency TEXT DEFAULT 'USD',
-- 条款结构化字段:payment_terms / delivery_terms 仍保留可读文本,供老页面直接展示
pi_no TEXT,
payment_method TEXT,
deposit_percent REAL,
balance_percent REAL,
delivery_method TEXT,
delivery_port TEXT,
validity_days INTEGER,
payment_terms TEXT,
delivery_terms TEXT,
status TEXT DEFAULT 'draft',
notes TEXT,
-- 系统备注:只给内部看,报价单对外不显示
internal_notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT
@@ -295,6 +355,10 @@ CREATE TABLE IF NOT EXISTS quotation_items (
unit_price REAL NOT NULL,
total_price REAL NOT NULL,
currency TEXT DEFAULT 'USD',
-- 成本口径快照:报价单发出去之后,供应商再调价也不能让这张单的历史毛利变样
purchase_price REAL,
exchange_rate REAL,
markup_rate REAL,
notes TEXT,
created_at TEXT NOT NULL
);
@@ -339,20 +403,63 @@ CREATE TABLE IF NOT EXISTS prospect_leads (
-- ================== 系统管理 ==================
-- 组织(团队):产品/客户的归属单位,「同组织的人均可参考」以它为边界。
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT,
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
email TEXT,
/* role = 权限角色(董事长 / 总经理 / 销售经理 / 销售业务员 …),也是「角色 → 权限组」映射的键 */
role TEXT DEFAULT 'user',
/* position = 对外展示的职位称谓,纯文字,与角色解耦 */
position TEXT,
phone TEXT,
address TEXT,
avatar TEXT,
org_id INTEGER REFERENCES organizations(id),
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT,
last_login_at TEXT,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
-- 权限组:一套命名的权限范围。角色被指派进某个组,即获得该组的全部权限点。
CREATE TABLE IF NOT EXISTS permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT
);
-- 权限组 → 权限点(permission_key 由后端 PERMISSIONS 清单定义)
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
permission_key TEXT NOT NULL,
UNIQUE (group_id, permission_key)
);
-- 角色 → 权限组(一个角色同时只属于一个组,改指派即换组)
CREATE TABLE IF NOT EXISTS role_permission_groups (
role TEXT PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
@@ -401,3 +508,125 @@ CREATE INDEX IF NOT EXISTS idx_prospect_leads_run ON prospect_leads(run_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at);
-- ================== 询盘与报价主链路 ==================
CREATE TABLE IF NOT EXISTS inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_no TEXT NOT NULL UNIQUE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
inquiry_date TEXT NOT NULL,
title TEXT,
status TEXT NOT NULL DEFAULT 'draft',
currency TEXT DEFAULT 'USD',
source TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS inquiry_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_id INTEGER NOT NULL REFERENCES inquiries(id),
sku TEXT NOT NULL,
product_name TEXT,
supplier_id INTEGER REFERENCES suppliers(id),
supplier_name TEXT,
purchase_price REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
exchange_rate REAL NOT NULL DEFAULT 1,
markup_rate REAL NOT NULL DEFAULT 0,
markup_factor REAL NOT NULL DEFAULT 1,
exw REAL NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
packaging_snapshot TEXT,
product_snapshot TEXT,
quoted_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id),
activity_type TEXT NOT NULL,
content TEXT NOT NULL,
next_action TEXT,
next_action_date TEXT,
contact_id INTEGER REFERENCES contacts(id),
inquiry_id INTEGER REFERENCES inquiries(id),
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS product_packaging (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE REFERENCES products(sku),
inner_pack_qty INTEGER,
carton_qty INTEGER,
carton_length REAL,
carton_width REAL,
carton_height REAL,
carton_weight REAL,
unit_length REAL,
unit_width REAL,
unit_height REAL,
unit_weight REAL,
packaging_material TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_no TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'queued',
file_count INTEGER NOT NULL DEFAULT 0,
source_files TEXT,
parse_method TEXT NOT NULL DEFAULT 'rules',
rule_version TEXT NOT NULL DEFAULT 'mvp-v1',
-- column_map:本次认列结果(字段 → 列号/表头/得分),出问题时唯一能自证的现场
column_map TEXT,
-- parse_source:这份结果是怎么来的 —— rules(字符规则)/ ai(模型解析)
parse_source TEXT DEFAULT 'rules',
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT,
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES contract_import_jobs(id),
filename TEXT NOT NULL,
row_no INTEGER,
raw_text TEXT,
sku TEXT,
product_name TEXT,
spec TEXT,
purchase_price REAL,
currency TEXT,
quantity INTEGER,
moq INTEGER,
status TEXT NOT NULL DEFAULT 'pending_review',
review_note TEXT,
parse_source TEXT DEFAULT 'rules',
reviewed_by INTEGER REFERENCES users(id),
reviewed_at TEXT,
-- committed_at / product_sku:入库留痕 —— 什么时候进的库、进成了哪个货号
-- (撞号时实际落库的是 -COPY2,光看 sku 列看不出来)
committed_at TEXT,
product_sku TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_inquiries_customer ON inquiries(customer_id);
CREATE INDEX IF NOT EXISTS idx_inquiries_status_date ON inquiries(status, inquiry_date);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_inquiry ON inquiry_items(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_sku ON inquiry_items(sku);
CREATE INDEX IF NOT EXISTS idx_follow_up_customer_date ON follow_up_records(customer_id, next_action_date);
CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku);
CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status);