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
+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);