/** * 业务数据目录 —— 导入 / 导出的唯一事实来源。 * * 设计口径(Wayne 2026-10-23 定): * ① **业务数据与系统数据必须分开**。业务表(客户、订单、产品…)可以整包搬去另一个 * 部署;系统表(用户、权限组、组织、会话、设置、审计)**永不随包走** —— * 设置里还存着大模型密钥,会话里是登录态,搬过去等于给别人开后台。 * ② **归属一律给导入人**。同事之间快速同步、或职责交接时,「谁导入就归谁」, * 导完再由管理员在界面上重新分配,比在包里猜账号可靠。 * ③ **冲突不覆盖**。线上已有的同一条记录(同货号 / 同供应商名 / 同订单号…)跳过并报告, * 绝不静默改掉线上数据。 * * 表清单一律按**依赖顺序**排列:父表在前、子表在后。导入时逐行插入,外键始终成立, * 不需要 `PRAGMA foreign_keys=OFF` 这种「先塞进去再祈祷」的做法。 * 新增业务表时,只要在 TABLES 里按依赖顺序补一条,导出、导入、界面三处自动跟上。 */ /** 可选的数据类别:一个类别 = 一组完整的业务闭环,可以单独导出/导入 */ const CATEGORY_LIST = [ { key: 'catalog', label: '产品目录', description: '供应商、产品与品类、阶梯价、价格历史、包装、产品图片、合同认列记录' }, { key: 'customers', label: '客户与往来', description: '客户、联系人、商机、询盘、客户报价单、跟进记录与时间线' }, { key: 'orders', label: '订单与支出', description: '订单、订单明细、订单杂费(快递费/吊卡费等)' }, { key: 'todos', label: '待办与日程', description: '待办事项与日历日程(不含账号信息)' }, { key: 'prospects', label: '新客开发', description: '开发任务与抓取到的线索' } ] /** * 业务表清单(依赖顺序!)。 * * key —— 判定「线上是否已有同一条记录」的列。全部非空才参与去重; * 某列为空时该行按新增处理(宁可多一条,也不误判成重复而丢数据)。 * contentKey —— 没有自然业务键的**流水表**(如价格历史)用整行内容比对。 * 价格历史里同一 SKU 在同一时间戳可能有多条改价(一次批量导入就会这样), * 拿 (sku, changed_at) 当键会把其中一部分当成重复静默丢掉 —— 实测 643 行丢 186 行。 * 整行比对等价于「内容完全相同才算重复」,不会误杀。 * stub —— 被别的表引用、但包里没带父记录时,允许用 key[0] 建一条最小记录 * (例如导入订单时可以只补一个只有公司名的客户壳)。 * label —— 报告里给人看的名字。 * * 刻意不在清单里的表: * users / sessions / organizations / permission_groups / permission_group_permissions / * role_permission_groups / settings / audit_log —— 系统数据,永不导出、永不导入。 * cart_items —— 只是本机「小推车」的临时挑选,不属于需要迁移的业务数据。 */ const TABLES = [ /* ---------------- 产品目录 ---------------- */ { table: 'suppliers', label: '供应商', category: 'catalog', key: ['name'], stub: true }, { table: 'product_groups', label: '产品组', category: 'catalog', key: ['name', 'brand'], stub: true }, /* 合同是产品价格的来源凭据:products.contract_id 指向它,所以必须排在 products 之前 */ { table: 'contracts', label: '合同认列记录', category: 'catalog', key: ['filename', 'processed_at'] }, { table: 'products', label: '产品', category: 'catalog', key: ['sku'] }, { table: 'supplier_quotes', label: '供应商报价', category: 'catalog', key: ['supplier_id', 'quote_date'] }, { table: 'tier_prices', label: '阶梯价', category: 'catalog', key: ['sku', 'moq_from', 'currency'] }, { table: 'price_history', label: '价格历史', category: 'catalog', contentKey: true }, { table: 'item_attributes', label: '产品属性', category: 'catalog', key: ['sku', 'name'] }, { table: 'product_images', label: '产品图片', category: 'catalog', key: ['sku', 'filename'] }, { table: 'product_image_files', label: '产品图片文件', category: 'catalog', key: ['image_id'] }, { table: 'contract_files', label: '合同原件', category: 'catalog', key: ['contract_id'] }, { table: 'product_packaging', label: '包装规格', category: 'catalog', key: ['sku'] }, { table: 'contract_import_jobs', label: '清洗任务', category: 'catalog', key: ['job_no'] }, { table: 'contract_import_items', label: '清洗明细', category: 'catalog', key: ['job_id', 'filename', 'row_no'] }, /* ---------------- 客户与往来 ---------------- */ { table: 'customers', label: '客户', category: 'customers', key: ['company'], stub: true }, { table: 'contacts', label: '联系人', category: 'customers', key: ['customer_id', 'name'] }, { table: 'deals', label: '商机', category: 'customers', key: ['customer_id', 'title'] }, { table: 'customer_quotations', label: '客户报价单', category: 'customers', key: ['quote_no'] }, { table: 'quotation_items', label: '报价明细', category: 'customers', key: ['quotation_id', 'sku'] }, { table: 'inquiries', label: '询盘', category: 'customers', key: ['inquiry_no'] }, { table: 'inquiry_items', label: '询盘明细', category: 'customers', key: ['inquiry_id', 'sku'] }, { table: 'follow_up_records', label: '跟进记录', category: 'customers', key: ['customer_id', 'created_at'] }, /* ---------------- 订单 ---------------- */ { table: 'orders', label: '订单', category: 'orders', key: ['order_no'] }, { table: 'order_items', label: '订单明细', category: 'orders', key: ['order_id', 'sku', 'unit_price'] }, { table: 'order_expenses', label: '订单杂费', category: 'orders', key: ['order_id', 'category', 'amount', 'spent_at'] }, /* activities 引用 customers / deals / orders 三张表,所以必须排在订单之后 —— 否则「时间线关联到哪张订单」这个外键会在导入时解析不到而丢成 NULL。 它属于「客户」类别,但依赖顺序优先于类别分组(类别只影响界面勾选)。 */ { table: 'activities', label: '客户时间线', category: 'customers', key: ['customer_id', 'activity_type', 'created_at'] }, /* ---------------- 待办(todos 引用客户/商机/订单,故排在它们之后) ---------------- */ { table: 'todos', label: '待办与日程', category: 'todos', key: ['title', 'created_at'] }, /* ---------------- 新客开发 ---------------- */ { table: 'prospect_runs', label: '开发任务', category: 'prospects', key: ['name', 'created_at'] }, { table: 'prospect_leads', label: '开发线索', category: 'prospects', key: ['run_id', 'company'] } ] /** * 「归属人」类列:导入时一律改写成执行导入的人。 * 这些列不随包迁移 —— 包里存的是原机器的用户 id,到这边既无意义也可能撞上别人。 */ const OWNER_COLUMNS = new Set(['owner_id', 'created_by', 'assigned_to', 'reviewed_by']) /** 归属组织:导入到当前部署的哪个组织(多组织阶段目前只有一个主组织) */ const ORG_COLUMNS = new Set(['org_id']) const CATEGORY_KEYS = CATEGORY_LIST.map(item => item.key) /** 按类别取表(保持依赖顺序) */ function tablesOfCategory(category) { return TABLES.filter(item => item.category === category) } /** 展开选择:'all' 或省略时返回全部业务表 */ function resolveCategories(input) { const list = Array.isArray(input) ? input.map(item => String(item || '').trim()).filter(Boolean) : String(input || '').split(',').map(item => item.trim()).filter(Boolean) if (!list.length || list.includes('all')) return CATEGORY_KEYS.slice() const unknown = list.filter(item => !CATEGORY_KEYS.includes(item)) if (unknown.length) { const error = new Error(`未知的数据类别:${unknown.join('、')}`) error.statusCode = 400 throw error } /* 去重 + 保持 CATEGORY_LIST 的固定顺序,报告顺序才稳定 */ return CATEGORY_KEYS.filter(key => list.includes(key)) } module.exports = { CATEGORY_LIST, CATEGORY_KEYS, TABLES, OWNER_COLUMNS, ORG_COLUMNS, tablesOfCategory, resolveCategories }