Files
Drsakura 13d67c804f 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
2026-09-17 14:53:02 +08:00

298 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 产品目录(SKU 模块)的本地数据层。
*
* 这块原先是独立的 sku-manager 服务(3300),happy 通过 /api/sku 代理访问它。
* 合并后数据直接落在 trade.db:少一个进程、少一个端口,开源部署只跑 4300 + 5300。
*
* 表结构沿用 sku-manager 那套(products / product_groups / suppliers / item_attributes /
* product_images / price_history / cart_items),列名保持一致 —— 迁移是按原 id 整表搬过来的,
* 所以这里的外键关系与历史数据完全对得上,不需要任何 id 映射。
*/
const crypto = require('crypto');
const db = require('../db/db');
/* --------------------------------- 规范化 --------------------------------- */
/**
* 货号规范化:去掉空格、连字符、下划线、斜杠、点,再转大写。
* 合同里的「GD-100F」和产品库里的「GD100F」必须归到同一条。
*/
function normalizeSku(raw) {
if (!raw) return null;
return String(raw).toUpperCase().replace(/[\s\-_./]/g, '').trim();
}
/**
* 产品归组键:同一单品的不同尺寸归为一个产品。
* 只剥「尺寸/规格」类字样(8x10、200mm、1/2"、6寸),件数(6件套)保留 ——
* 套装件数不同是不同的产品。
*/
function groupKey(name) {
if (!name) return null;
let s = String(name).replace(/\s+/g, '');
s = s.replace(/\d+(\.\d+)?[x×*]\d+(\.\d+)?(mm|cm)?/gi, '');
s = s.replace(/\d+\/\d+(["″”寸]|英寸)?/g, '');
s = s.replace(/\d+(\.\d+)?(mm|cm|寸|英寸|inch|in|"|″|”|米)/gi, '');
s = s.replace(/[-—–()()]/g, '');
const key = s.toUpperCase();
return key || String(name).trim().toUpperCase();
}
/* ------------------------------ 二进制存储 ------------------------------ */
const MIME_BY_EXT = {
'.pdf': 'application/pdf',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp'
};
function mimeFor(name) {
const i = String(name).lastIndexOf('.');
return (i >= 0 && MIME_BY_EXT[String(name).slice(i).toLowerCase()]) || 'application/octet-stream';
}
const syncImageStmt = db.prepare(
`INSERT INTO product_image_files (image_id, bytes, size, stored_at)
VALUES (@image_id, @bytes, @size, @stored_at)
ON CONFLICT(image_id) DO UPDATE SET bytes = excluded.bytes, size = excluded.size, stored_at = excluded.stored_at`
);
const getImageStmt = db.prepare('SELECT bytes, size FROM product_image_files WHERE image_id = ?');
const delImageStmt = db.prepare('DELETE FROM product_image_files WHERE image_id = ?');
const storeImage = (imageId, buf) =>
syncImageStmt.run({ image_id: imageId, bytes: buf, size: buf.length, stored_at: new Date().toISOString() });
const getImage = (imageId) => getImageStmt.get(imageId);
const deleteImage = (imageId) => delImageStmt.run(imageId);
/** 批量删图(批量删产品时用),避免逐条 delete。 */
function deleteImagesFor(imageIds) {
if (!imageIds.length) return;
db.prepare(`DELETE FROM product_image_files WHERE image_id IN (${imageIds.map(() => '?').join(',')})`)
.run(...imageIds);
}
/* ------------------------------- 合同原件 ------------------------------- */
const putContractStmt = db.prepare(
`INSERT INTO contract_files (contract_id, filename, mime, size, sha256, bytes, stored_at)
VALUES (@contract_id, @filename, @mime, @size, @sha256, @bytes, @stored_at)
ON CONFLICT(contract_id) DO UPDATE SET
filename = excluded.filename, mime = excluded.mime, size = excluded.size,
sha256 = excluded.sha256, bytes = excluded.bytes, stored_at = excluded.stored_at`
);
/** 把一份合同原件收进库。返回 null 表示没收到(不算致命,解析结果才是主产物)。 */
function storeContractFile(contractId, bytes, displayName) {
if (!bytes || !bytes.length) return null;
const filename = displayName || `contract-${contractId}`;
putContractStmt.run({
contract_id: contractId,
filename,
mime: mimeFor(filename),
size: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
bytes,
stored_at: new Date().toISOString()
});
return { size: bytes.length };
}
const getContractFile = (contractId) =>
db.prepare('SELECT filename, mime, size, bytes FROM contract_files WHERE contract_id = ?').get(contractId);
/* --------------------------------- 参数 --------------------------------- */
const getItemAttrs = db.prepare(
'SELECT id, name, value, sort FROM item_attributes WHERE sku = ? ORDER BY sort, id'
);
const getGroupAttrs = db.prepare(
'SELECT id, name, value, sort FROM item_attributes WHERE group_id = ? AND sku IS NULL ORDER BY sort, id'
);
/** 整组替换参数(货号级或产品级)。传入 [{name, value}],空名跳过。 */
function replaceAttrs({ sku = null, groupId = null }, attrs) {
if (!Array.isArray(attrs)) return;
db.transaction(() => {
if (sku) db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku);
else db.prepare('DELETE FROM item_attributes WHERE group_id = ? AND sku IS NULL').run(groupId);
const ins = db.prepare(
'INSERT INTO item_attributes (group_id, sku, name, value, sort) VALUES (@group_id, @sku, @name, @value, @sort)'
);
attrs.forEach((a, i) => {
const name = String(a?.name || '').trim();
if (!name) return;
ins.run({ group_id: sku ? null : groupId, sku, name, value: String(a?.value ?? '').trim() || null, sort: i });
});
})();
}
/** 产品组变空后清理掉,避免列表里留下没有货号的壳(有图片的保留)。 */
function cleanupGroupIfEmpty(groupId) {
if (!groupId) return;
const hasItems = db.prepare('SELECT 1 FROM products WHERE group_id = ? LIMIT 1').get(groupId);
const hasImages = db.prepare('SELECT 1 FROM product_images WHERE group_id = ? LIMIT 1').get(groupId);
if (!hasItems && !hasImages) {
db.prepare('DELETE FROM item_attributes WHERE group_id = ?').run(groupId);
db.prepare('DELETE FROM product_groups WHERE id = ?').run(groupId);
}
}
/* --------------------------------- 货号 --------------------------------- */
function validatePriceMoq(b) {
const price = b.price === '' || b.price == null ? null : Number(b.price);
if (price !== null && (!Number.isFinite(price) || price < 0)) return { error: '采购价必须是非负数字' };
const moq = b.moq === '' || b.moq == null ? null : Number(b.moq);
if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return { error: '起订量必须是非负数字' };
return { price, moq };
}
/**
* 新增货号。
*
* 货号重复**不当错误** —— 产品库是组织共享的,谁都能上传自己那一版:
* 撞号时落成兄弟副本 `<SKU>-COPY2 / -COPY3 …`,display_sku 加「-副本N」,
* 并用 duplicated_from 记住它复制自哪个货号,界面上标出来,避免两条同名货号看着像脏数据。
* 无论是否重复都记 uploader / org_id;手工价同时写 changed_by,保证「谁传的、谁改的价」可追。
*/
function insertItem(b, groupId, supplierId, actor = null, actorOrg = null, options = {}) {
const displaySku = String(b.sku || '').trim();
const base = normalizeSku(displaySku);
if (!base) return { error: '货号不能为空' };
const v = validatePriceMoq(b);
if (v.error) return v;
const taken = (candidate) => !!db.prepare('SELECT sku FROM products WHERE sku = ?').get(candidate);
let sku = base;
let display = displaySku;
let duplicatedFrom = null;
if (taken(base)) {
duplicatedFrom = base;
let n = 2;
while (taken(`${base}-COPY${n}`)) n += 1;
sku = `${base}-COPY${n}`;
display = `${displaySku}-副本${n}`;
}
const now = new Date().toISOString();
/* options 只在「带来源的批量导入」里传(目前是合同清洗中心的入库):
sourceContract —— 价格出处,只记合同**文件名**(Wayne 2026-09-16:不记路径、不记任务号)
parseMethod —— 'manual' | 'rules' | 'ai',用来区分这条货号是手录还是合同带进来的
currency —— 新建时按合同的币种落库;不传仍然是 CNY(产品库原有口径)
⚠️ 更新已有货号时**不动 currency** —— 汇率口径牵一发动全身,不在这里顺手改。 */
const sourceContract = options.sourceContract || null;
const parseMethod = options.parseMethod || 'manual';
const currency = String(options.currency || 'CNY').toUpperCase();
db.transaction(() => {
db.prepare(
`INSERT INTO products (sku, display_sku, name, spec, price, currency, moq, description,
supplier_id, contract_id, group_id, parse_method,
last_updated, source_contract, confidence,
uploader, org_id, duplicated_from, created_at, updated_at)
VALUES (@sku, @display_sku, @name, @spec, @price, @currency, @moq, @description,
@supplier_id, NULL, @group_id, @parse_method, @now, @source_contract, 'high',
@uploader, @org_id, @duplicated_from, @now, @now)`
).run({
sku,
display_sku: display,
name: b.name?.trim() || null,
spec: b.spec?.trim() || null,
price: v.price,
moq: v.moq,
currency,
description: b.description?.trim() || null,
supplier_id: supplierId,
group_id: groupId,
parse_method: parseMethod,
source_contract: sourceContract,
now,
uploader: actor,
org_id: actorOrg,
duplicated_from: duplicatedFrom
});
/* 手工价格同样留痕,保证「每个价格都能追到出处」这条不破功。
带来源时(合同入库)把合同文件名当出处写进去,手工录入才写「手工录入」。 */
if (v.price !== null) {
db.prepare(
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
supplier_id, source_contract, confidence, changed_at, changed_by)
VALUES (@sku, NULL, @price, NULL, @moq, @currency, @supplier_id, @source_contract, 'high', @now, @changed_by)`
).run({
sku,
price: v.price,
moq: v.moq,
currency,
supplier_id: supplierId,
source_contract: sourceContract || '手工录入',
now,
changed_by: actor
});
}
})();
if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes);
return { sku, displaySku: display, duplicatedFrom };
}
/* ------------------------------- 产品解析 ------------------------------- */
/**
* 解析货号,返回统一的 { product, packaging } 视图。
*
* 合并前这里要先打 SKU 服务、失败再回退本地镜像(镜像只兜底,绝不拿过期价去报价)。
* 现在数据就在本库,直接查即可 —— 单表主键命中,比原来走一趟 HTTP 快得多。
* packaging 始终取自本地 product_packaging(询盘/报价用的包装快照来源)。
*/
function resolveProduct(sku) {
const raw = String(sku || '').trim();
if (!raw) throw new Error('SKU 不能为空');
const key = normalizeSku(raw);
const row = db.prepare(
`SELECT p.*, s.name AS supplier_name FROM products p
LEFT JOIN suppliers s ON s.id = p.supplier_id WHERE p.sku = ?`
).get(key);
if (!row) throw new Error(`产品 SKU 不存在:${raw}`);
const product = {
...row,
sku: row.sku,
display_sku: row.display_sku || row.sku,
name: row.name || row.name_en || row.sku,
name_en: row.name_en || null,
price: row.price,
currency: row.currency || 'CNY',
moq: row.moq === undefined ? null : row.moq
};
const packaging = db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(row.sku) || null;
return { product, packaging };
}
module.exports = {
normalizeSku,
groupKey,
mimeFor,
storeImage,
getImage,
deleteImage,
deleteImagesFor,
storeContractFile,
getContractFile,
getItemAttrs,
getGroupAttrs,
replaceAttrs,
cleanupGroupIfEmpty,
validatePriceMoq,
insertItem,
resolveProduct
};