feat(supply-chain): complete product archive data pipeline
This commit is contained in:
parent
501f561eae
commit
2bd31cc06b
@ -5,6 +5,16 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DB_NAME?schema=public
|
||||
|
||||
# 产品全量档案的聚水潭 SQL Server 只读来源
|
||||
JST_REPORT_DB_HOST=
|
||||
JST_REPORT_DB_PORT=1433
|
||||
JST_REPORT_DB_NAME=
|
||||
JST_REPORT_DB_SCHEMA=rpt
|
||||
JST_REPORT_DB_USER=
|
||||
JST_REPORT_DB_PASSWORD=
|
||||
JST_REPORT_DB_ENCRYPT=true
|
||||
JST_REPORT_DB_TRUST_SERVER_CERTIFICATE=true
|
||||
|
||||
# OA 新品流程看板快照来源(MySQL)
|
||||
OA_BOARD_SOURCE_DB_HOST=
|
||||
OA_BOARD_SOURCE_DB_PORT=3306
|
||||
|
||||
85
furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md
Normal file
85
furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md
Normal file
@ -0,0 +1,85 @@
|
||||
# 供应链产品全量档案字段来源对照表
|
||||
|
||||
更新时间:2026-08-07
|
||||
|
||||
## 1. 合并与追踪规则
|
||||
|
||||
- 汇总表:`SupplyChainProductArchive`
|
||||
- 唯一合并键:`barcode`
|
||||
- 来源记录:`SupplyChainProductArchiveSource`
|
||||
- 字段级来源:`SupplyChainProductArchive.fieldSourceMap`
|
||||
- 聚水潭来源只读取 `JSReportReplica.rpt.jst_sku` 中 `item_type = '成品'` 的行。
|
||||
- `jst_sku` 只更新本表明确列出的字段;空值不覆盖其他来源的已有值。
|
||||
- 已由 `JST_SKU` 提供且有值的字段,不再被产品开发同步覆盖。
|
||||
- 一级类目由 `SupplyChainProductArchiveCategoryConfig` 按 JST 二级类目匹配,未配置时保持空值。
|
||||
- 创建时间优先级固定为:`云杉商品资料.xlsx` 的“创建时间” > `jst_sku.created` > 产品开发创建时间。
|
||||
- 云杉文件只在部署或补数时通过导入脚本读取一次,不进入 Git,页面运行时不读取 Excel。
|
||||
- 体积统一由同一来源记录的长、宽、高相乘计算;任一尺寸缺失、不是有效数字或不大于 0 时保持空值。
|
||||
- 未列出的字段不猜测、不兜底,保持原来源或空值。
|
||||
|
||||
## 2. 当前字段对照
|
||||
|
||||
| 全量档案字段 | 页面表头 | JST_SKU 来源 | 产品开发来源 | 其他来源 | 转换规则 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `barcode` | 条码 | `rpt.jst_sku.sku_id` | `external.product_catalog_wide_v1.barcode_business_key` | | 去空格并转大写;档案唯一键 |
|
||||
| `spu` | SPU | `rpt.jst_sku.i_id` | `archive_spu` | | 去空格并转大写 |
|
||||
| `sku` | SKU | `rpt.jst_sku.other_7` | `archive_sku` | | 去空格并转大写;JST 空值不兜底 |
|
||||
| `image` | 图片 | 暂不接入 | `image_url`,空时取 `archive_image` | | JST 图片待单独确认 |
|
||||
| `businessCreatedAt` | 创建时间 | `rpt.jst_sku.created` | `product_development_created_at`,空时取 `source_created_at` | 云杉文件“创建时间”按条码匹配 | 按固定优先级覆盖 |
|
||||
| `categoryL1` | 一级类目 | | `sales_category_name` | `SupplyChainProductArchiveCategoryConfig.categoryL1` | JST 行按完整二级类目匹配配置表 |
|
||||
| `categoryL2` | 二级类目 | `rpt.jst_sku.category` | `archive_product_category_name` | | JST 原样保留完整类目文本 |
|
||||
| `productName` | 商品名称 | `rpt.jst_sku.name` | `archive_product_name` | | 原样保留 |
|
||||
| `designer` | 设计师 | 暂不接入 | `archive_designer` | | |
|
||||
| `productStatus` | 商品状态 | `rpt.jst_sku.other_5` | `sku_sales_status` | | 只接受“销售、停产、退市” |
|
||||
| `material` | 材质 | `rpt.jst_sku.other_10` | `archive_material` | | 原样保留 |
|
||||
| `color` | 颜色 | `rpt.jst_sku.properties_value` | `archive_color` | | 原样保留 |
|
||||
| `weight` | 重量 | `rpt.jst_sku.weight` | `sample_weight_kg` | | 转十进制数;空值不覆盖 |
|
||||
| `length` | 长 | `rpt.jst_sku.l` | `package_length_cm` | | 转十进制数;空值不覆盖 |
|
||||
| `width` | 宽 | `rpt.jst_sku.w` | `package_width_cm` | | 转十进制数;空值不覆盖 |
|
||||
| `height` | 高 | `rpt.jst_sku.h` | `package_height_cm` | | 转十进制数;空值不覆盖 |
|
||||
| `volume` | 体积 | `rpt.jst_sku.l * w * h` | `package_length_cm * package_width_cm * package_height_cm` | | 不读取来源体积列,统一按长宽高计算 |
|
||||
| `packType` | 单双包 | `rpt.jst_sku.other_8` | `package_set_count` | | 1-9 转为非双包至九包;已是标准文字时原样保留 |
|
||||
| `productTag` | 商品标签 | `rpt.jst_sku.labels` | `product_tag` | | 原样保留 |
|
||||
| `installDocId` | 安装图纸/视频 ID | `rpt.jst_sku.other_9` | `drawing_id` | | 原样保留 |
|
||||
| `spec` | 产品尺寸 | 从 `rpt.jst_sku.name` 提取 | `archive_spec` | | 提取三轴尺寸,统一 `/` 分隔,保留小数和范围 |
|
||||
| `brand` | 品牌 | `rpt.jst_sku.brand` | `brand_name` | | 原样保留 |
|
||||
| `orderType` | 叫货类型 | 暂不接入 | `purchase_order_method` | | |
|
||||
| `storageType` | 入库/代发 | 暂不接入 | `archive_shipping_method` | | |
|
||||
| `isSeasonal` | 是否季节性产品 | 暂无来源 | 暂无来源 | | 保持空值 |
|
||||
| `replaceCount` | 替换款数量 | 暂无来源 | 暂无来源 | | 保持空值 |
|
||||
| `supplierName` | 供应商 | `rpt.jst_sku.supplier_name` | `archive_supplier_name` | | 原样保留 |
|
||||
| `purchaseOwner` | 采购 | 暂不接入 | `archive_purchase_owner` | | 只取流程采购负责人,不用产品负责人兜底 |
|
||||
| `minimumOrderQuantity` | 产品起订量 | 暂不接入 | `archive_minimum_order_quantity` | | |
|
||||
|
||||
## 3. JST_SKU 第一批字段覆盖率
|
||||
|
||||
统计范围:`item_type = '成品'`,共 65,164 行。
|
||||
|
||||
| 来源字段 | 对应档案字段 | 非空行数 |
|
||||
| --- | --- | ---: |
|
||||
| `sku_id` | 条码 | 65,164 |
|
||||
| `i_id` | SPU | 65,164 |
|
||||
| `name` | 商品名称、规格解析 | 65,164 |
|
||||
| `other_7` | SKU | 63,537 |
|
||||
| `other_5` | 商品状态 | 63,771 |
|
||||
| `other_9` | 安装图纸/视频 ID | 24,792 |
|
||||
| `other_10` | 材质 | 31,394 |
|
||||
| `properties_value` | 颜色 | 63,855 |
|
||||
| `category` | 二级类目 | 63,539 |
|
||||
| `weight` | 重量 | 35,754 |
|
||||
| `l` | 长 | 38,224 |
|
||||
| `w` | 宽 | 38,083 |
|
||||
| `h` | 高 | 38,124 |
|
||||
| `other_8` | 单双包 | 61,908 |
|
||||
| `labels` | 商品标签 | 以本次来源库实时数据为准 |
|
||||
| `brand` | 品牌 | 以本次来源库实时数据为准 |
|
||||
| `supplier_name` | 供应商 | 以本次来源库实时数据为准 |
|
||||
|
||||
## 4. 来源代码
|
||||
|
||||
| 来源代码 | 来源系统 | Schema | 对象 |
|
||||
| --- | --- | --- | --- |
|
||||
| `JST_SKU` | 聚水潭 JSReportReplica | `rpt` | `jst_sku` |
|
||||
| `PRODUCT_DEVELOPMENT` | 家具产品开发系统 | `external` | `product_catalog_wide_v1` |
|
||||
| `CATEGORY_CONFIG` | 供应链中心类目配置 | `public` | `SupplyChainProductArchiveCategoryConfig` |
|
||||
| `YUNSHAN_PRODUCT_EXPORT` | 云杉商品资料 | Excel 工作表名 | Excel 文件名 |
|
||||
@ -17,6 +17,8 @@
|
||||
"prisma:push": "node scripts/prisma-db-push-local.js",
|
||||
"prisma:studio": "prisma studio",
|
||||
"sync:supply-chain-product-archive": "tsx scripts/sync-supply-chain-product-archive.ts",
|
||||
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
|
||||
"import:yunshan-created-at": "tsx scripts/import-yunshan-product-created-at.ts",
|
||||
"db:verify-master-data": "node scripts/verify-master-data-foundation.js",
|
||||
"db:verify-master-data:audit": "node scripts/verify-master-data-foundation.js --output .master-data-audits/foundation-hard-ready-gate-current.json --summary-output .master-data-audits/foundation-hard-ready-gate-current.summary.json --summary-stdout",
|
||||
"db:verify-master-data:strict": "node scripts/verify-master-data-foundation.js --require-applied",
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
ALTER TABLE "IntakeNonIntegratedSkuItem"
|
||||
ADD COLUMN "sourceWeight" DECIMAL(18, 3),
|
||||
ADD COLUMN "sourceBrand" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "sourceCreatedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "sourceUpdatedAt" TIMESTAMP(3);
|
||||
@ -0,0 +1,3 @@
|
||||
-- Product archive rows use barcode as the unique item identity. SKU codes may
|
||||
-- repeat across different barcodes and therefore remain searchable, not unique.
|
||||
DROP INDEX IF EXISTS "IntakeNonIntegratedSkuItem_skuCode_non_empty_key";
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "fieldSourceMap" JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@ -0,0 +1,99 @@
|
||||
CREATE TABLE "SupplyChainProductArchiveCategoryConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"categoryL2" TEXT NOT NULL,
|
||||
"categoryL1" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SupplyChainProductArchiveCategoryConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SupplyChainProductArchiveCategoryConfig_categoryL2_key"
|
||||
ON "SupplyChainProductArchiveCategoryConfig"("categoryL2");
|
||||
CREATE INDEX "SupplyChainProductArchiveCategoryConfig_categoryL1_idx"
|
||||
ON "SupplyChainProductArchiveCategoryConfig"("categoryL1");
|
||||
CREATE INDEX "SupplyChainProductArchiveCategoryConfig_status_idx"
|
||||
ON "SupplyChainProductArchiveCategoryConfig"("status");
|
||||
|
||||
INSERT INTO "SupplyChainProductArchiveCategoryConfig"
|
||||
("id", "categoryL2", "categoryL1", "status", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
('category-config-001', '住宅家具 - 书桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-002', '住宅家具 - 鞋柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-003', '住宅家具 - 门厅/玄关柜/玄关架', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-004', '住宅家具 - 餐边柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-005', '住宅家具 - 电视柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-006', '住宅家具 - 角柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-007', '住宅家具 - 书架', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-008', '住宅家具 - 书柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-009', '住宅家具 - 厨房置物架/角架', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-010', '住宅家具 - 床头柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-011', '住宅家具 - 电脑桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-012', '住宅家具 - 梳妆台/桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-013', '商业/办公家具 - 演讲台', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-014', '住宅家具 - 折叠桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-015', '住宅家具 - 餐桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-016', '住宅家具 - 电脑椅', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-017', '住宅家具 - 餐椅', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-018', '住宅家具 - 梳妆凳', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-019', '住宅家具 - 衣帽架', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-020', '住宅家具 - 化妆镜', '镜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-021', '住宅家具 - 花架/花几', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-022', '住宅家具 - 搁板/置物架', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-023', '住宅家具 - 书桌收纳置物架', '收纳类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-024', '住宅家具 - 厨房储物架/厨房置物架', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-025', '住宅家具 - 角几/边几', '几类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-026', '住宅家具 - 茶几', '几类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-027', '住宅家具 - 办公升降台/升降桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-028', '商业/办公家具 - 文具边柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-029', '住宅家具 - 斗柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-030', '住宅家具 - 穿衣镜', '镜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-031', '住宅家具 - 矮凳', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-032', '住宅家具 - 其它凳子', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-033', '住宅家具 - 布艺沙发', '沙发类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-034', '住宅家具 - 床垫类', '床类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-035', '住宅家具 - 平板手推车/工具车', '收纳类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-036', '住宅家具 - 炕几', '几类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-037', '住宅家具 - 换鞋凳', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-038', '住宅家具 - 懒人沙发', '沙发类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-039', '住宅家具 - 家居收纳产品', '收纳类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-040', '住宅家具 - 其它家具配件', '第三方配件', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-041', '住宅家具 - 休闲椅', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-042', '住宅家具 - 折叠床', '床类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-043', '住宅家具 - 窝/屋/帐篷', '户外类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-044', '商业/办公家具 - 会议桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-045', '住宅家具 - 收纳洞洞板', '收纳类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-046', '住宅家具 - 洞洞板', '收纳类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-047', '住宅家具 - 功能沙发', '沙发类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-048', '住宅家具 - 沙发床', '沙发类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-049', '住宅家具 - 收纳柜', '收纳类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-050', '住宅家具 - 收纳筐', '收纳类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-051', '住宅家具 - 鞋架', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-052', '住宅家具 - 儿童学习桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-053', '住宅家具 - 衣柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-054', '住宅家具 - 折叠床/午休床', '床类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-055', '住宅家具 - 折叠椅/躺椅', '户外类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-056', '住宅家具 - 儿童餐椅', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-057', '商业/办公家具 - 组合/屏风工作位', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-058', '其他 - 配件', '其他', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-059', '住宅家具 - 衣物洗/晒/护理用具', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-060', '住宅家具 - 实木床', '床类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-061', '住宅家具 - 卫浴/置物用具', '收纳类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-062', '住宅家具 - 儿童学习椅', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-063', '住宅家具 - 板木结合床', '床类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-064', '住宅家具 - 家用梯', '其他', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-065', '其他 - 赠品', '其他', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-066', '住宅家具 - 电竞椅', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-067', '住宅家具 - 酒吧椅', '坐具类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-068', '住宅家具 - 飘窗柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-069', '其他 - 福利品', '其他', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-070', '住宅家具 - 电动升降桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-071', '住宅家具 - 办公桌', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-072', '其他 - 虚拟产品', '其他', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-073', '住宅家具 - 功能床', '床类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-074', '住宅家具 - 皮艺沙发', '沙发类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-075', '住宅家具 - 皮艺床', '床类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-076', '商业/办公家具 - 前台/接待台(新增)', '桌类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-077', '住宅家具 - 吊柜/壁柜', '柜类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('category-config-078', '住宅家具 - 主机箱托架', '架类', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
@ -480,6 +480,7 @@ model SupplyChainProductArchive {
|
||||
purchaseOwner String @default("")
|
||||
minimumOrderQuantity String @default("")
|
||||
firstSourceCode String @default("PRODUCT_DEVELOPMENT")
|
||||
fieldSourceMap Json @default("{}")
|
||||
sources SupplyChainProductArchiveSource[]
|
||||
imageEmbedding ProductArchiveImageEmbedding?
|
||||
createdAt DateTime @default(now())
|
||||
@ -494,6 +495,18 @@ model SupplyChainProductArchive {
|
||||
@@index([firstSourceCode, createdAt])
|
||||
}
|
||||
|
||||
model SupplyChainProductArchiveCategoryConfig {
|
||||
id String @id @default(cuid())
|
||||
categoryL2 String @unique
|
||||
categoryL1 String
|
||||
status String @default("ACTIVE")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([categoryL1])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model SupplyChainProductArchiveSource {
|
||||
id String @id @default(cuid())
|
||||
archiveId String
|
||||
|
||||
167
furniture-3d-system/scripts/import-yunshan-product-created-at.ts
Normal file
167
furniture-3d-system/scripts/import-yunshan-product-created-at.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
import XLSX from 'xlsx'
|
||||
import type { YunshanCreatedAtSourceRow } from '../src/server/services/yunshan-product-created-at.service'
|
||||
|
||||
loadEnvConfig(process.cwd())
|
||||
|
||||
const CONFIRM_TOKEN = 'import-yunshan-product-created-at'
|
||||
const BARCODE_HEADER = '条形码'
|
||||
const CREATED_AT_HEADER = '创建时间'
|
||||
const DEFAULT_SHEET_NAME = '商品资料导出(含明细)'
|
||||
|
||||
type CliOptions = {
|
||||
apply: boolean
|
||||
confirm: string
|
||||
expectedDatabaseIdentity: string
|
||||
expectedFileSha256: string
|
||||
file: string
|
||||
help: boolean
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
apply: false,
|
||||
confirm: '',
|
||||
expectedDatabaseIdentity: '',
|
||||
expectedFileSha256: '',
|
||||
file: '',
|
||||
help: false,
|
||||
}
|
||||
argv.forEach((arg) => {
|
||||
if (arg === '--apply') options.apply = true
|
||||
else if (arg === '--dry-run') options.apply = false
|
||||
else if (arg === '--help' || arg === '-h') options.help = true
|
||||
else if (arg.startsWith('--file=')) options.file = arg.slice('--file='.length)
|
||||
else if (arg.startsWith('--confirm=')) options.confirm = arg.slice('--confirm='.length)
|
||||
else if (arg.startsWith('--expected-database-identity=')) {
|
||||
options.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length)
|
||||
} else if (arg.startsWith('--expected-file-sha256=')) {
|
||||
options.expectedFileSha256 = arg.slice('--expected-file-sha256='.length).toLowerCase()
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
function databaseIdentityFromUrl(databaseUrl: string) {
|
||||
const parsed = new URL(databaseUrl)
|
||||
return [
|
||||
parsed.protocol.replace(/:$/, ''),
|
||||
decodeURIComponent(parsed.username || ''),
|
||||
parsed.hostname,
|
||||
parsed.port || '',
|
||||
parsed.pathname.replace(/^\//, ''),
|
||||
parsed.searchParams.get('schema') ?? '',
|
||||
].join('|')
|
||||
}
|
||||
|
||||
function fileSha256(filePath: string) {
|
||||
return createHash('sha256').update(readFileSync(filePath)).digest('hex')
|
||||
}
|
||||
|
||||
function cellText(sheet: XLSX.WorkSheet, row: number, column: number) {
|
||||
const cell = sheet[XLSX.utils.encode_cell({ r: row, c: column })]
|
||||
if (!cell || cell.v === null || cell.v === undefined) return ''
|
||||
return String(cell.w ?? cell.v).trim()
|
||||
}
|
||||
|
||||
function readSourceRows(filePath: string) {
|
||||
const workbook = XLSX.readFile(filePath, { cellDates: true })
|
||||
const sheetName = workbook.SheetNames.includes(DEFAULT_SHEET_NAME)
|
||||
? DEFAULT_SHEET_NAME
|
||||
: workbook.SheetNames[0]
|
||||
const sheet = workbook.Sheets[sheetName]
|
||||
if (!sheet?.['!ref']) throw new Error('云杉商品资料没有可读取的工作表')
|
||||
|
||||
const range = XLSX.utils.decode_range(sheet['!ref'])
|
||||
const headers = new Map<string, number>()
|
||||
for (let column = range.s.c; column <= range.e.c; column += 1) {
|
||||
headers.set(cellText(sheet, range.s.r, column), column)
|
||||
}
|
||||
const barcodeColumn = headers.get(BARCODE_HEADER)
|
||||
const createdAtColumn = headers.get(CREATED_AT_HEADER)
|
||||
if (barcodeColumn === undefined || createdAtColumn === undefined) {
|
||||
throw new Error(`云杉商品资料必须包含“${BARCODE_HEADER}”和“${CREATED_AT_HEADER}”列`)
|
||||
}
|
||||
|
||||
const rows: YunshanCreatedAtSourceRow[] = []
|
||||
for (let row = range.s.r + 1; row <= range.e.r; row += 1) {
|
||||
rows.push({
|
||||
rowNumber: row + 1,
|
||||
barcode: cellText(sheet, row, barcodeColumn),
|
||||
createdAtText: cellText(sheet, row, createdAtColumn),
|
||||
})
|
||||
}
|
||||
return { rows, sheetName }
|
||||
}
|
||||
|
||||
function assertApplyGuards(options: CliOptions, sha256: string) {
|
||||
if (!options.apply) return
|
||||
if (options.confirm !== CONFIRM_TOKEN) {
|
||||
throw new Error(`真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}`)
|
||||
}
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim() ?? ''
|
||||
if (!databaseUrl) throw new Error('真实写库必须先配置 DATABASE_URL')
|
||||
const databaseIdentity = databaseIdentityFromUrl(databaseUrl)
|
||||
if (!options.expectedDatabaseIdentity) {
|
||||
throw new Error(
|
||||
`真实写库必须显式传入 --expected-database-identity=${databaseIdentity}`,
|
||||
)
|
||||
}
|
||||
if (options.expectedDatabaseIdentity !== databaseIdentity) {
|
||||
throw new Error('目标 PostgreSQL 身份与 DATABASE_URL 不一致,禁止写库')
|
||||
}
|
||||
if (!options.expectedFileSha256) {
|
||||
throw new Error(`真实写库必须显式传入 --expected-file-sha256=${sha256}`)
|
||||
}
|
||||
if (options.expectedFileSha256 !== sha256) {
|
||||
throw new Error('云杉商品资料文件摘要不一致,禁止写库')
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npm run import:yunshan-created-at -- --file="D:\\供应商\\云杉资料\\云杉商品资料.xlsx" --dry-run
|
||||
npm run import:yunshan-created-at -- --file="..." --apply --confirm=${CONFIRM_TOKEN} --expected-database-identity=... --expected-file-sha256=...
|
||||
|
||||
默认只预览。真实写入必须同时确认目标数据库身份和源文件 SHA-256。
|
||||
`.trim())
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) return printHelp()
|
||||
if (!options.file.trim()) throw new Error('必须显式传入 --file')
|
||||
|
||||
const filePath = path.resolve(options.file)
|
||||
if (!existsSync(filePath)) throw new Error(`云杉商品资料不存在:${filePath}`)
|
||||
const sha256 = fileSha256(filePath)
|
||||
assertApplyGuards(options, sha256)
|
||||
const source = readSourceRows(filePath)
|
||||
const [{ prisma }, { yunshanProductCreatedAtService }] = await Promise.all([
|
||||
import('../src/lib/prisma'),
|
||||
import('../src/server/services/yunshan-product-created-at.service'),
|
||||
])
|
||||
try {
|
||||
const result = await yunshanProductCreatedAtService.importRows({
|
||||
rows: source.rows,
|
||||
apply: options.apply,
|
||||
fileName: path.basename(filePath),
|
||||
sheetName: source.sheetName,
|
||||
fileModifiedAt: statSync(filePath).mtime,
|
||||
})
|
||||
console.log(JSON.stringify({ fileSha256: sha256, ...result }, null, 2))
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
139
furniture-3d-system/scripts/sync-jst-sku-product-archive.ts
Normal file
139
furniture-3d-system/scripts/sync-jst-sku-product-archive.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
|
||||
loadEnvConfig(process.cwd())
|
||||
|
||||
type CliOptions = {
|
||||
apply: boolean
|
||||
confirm: string
|
||||
expectedDatabaseIdentity: string
|
||||
expectedSourceDatabaseIdentity: string
|
||||
help: boolean
|
||||
spu: string
|
||||
}
|
||||
|
||||
export const CONFIRM_TOKEN = 'sync-jst-sku-product-archive'
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
apply: false,
|
||||
confirm: '',
|
||||
expectedDatabaseIdentity: '',
|
||||
expectedSourceDatabaseIdentity: '',
|
||||
help: false,
|
||||
spu: '',
|
||||
}
|
||||
|
||||
argv.forEach((arg) => {
|
||||
if (arg === '--apply') options.apply = true
|
||||
else if (arg === '--dry-run') options.apply = false
|
||||
else if (arg === '--help' || arg === '-h') options.help = true
|
||||
else if (arg.startsWith('--spu=')) options.spu = arg.slice('--spu='.length)
|
||||
else if (arg.startsWith('--confirm=')) options.confirm = arg.slice('--confirm='.length)
|
||||
else if (arg.startsWith('--expected-database-identity=')) {
|
||||
options.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length)
|
||||
} else if (arg.startsWith('--expected-source-database-identity=')) {
|
||||
options.expectedSourceDatabaseIdentity = arg.slice(
|
||||
'--expected-source-database-identity='.length,
|
||||
)
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
function databaseIdentityFromUrl(databaseUrl: string) {
|
||||
const parsed = new URL(databaseUrl)
|
||||
return [
|
||||
parsed.protocol.replace(/:$/, ''),
|
||||
decodeURIComponent(parsed.username || ''),
|
||||
parsed.hostname,
|
||||
parsed.port || '',
|
||||
parsed.pathname.replace(/^\//, ''),
|
||||
parsed.searchParams.get('schema') ?? '',
|
||||
].join('|')
|
||||
}
|
||||
|
||||
function sourceDatabaseIdentity(env: NodeJS.ProcessEnv = process.env) {
|
||||
const host = env.JST_REPORT_DB_HOST?.trim() ?? ''
|
||||
const database = env.JST_REPORT_DB_NAME?.trim() ?? ''
|
||||
const user = env.JST_REPORT_DB_USER?.trim() ?? ''
|
||||
if (!host || !database || !user) return ''
|
||||
return [
|
||||
'sqlserver',
|
||||
user,
|
||||
host,
|
||||
env.JST_REPORT_DB_PORT?.trim() || '1433',
|
||||
database,
|
||||
env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
|
||||
].join('|')
|
||||
}
|
||||
|
||||
export function assertApplyGuards(
|
||||
options: CliOptions,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
if (!options.apply) return
|
||||
if (options.confirm !== CONFIRM_TOKEN) {
|
||||
throw new Error(`真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`)
|
||||
}
|
||||
|
||||
const databaseUrl = env.DATABASE_URL?.trim() ?? ''
|
||||
if (!databaseUrl) throw new Error('真实写库必须先配置 DATABASE_URL。')
|
||||
const actualDatabaseIdentity = databaseIdentityFromUrl(databaseUrl)
|
||||
if (!options.expectedDatabaseIdentity) {
|
||||
throw new Error(
|
||||
`真实写库必须显式传入 --expected-database-identity=${actualDatabaseIdentity}。`,
|
||||
)
|
||||
}
|
||||
if (options.expectedDatabaseIdentity !== actualDatabaseIdentity) {
|
||||
throw new Error('目标 PostgreSQL 身份与 DATABASE_URL 不一致,禁止写库。')
|
||||
}
|
||||
|
||||
const actualSourceIdentity = sourceDatabaseIdentity(env)
|
||||
if (!actualSourceIdentity) throw new Error('真实写库必须先配置 JST_REPORT_DB_*。')
|
||||
if (!options.expectedSourceDatabaseIdentity) {
|
||||
throw new Error(
|
||||
`真实写库必须显式传入 --expected-source-database-identity=${actualSourceIdentity}。`,
|
||||
)
|
||||
}
|
||||
if (options.expectedSourceDatabaseIdentity !== actualSourceIdentity) {
|
||||
throw new Error('来源 SQL Server 身份与 JST_REPORT_DB_* 不一致,禁止写库。')
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npm run sync:jst-sku-product-archive -- --spu=CJ0031 [--dry-run]
|
||||
npm run sync:jst-sku-product-archive -- --spu=CJ0031 --apply --confirm=${CONFIRM_TOKEN} --expected-database-identity=... --expected-source-database-identity=...
|
||||
|
||||
默认只预览,不写数据库。真实写入必须同时确认目标 PostgreSQL 和来源 SQL Server 身份。
|
||||
`.trim())
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) return printHelp()
|
||||
if (!options.spu.trim()) throw new Error('必须显式传入 --spu。')
|
||||
assertApplyGuards(options)
|
||||
|
||||
const [{ prisma }, { jstSkuProductArchiveSyncService }] = await Promise.all([
|
||||
import('../src/lib/prisma'),
|
||||
import('../src/server/services/jst-sku-product-archive-sync.service'),
|
||||
])
|
||||
try {
|
||||
const result = await jstSkuProductArchiveSyncService.syncSpu({
|
||||
spu: options.spu,
|
||||
apply: options.apply,
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
@ -0,0 +1,40 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { supplyChainProductArchiveCategoryConfigService } from '@/server/services/supply-chain-product-archive-category-config.service'
|
||||
|
||||
const updateSchema = z.object({
|
||||
categoryL2: z.string().trim().min(1).max(160),
|
||||
categoryL1: z.string().trim().min(1).max(160),
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']),
|
||||
}).strict()
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '请求体格式不正确', 400, ctx.requestId)
|
||||
}
|
||||
const parsed = updateSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '类目配置内容不正确', 400, ctx.requestId)
|
||||
}
|
||||
const result = await supplyChainProductArchiveCategoryConfigService.update({ id, ...parsed.data })
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
},
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { supplyChainProductArchiveCategoryConfigService } from '@/server/services/supply-chain-product-archive-category-config.service'
|
||||
|
||||
const statusSchema = z.enum(['ACTIVE', 'INACTIVE'])
|
||||
const listQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).default(100),
|
||||
search: z.string().trim().max(160).optional(),
|
||||
status: statusSchema.optional(),
|
||||
})
|
||||
const createSchema = z.object({
|
||||
categoryL2: z.string().trim().min(1).max(160),
|
||||
categoryL1: z.string().trim().min(1).max(160),
|
||||
}).strict()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const parsed = listQuerySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '类目配置筛选条件不正确', 400, ctx.requestId)
|
||||
}
|
||||
const result = await supplyChainProductArchiveCategoryConfigService.list(parsed.data)
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
},
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '请求体格式不正确', 400, ctx.requestId)
|
||||
}
|
||||
const parsed = createSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '二级类目和一级类目不能为空', 400, ctx.requestId)
|
||||
}
|
||||
const result = await supplyChainProductArchiveCategoryConfigService.create(parsed.data)
|
||||
return jsonSuccess(result, 201, ctx.requestId)
|
||||
},
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { supplyChainProductArchiveChangeLogService } from '@/server/services/supply-chain-product-archive-change-log.service'
|
||||
|
||||
const barcodeSchema = z.string().trim().min(1).max(120)
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ barcode: string }> },
|
||||
) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { barcode: rawBarcode } = await context.params
|
||||
const barcode = barcodeSchema.safeParse(decodeURIComponent(rawBarcode))
|
||||
if (!barcode.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'条码格式不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const page = Math.max(1, Number(request.nextUrl.searchParams.get('page') ?? '1') || 1)
|
||||
const pageSize = Math.min(
|
||||
100,
|
||||
Math.max(1, Number(request.nextUrl.searchParams.get('pageSize') ?? '50') || 50),
|
||||
)
|
||||
const result = await supplyChainProductArchiveChangeLogService.listByBarcode({
|
||||
barcode: barcode.data,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
},
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -82,6 +82,7 @@ export function useSupplyChainTabs() {
|
||||
export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
|
||||
'/supply-chain/dashboard': '工作台',
|
||||
'/supply-chain/database/product-archive': '产品全量档案',
|
||||
'/supply-chain/database/category-config': '类目配置',
|
||||
'/supply-chain/purchase/daily-affairs': '日常事项',
|
||||
'/supply-chain/purchase/price-change': '产品变价',
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
|
||||
{
|
||||
items: [
|
||||
{ label: '产品全量档案', route: '/supply-chain/database/product-archive' },
|
||||
{ label: '类目配置', route: '/supply-chain/database/category-config' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Edit3, Plus, RefreshCw, Search, ToggleLeft, ToggleRight, X } from 'lucide-react'
|
||||
|
||||
type CategoryConfigStatus = 'ACTIVE' | 'INACTIVE'
|
||||
type CategoryConfig = {
|
||||
id: string
|
||||
categoryL2: string
|
||||
categoryL1: string
|
||||
status: CategoryConfigStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
type PageInfo = { page: number; pageSize: number; total: number; totalPages: number }
|
||||
type Draft = { id?: string; categoryL2: string; categoryL1: string; status: CategoryConfigStatus }
|
||||
|
||||
const EMPTY_PAGE: PageInfo = { page: 1, pageSize: 100, total: 0, totalPages: 1 }
|
||||
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
async function readResponse(response: Response) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || '请求失败,请稍后重试')
|
||||
}
|
||||
return payload.data
|
||||
}
|
||||
|
||||
export function CategoryConfigView() {
|
||||
const [items, setItems] = useState<CategoryConfig[]>([])
|
||||
const [pageInfo, setPageInfo] = useState<PageInfo>(EMPTY_PAGE)
|
||||
const [page, setPage] = useState(1)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState<'ALL' | CategoryConfigStatus>('ALL')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '100' })
|
||||
if (search) params.set('search', search)
|
||||
if (status !== 'ALL') params.set('status', status)
|
||||
const data = await readResponse(await fetch(`/api/supply-chain/category-config?${params.toString()}`, { cache: 'no-store' }))
|
||||
setItems(data.items ?? [])
|
||||
setPageInfo(data.pageInfo ?? EMPTY_PAGE)
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, status])
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
const activeCount = useMemo(() => items.filter((item) => item.status === 'ACTIVE').length, [items])
|
||||
|
||||
const submitSearch = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setPage(1)
|
||||
setSearch(searchInput.trim())
|
||||
}
|
||||
|
||||
const openCreate = () => setDraft({ categoryL2: '', categoryL1: '', status: 'ACTIVE' })
|
||||
const openEdit = (item: CategoryConfig) => setDraft({
|
||||
id: item.id,
|
||||
categoryL2: item.categoryL2,
|
||||
categoryL1: item.categoryL1,
|
||||
status: item.status,
|
||||
})
|
||||
|
||||
const saveDraft = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!draft || saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
draft.id ? `/api/supply-chain/category-config/${encodeURIComponent(draft.id)}` : '/api/supply-chain/category-config',
|
||||
{
|
||||
method: draft.id ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ categoryL2: draft.categoryL2, categoryL1: draft.categoryL1, status: draft.status }),
|
||||
},
|
||||
)
|
||||
await readResponse(response)
|
||||
setDraft(null)
|
||||
await load()
|
||||
} catch (cause) {
|
||||
window.alert(cause instanceof Error ? cause.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = async (item: CategoryConfig) => {
|
||||
const nextStatus: CategoryConfigStatus = item.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
try {
|
||||
const response = await fetch(`/api/supply-chain/category-config/${encodeURIComponent(item.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ categoryL2: item.categoryL2, categoryL1: item.categoryL1, status: nextStatus }),
|
||||
})
|
||||
await readResponse(response)
|
||||
await load()
|
||||
} catch (cause) {
|
||||
window.alert(cause instanceof Error ? cause.message : '状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-config-page">
|
||||
<header className="category-config-toolbar">
|
||||
<div>
|
||||
<h1>产品全量档案-类目配置</h1>
|
||||
</div>
|
||||
<div className="category-config-toolbar-meta">
|
||||
<span>共 {pageInfo.total} 条</span>
|
||||
<span>当前页启用 {activeCount} 条</span>
|
||||
<button type="button" className="category-config-button primary" onClick={openCreate}>
|
||||
<Plus size={15} /> 新增配置
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form className="category-config-filters" onSubmit={submitSearch}>
|
||||
<label className="category-config-search">
|
||||
<Search size={15} />
|
||||
<input value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="搜索二级类目或一级类目" />
|
||||
</label>
|
||||
<select value={status} onChange={(event) => { setPage(1); setStatus(event.target.value as typeof status) }} aria-label="状态筛选">
|
||||
<option value="ALL">全部状态</option>
|
||||
<option value="ACTIVE">启用</option>
|
||||
<option value="INACTIVE">停用</option>
|
||||
</select>
|
||||
<button type="submit" className="category-config-button">搜索</button>
|
||||
<button type="button" className="category-config-icon-button" title="刷新" aria-label="刷新" onClick={() => void load()}>
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="category-config-table-wrap">
|
||||
<table className="category-config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>二级类目</th>
|
||||
<th>一级类目</th>
|
||||
<th>状态</th>
|
||||
<th>更新时间</th>
|
||||
<th className="category-config-actions-col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td colSpan={5} className="category-config-empty">正在加载...</td></tr>}
|
||||
{!loading && error && <tr><td colSpan={5} className="category-config-empty error">{error}</td></tr>}
|
||||
{!loading && !error && items.length === 0 && <tr><td colSpan={5} className="category-config-empty">暂无配置</td></tr>}
|
||||
{!loading && !error && items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="category-config-l2">{item.categoryL2}</td>
|
||||
<td>{item.categoryL1}</td>
|
||||
<td><span className={`category-config-status ${item.status.toLowerCase()}`}>{item.status === 'ACTIVE' ? '启用' : '停用'}</span></td>
|
||||
<td className="category-config-time">{formatTime(item.updatedAt)}</td>
|
||||
<td className="category-config-actions">
|
||||
<button type="button" title="编辑" aria-label={`编辑 ${item.categoryL2}`} onClick={() => openEdit(item)}><Edit3 size={14} /> 编辑</button>
|
||||
<button type="button" title={item.status === 'ACTIVE' ? '停用' : '启用'} aria-label={`${item.status === 'ACTIVE' ? '停用' : '启用'} ${item.categoryL2}`} onClick={() => void toggleStatus(item)}>
|
||||
{item.status === 'ACTIVE' ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
|
||||
{item.status === 'ACTIVE' ? '停用' : '启用'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer className="category-config-pagination">
|
||||
<span>第 {pageInfo.page} / {pageInfo.totalPages} 页</span>
|
||||
<button type="button" disabled={loading || page <= 1} onClick={() => setPage((current) => Math.max(1, current - 1))}><ArrowLeft size={14} /> 上一页</button>
|
||||
<button type="button" disabled={loading || page >= pageInfo.totalPages} onClick={() => setPage((current) => Math.min(pageInfo.totalPages, current + 1))}>下一页 <ArrowRight size={14} /></button>
|
||||
</footer>
|
||||
|
||||
{draft && (
|
||||
<div className="category-config-modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setDraft(null) }}>
|
||||
<form className="category-config-modal" onSubmit={saveDraft}>
|
||||
<div className="category-config-modal-header">
|
||||
<h2>{draft.id ? '编辑类目配置' : '新增类目配置'}</h2>
|
||||
<button type="button" title="关闭" aria-label="关闭" onClick={() => setDraft(null)}><X size={17} /></button>
|
||||
</div>
|
||||
<label>二级类目<input autoFocus required maxLength={160} value={draft.categoryL2} onChange={(event) => setDraft({ ...draft, categoryL2: event.target.value })} placeholder="例如:住宅家具 - 书桌" /></label>
|
||||
<label>一级类目<input required maxLength={160} value={draft.categoryL1} onChange={(event) => setDraft({ ...draft, categoryL1: event.target.value })} placeholder="例如:桌类" /></label>
|
||||
{draft.id && <label>状态<select value={draft.status} onChange={(event) => setDraft({ ...draft, status: event.target.value as CategoryConfigStatus })}><option value="ACTIVE">启用</option><option value="INACTIVE">停用</option></select></label>}
|
||||
<div className="category-config-modal-actions"><button type="button" onClick={() => setDraft(null)}>取消</button><button type="submit" className="primary" disabled={saving}>{saving ? '保存中...' : '保存'}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.category-config-page { display: flex; flex: 1; min-height: 0; flex-direction: column; background: #fafafa; color: #27272a; }
|
||||
.category-config-toolbar { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; padding: 22px 28px 18px; border-bottom: 1px solid #e4e4e7; background: #fff; }
|
||||
h1 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: 0; }
|
||||
.category-config-toolbar-meta { display: flex; align-items: center; gap: 14px; color: #71717a; font-size: 12px; white-space: nowrap; }
|
||||
.category-config-filters { display: flex; align-items: center; gap: 8px; padding: 14px 28px; border-bottom: 1px solid #e4e4e7; background: #fff; }
|
||||
.category-config-search { display: flex; height: 34px; width: min(380px, 42vw); align-items: center; gap: 8px; padding: 0 10px; border: 1px solid #d4d4d8; border-radius: 5px; color: #71717a; }
|
||||
.category-config-search input { width: 100%; border: 0; outline: 0; color: #27272a; font-size: 13px; background: transparent; }
|
||||
select { height: 34px; padding: 0 28px 0 10px; border: 1px solid #d4d4d8; border-radius: 5px; background: #fff; color: #3f3f46; font-size: 13px; }
|
||||
.category-config-button, .category-config-icon-button { display: inline-flex; height: 34px; align-items: center; justify-content: center; gap: 6px; padding: 0 12px; border: 1px solid #d4d4d8; border-radius: 5px; background: #fff; color: #3f3f46; font-size: 13px; cursor: pointer; }
|
||||
.category-config-button:hover, .category-config-icon-button:hover { background: #f4f4f5; }
|
||||
.category-config-button.primary { border-color: #27272a; background: #27272a; color: #fff; }
|
||||
.category-config-button.primary:hover { background: #3f3f46; }
|
||||
.category-config-icon-button { width: 34px; padding: 0; }
|
||||
.category-config-table-wrap { flex: 1; min-height: 0; overflow: auto; padding: 0 28px; }
|
||||
.category-config-table { width: 100%; border-collapse: collapse; background: #fff; font-size: 13px; }
|
||||
.category-config-table th { position: sticky; top: 0; z-index: 1; height: 42px; padding: 0 14px; border-bottom: 1px solid #d4d4d8; background: #f4f4f5; color: #52525b; font-size: 12px; font-weight: 600; text-align: left; }
|
||||
.category-config-table td { height: 48px; padding: 0 14px; border-bottom: 1px solid #f0f0f1; color: #3f3f46; }
|
||||
.category-config-table tbody tr:hover { background: #fafafa; }
|
||||
.category-config-l2 { color: #18181b; font-weight: 500; }
|
||||
.category-config-time { color: #71717a !important; font-variant-numeric: tabular-nums; }
|
||||
.category-config-status { display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 4px; font-size: 12px; }
|
||||
.category-config-status.active { background: #ecfdf3; color: #15803d; }
|
||||
.category-config-status.inactive { background: #f4f4f5; color: #71717a; }
|
||||
.category-config-actions-col { width: 190px; }
|
||||
.category-config-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.category-config-actions button { display: inline-flex; align-items: center; gap: 4px; border: 0; background: transparent; color: #52525b; font-size: 12px; cursor: pointer; }
|
||||
.category-config-actions button:hover { color: #18181b; }
|
||||
.category-config-empty { height: 160px !important; color: #a1a1aa !important; text-align: center; }
|
||||
.category-config-empty.error { color: #b91c1c !important; }
|
||||
.category-config-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 28px; border-top: 1px solid #e4e4e7; background: #fff; color: #71717a; font-size: 12px; }
|
||||
.category-config-pagination button { display: inline-flex; align-items: center; gap: 4px; height: 30px; padding: 0 9px; border: 1px solid #d4d4d8; border-radius: 4px; background: #fff; color: #52525b; font-size: 12px; cursor: pointer; }
|
||||
.category-config-pagination button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.category-config-modal-backdrop { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(24,24,27,.26); }
|
||||
.category-config-modal { width: min(480px, calc(100vw - 32px)); padding: 20px; border: 1px solid #e4e4e7; border-radius: 8px; background: #fff; box-shadow: 0 18px 50px rgba(24,24,27,.18); }
|
||||
.category-config-modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
||||
.category-config-modal h2 { margin: 0; font-size: 16px; font-weight: 650; }
|
||||
.category-config-modal-header button { display: inline-flex; border: 0; background: transparent; color: #71717a; cursor: pointer; }
|
||||
.category-config-modal label { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; color: #52525b; font-size: 12px; }
|
||||
.category-config-modal input, .category-config-modal select { height: 36px; width: 100%; box-sizing: border-box; padding: 0 10px; border: 1px solid #d4d4d8; border-radius: 5px; outline: 0; color: #27272a; font-size: 13px; }
|
||||
.category-config-modal input:focus, .category-config-modal select:focus { border-color: #52525b; }
|
||||
.category-config-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; }
|
||||
.category-config-modal-actions button { height: 34px; padding: 0 13px; border: 1px solid #d4d4d8; border-radius: 5px; background: #fff; color: #52525b; cursor: pointer; }
|
||||
.category-config-modal-actions button.primary { border-color: #27272a; background: #27272a; color: #fff; }
|
||||
.category-config-modal-actions button:disabled { opacity: .55; cursor: wait; }
|
||||
@media (max-width: 760px) { .category-config-toolbar { align-items: flex-start; flex-direction: column; padding: 18px; } .category-config-toolbar-meta { width: 100%; justify-content: space-between; } .category-config-filters { padding: 12px 18px; flex-wrap: wrap; } .category-config-search { width: 100%; } .category-config-table-wrap { padding: 0 18px; } .category-config-pagination { padding: 12px 18px; } }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { CategoryConfigView } from './CategoryConfigView'
|
||||
|
||||
export const metadata = { title: '类目配置 · 供应链中心' }
|
||||
|
||||
export default async function CategoryConfigPage() {
|
||||
await requireSession('/login')
|
||||
return <CategoryConfigView />
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||
|
||||
export function ProductArchiveActions({
|
||||
row,
|
||||
onView,
|
||||
onChangeLog,
|
||||
}: {
|
||||
row: SupplyChainProductArchiveRow
|
||||
onView: (row: SupplyChainProductArchiveRow) => void
|
||||
onChangeLog: (barcode: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="archive-row-actions" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="archive-row-action-link"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onView(row)
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="archive-row-action-link archive-row-more-trigger"
|
||||
aria-expanded={open}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setOpen((current) => !current)
|
||||
}}
|
||||
>
|
||||
更多
|
||||
<ChevronDown size={13} strokeWidth={1.8} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="archive-row-more-menu">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setOpen(false)
|
||||
onChangeLog(row.barcode)
|
||||
}}
|
||||
>
|
||||
商品修改日志
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<style jsx>{`
|
||||
.archive-row-actions {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.archive-row-action-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-row-action-link:hover { color: #1d4ed8; text-decoration: underline; }
|
||||
.archive-row-more-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
min-width: 148px;
|
||||
padding: 5px;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.archive-row-more-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #3f3f46;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-row-more-menu button:hover { background: #f4f4f5; color: #18181b; }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,333 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Download, RefreshCw, X } from 'lucide-react'
|
||||
import type { ProductArchiveChangeLogItem } from '@/lib/supply-chain-product-archive-change-log'
|
||||
import { formatProductArchiveChangeSource } from '@/lib/supply-chain-product-archive-change-log'
|
||||
import { useProductArchiveChangeLog } from './useProductArchiveChangeLog'
|
||||
import {
|
||||
ProductArchiveChangeLogFilters,
|
||||
type ProductArchiveLogFilterValues,
|
||||
} from './ProductArchiveChangeLogFilters'
|
||||
|
||||
const EMPTY_FILTERS: ProductArchiveLogFilterValues = {
|
||||
actor: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
content: '',
|
||||
operation: '',
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function displayContent(log: ProductArchiveChangeLogItem) {
|
||||
if (log.details.kind === 'IMPORTED') return '商品档案导入'
|
||||
const field = log.details.fieldLabel || '商品档案'
|
||||
const previousValue = log.details.previousValue || '-'
|
||||
const nextValue = log.details.nextValue || '-'
|
||||
return `${field}:${previousValue} → ${nextValue}`
|
||||
}
|
||||
|
||||
function displayOperation(log: ProductArchiveChangeLogItem) {
|
||||
if (log.details.kind === 'IMPORTED') return '导入商品档案'
|
||||
return log.source === 'SYSTEM' ? '数据库推送更新' : '系统修改'
|
||||
}
|
||||
|
||||
function csvCell(value: string) {
|
||||
return `"${value.replaceAll('"', '""')}"`
|
||||
}
|
||||
|
||||
function exportLogs(logs: ProductArchiveChangeLogItem[]) {
|
||||
const rows = [
|
||||
['商品编码', '操作内容', '操作', '操作人', '操作时间'],
|
||||
...logs.map((log) => [
|
||||
log.barcode,
|
||||
displayContent(log),
|
||||
displayOperation(log),
|
||||
formatProductArchiveChangeSource(log.source, log.actorName, log.details),
|
||||
formatTime(log.createdAt),
|
||||
]),
|
||||
]
|
||||
const blob = new Blob([`\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = '商品修改日志.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function inDateRange(value: string, startDate: string, endDate: string) {
|
||||
const timestamp = new Date(value).getTime()
|
||||
if (startDate) {
|
||||
const start = new Date(`${startDate}T00:00:00`).getTime()
|
||||
if (timestamp < start) return false
|
||||
}
|
||||
if (endDate) {
|
||||
const end = new Date(`${endDate}T23:59:59.999`).getTime()
|
||||
if (timestamp > end) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function ProductArchiveChangeLogDialog({
|
||||
barcode,
|
||||
onClose,
|
||||
}: {
|
||||
barcode: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS)
|
||||
const { logs, pageInfo, loading, error, reload } = useProductArchiveChangeLog(barcode, page)
|
||||
|
||||
const actors = useMemo(
|
||||
() => Array.from(new Set(logs.map((log) => formatProductArchiveChangeSource(log.source, log.actorName, log.details)))).sort(),
|
||||
[logs],
|
||||
)
|
||||
const filteredLogs = useMemo(() => {
|
||||
const contentKeyword = appliedFilters.content.trim().toLowerCase()
|
||||
return logs.filter((log) => {
|
||||
const actor = formatProductArchiveChangeSource(log.source, log.actorName, log.details)
|
||||
const content = `${displayContent(log)} ${log.message}`.toLowerCase()
|
||||
return (
|
||||
(!appliedFilters.actor || actor === appliedFilters.actor) &&
|
||||
(!contentKeyword || content.includes(contentKeyword)) &&
|
||||
(!appliedFilters.operation || log.details.kind === appliedFilters.operation) &&
|
||||
inDateRange(log.createdAt, appliedFilters.startDate, appliedFilters.endDate)
|
||||
)
|
||||
})
|
||||
}, [appliedFilters, logs])
|
||||
|
||||
const updateFilter = <K extends keyof ProductArchiveLogFilterValues>(
|
||||
key: K,
|
||||
value: ProductArchiveLogFilterValues[K],
|
||||
) => setDraftFilters((current) => ({ ...current, [key]: value }))
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraftFilters(EMPTY_FILTERS)
|
||||
setAppliedFilters(EMPTY_FILTERS)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="archive-log-overlay" onMouseDown={onClose}>
|
||||
<section
|
||||
className="archive-log-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="archive-log-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="archive-log-header">
|
||||
<div>
|
||||
<p className="archive-log-eyebrow">商品档案 · 条码 {barcode}</p>
|
||||
<h2 id="archive-log-title">查看商品修改日志</h2>
|
||||
</div>
|
||||
<div className="archive-log-header-actions">
|
||||
<button type="button" onClick={() => exportLogs(filteredLogs)} title="导出当前日志" aria-label="导出当前日志">
|
||||
<Download size={16} strokeWidth={1.8} />
|
||||
</button>
|
||||
<button type="button" onClick={onClose} title="关闭" aria-label="关闭日志">
|
||||
<X size={18} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ProductArchiveChangeLogFilters
|
||||
barcode={barcode}
|
||||
values={draftFilters}
|
||||
actors={actors}
|
||||
onChange={updateFilter}
|
||||
onSearch={() => {
|
||||
setAppliedFilters(draftFilters)
|
||||
setPage(1)
|
||||
}}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
<div className="archive-log-table-area">
|
||||
<div className="archive-log-table-wrap">
|
||||
<table className="archive-log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>商品编码</th>
|
||||
<th>操作内容</th>
|
||||
<th>操作</th>
|
||||
<th>操作人</th>
|
||||
<th>操作时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr className="archive-log-placeholder-row">
|
||||
<td colSpan={5}>正在读取日志...</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr className="archive-log-placeholder-row archive-log-error-row">
|
||||
<td colSpan={5}>{error}</td>
|
||||
</tr>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<tr className="archive-log-placeholder-row">
|
||||
<td className="archive-log-barcode">{barcode}</td>
|
||||
<td className="archive-log-content-cell">
|
||||
{logs.length === 0 ? '暂无操作记录' : '当前筛选无匹配记录'}
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td className="archive-log-time">-</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredLogs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="archive-log-barcode">{log.barcode || barcode}</td>
|
||||
<td className="archive-log-content-cell">{displayContent(log)}</td>
|
||||
<td>{displayOperation(log)}</td>
|
||||
<td>{formatProductArchiveChangeSource(log.source, log.actorName, log.details)}</td>
|
||||
<td className="archive-log-time">{formatTime(log.createdAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="archive-log-footer">
|
||||
<span>共 {appliedFilters === EMPTY_FILTERS ? pageInfo.total : filteredLogs.length} 条,每页 {pageInfo.pageSize} 条</span>
|
||||
<div className="archive-log-pagination">
|
||||
<button type="button" disabled={loading || page <= 1} onClick={() => setPage((current) => Math.max(1, current - 1))} aria-label="上一页">
|
||||
<ChevronLeft size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<span>{page} / {pageInfo.totalPages}</span>
|
||||
<button type="button" disabled={loading || page >= pageInfo.totalPages} onClick={() => setPage((current) => Math.min(pageInfo.totalPages, current + 1))} aria-label="下一页">
|
||||
<ChevronRight size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<button type="button" onClick={reload} title="刷新日志" aria-label="刷新日志">
|
||||
<RefreshCw size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<style jsx>{`
|
||||
.archive-log-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 110;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(24, 24, 27, 0.48);
|
||||
}
|
||||
.archive-log-modal {
|
||||
display: flex;
|
||||
width: min(1140px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - 48px);
|
||||
min-height: min(560px, calc(100vh - 48px));
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
box-shadow: 0 20px 60px rgba(24, 24, 27, 0.22);
|
||||
}
|
||||
.archive-log-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.archive-log-eyebrow { margin: 0 0 4px; color: #71717a; font-size: 12px; }
|
||||
.archive-log-header h2 { margin: 0; color: #18181b; font-size: 18px; font-weight: 600; }
|
||||
.archive-log-header-actions { display: inline-flex; align-items: center; gap: 9px; }
|
||||
.archive-log-header-actions button {
|
||||
display: inline-flex;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-log-header-actions button:hover { border-color: #d4d4d8; background: #f8fafc; color: #334155; }
|
||||
.archive-log-table-area { min-height: 0; flex: 1; overflow: auto; }
|
||||
.archive-log-state { display: grid; min-height: 220px; place-items: center; color: #a1a1aa; font-size: 13px; }
|
||||
.archive-log-error { color: #b91c1c; }
|
||||
.archive-log-table-wrap { overflow-x: auto; border: 1px solid #e4e4e7; border-radius: 4px; }
|
||||
.archive-log-table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: 12px; }
|
||||
.archive-log-table th {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
background: #f1f3f5;
|
||||
color: #27272a;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.archive-log-table td { padding: 10px 8px; border-bottom: 1px solid #f1f5f9; color: #334155; vertical-align: top; }
|
||||
.archive-log-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.archive-log-table tbody tr:hover td { background: #fafafa; }
|
||||
.archive-log-placeholder-row td { height: 48px; color: #94a3b8; }
|
||||
.archive-log-error-row td { color: #b91c1c; }
|
||||
.archive-log-barcode { color: #1e3a8a; font-family: ui-monospace, monospace; white-space: nowrap; }
|
||||
.archive-log-content-cell { min-width: 280px; color: #1f2937; }
|
||||
.archive-log-time { min-width: 168px; color: #475569; white-space: nowrap; }
|
||||
.archive-log-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
flex: 0 0 auto;
|
||||
padding-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.archive-log-pagination { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.archive-log-pagination button {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-log-pagination button:disabled { cursor: not-allowed; opacity: 0.4; }
|
||||
.archive-log-pagination button:hover:not(:disabled) { background: #f8fafc; border-color: #94a3b8; }
|
||||
@media (max-width: 760px) {
|
||||
.archive-log-overlay { padding: 12px; }
|
||||
.archive-log-modal { width: 100%; max-height: calc(100vh - 24px); min-height: calc(100vh - 24px); padding: 16px; }
|
||||
.archive-log-footer { justify-content: space-between; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { RotateCcw, Search, X } from 'lucide-react'
|
||||
|
||||
export type ProductArchiveLogFilterValues = {
|
||||
actor: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
content: string
|
||||
operation: '' | 'IMPORTED' | 'UPDATED'
|
||||
}
|
||||
|
||||
export function ProductArchiveChangeLogFilters({
|
||||
barcode,
|
||||
values,
|
||||
actors,
|
||||
onChange,
|
||||
onSearch,
|
||||
onReset,
|
||||
}: {
|
||||
barcode: string
|
||||
values: ProductArchiveLogFilterValues
|
||||
actors: string[]
|
||||
onChange: <K extends keyof ProductArchiveLogFilterValues>(
|
||||
key: K,
|
||||
value: ProductArchiveLogFilterValues[K],
|
||||
) => void
|
||||
onSearch: () => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="archive-log-filters" aria-label="日志筛选">
|
||||
<div className="archive-log-filter-mode" aria-label="日志范围">
|
||||
<span className="archive-log-filter-mode-active">按商品</span>
|
||||
</div>
|
||||
|
||||
<div className="archive-log-barcode-chip" title="当前日志条码">
|
||||
<span>{barcode}</span>
|
||||
<button type="button" onClick={onReset} aria-label="清除筛选条件" title="清除筛选条件">
|
||||
<X size={13} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="archive-log-filter-control archive-log-filter-actor"
|
||||
value={values.actor}
|
||||
onChange={(event) => onChange('actor', event.target.value)}
|
||||
aria-label="操作人"
|
||||
>
|
||||
<option value="">操作人</option>
|
||||
{actors.map((actor) => <option key={actor} value={actor}>{actor}</option>)}
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="archive-log-filter-control archive-log-date"
|
||||
type="date"
|
||||
value={values.startDate}
|
||||
onChange={(event) => onChange('startDate', event.target.value)}
|
||||
aria-label="开始日期"
|
||||
title="开始日期"
|
||||
/>
|
||||
<span className="archive-log-date-separator">-</span>
|
||||
<input
|
||||
className="archive-log-filter-control archive-log-date"
|
||||
type="date"
|
||||
value={values.endDate}
|
||||
onChange={(event) => onChange('endDate', event.target.value)}
|
||||
aria-label="结束日期"
|
||||
title="结束日期"
|
||||
/>
|
||||
|
||||
<input
|
||||
className="archive-log-filter-control archive-log-content-input"
|
||||
value={values.content}
|
||||
onChange={(event) => onChange('content', event.target.value)}
|
||||
placeholder="操作内容"
|
||||
aria-label="操作内容"
|
||||
/>
|
||||
<select
|
||||
className="archive-log-filter-control archive-log-operation"
|
||||
value={values.operation}
|
||||
onChange={(event) => onChange('operation', event.target.value as ProductArchiveLogFilterValues['operation'])}
|
||||
aria-label="操作"
|
||||
>
|
||||
<option value="">操作</option>
|
||||
<option value="IMPORTED">商品档案导入</option>
|
||||
<option value="UPDATED">字段修改</option>
|
||||
</select>
|
||||
|
||||
<button type="button" className="archive-log-search" onClick={onSearch}>
|
||||
<Search size={14} strokeWidth={1.9} />
|
||||
搜索
|
||||
</button>
|
||||
<button type="button" className="archive-log-reset" onClick={onReset} title="重置筛选">
|
||||
<RotateCcw size={13} strokeWidth={1.8} />
|
||||
重置
|
||||
</button>
|
||||
|
||||
<style jsx>{`
|
||||
.archive-log-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.archive-log-filter-mode {
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
}
|
||||
.archive-log-filter-mode button {
|
||||
min-width: 52px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-right: 1px solid #d4d4d8;
|
||||
background: #fff;
|
||||
color: #52525b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-log-filter-mode button:last-child { border-right: 0; }
|
||||
.archive-log-filter-mode button.is-active { background: #eff6ff; color: #2563eb; }
|
||||
.archive-log-filter-mode-active {
|
||||
display: inline-flex;
|
||||
min-width: 52px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
}
|
||||
.archive-log-barcode-chip {
|
||||
display: inline-flex;
|
||||
max-width: 184px;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 5px 0 9px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: #3f3f46;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.archive-log-barcode-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.archive-log-barcode-chip button {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #e4e4e7;
|
||||
color: #71717a;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-log-filter-control {
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: #52525b;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.archive-log-filter-control:focus { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.12); }
|
||||
.archive-log-filter-actor { width: 112px; }
|
||||
.archive-log-date { width: 126px; color: #71717a; }
|
||||
.archive-log-date-separator { color: #a1a1aa; font-size: 12px; }
|
||||
.archive-log-content-input { width: 132px; }
|
||||
.archive-log-operation { width: 116px; }
|
||||
.archive-log-search,
|
||||
.archive-log-reset,
|
||||
.archive-log-export {
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-log-search { border-color: #3b82f6; background: #3b82f6; color: #fff; }
|
||||
.archive-log-search:hover { background: #2563eb; }
|
||||
.archive-log-reset { background: #fff; color: #52525b; }
|
||||
.archive-log-reset:hover, .archive-log-export:hover { background: #f4f4f5; }
|
||||
@media (max-width: 760px) {
|
||||
.archive-log-date, .archive-log-content-input, .archive-log-operation { flex: 1 1 130px; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { X } from 'lucide-react'
|
||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||
import type { ArchiveColumnGroup } from './columns'
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function ProductArchiveDetailDialog({
|
||||
row,
|
||||
columns,
|
||||
onClose,
|
||||
}: {
|
||||
row: SupplyChainProductArchiveRow
|
||||
columns: ArchiveColumnGroup[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const values = row as unknown as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<div className="archive-detail-overlay" onMouseDown={onClose}>
|
||||
<aside
|
||||
className="archive-detail-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="archive-detail-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="archive-detail-header">
|
||||
<div>
|
||||
<p className="archive-detail-eyebrow">条码档案</p>
|
||||
<h2 id="archive-detail-title">查看商品</h2>
|
||||
<p className="archive-detail-identity">
|
||||
{row.barcode || '-'}
|
||||
{row.productName ? ` · ${row.productName}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="archive-detail-close" onClick={onClose} aria-label="关闭查看">
|
||||
<X size={18} strokeWidth={1.8} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="archive-detail-content">
|
||||
{columns.map((group) => (
|
||||
<section className="archive-detail-section" key={group.group}>
|
||||
<h3>{group.group}</h3>
|
||||
<dl className="archive-detail-grid">
|
||||
{group.columns.map((column) => {
|
||||
const value = values[column.prop]
|
||||
return (
|
||||
<div className="archive-detail-field" key={column.prop}>
|
||||
<dt>{column.label}</dt>
|
||||
<dd>
|
||||
{column.prop === 'image' && typeof value === 'string' && value ? (
|
||||
<Image
|
||||
unoptimized
|
||||
src={value}
|
||||
alt={row.productName || row.barcode || '商品图片'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="archive-detail-image"
|
||||
/>
|
||||
) : (
|
||||
formatValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style jsx>{`
|
||||
.archive-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(24, 24, 27, 0.28);
|
||||
}
|
||||
.archive-detail-dialog {
|
||||
display: flex;
|
||||
width: min(1120px, 96vw);
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.archive-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 22px 28px 18px;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
}
|
||||
.archive-detail-eyebrow { margin: 0; color: #71717a; font-size: 12px; }
|
||||
.archive-detail-header h2 { margin: 5px 0 0; color: #18181b; font-size: 20px; }
|
||||
.archive-detail-identity { margin: 7px 0 0; color: #52525b; font-size: 13px; }
|
||||
.archive-detail-close {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #52525b;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-detail-close:hover { background: #f4f4f5; color: #18181b; }
|
||||
.archive-detail-content { flex: 1; overflow-y: auto; padding: 20px 28px 36px; }
|
||||
.archive-detail-section + .archive-detail-section { margin-top: 20px; }
|
||||
.archive-detail-section h3 {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e4e4e7;
|
||||
background: #f7f8fa;
|
||||
color: #27272a;
|
||||
font-size: 14px;
|
||||
}
|
||||
.archive-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
border-left: 1px solid #e4e4e7;
|
||||
border-top: 1px solid #e4e4e7;
|
||||
}
|
||||
.archive-detail-field {
|
||||
display: grid;
|
||||
grid-template-columns: 142px minmax(0, 1fr);
|
||||
min-height: 42px;
|
||||
border-right: 1px solid #e4e4e7;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
}
|
||||
.archive-detail-field dt,
|
||||
.archive-detail-field dd { margin: 0; padding: 11px 12px; font-size: 13px; }
|
||||
.archive-detail-field dt { background: #fafafa; color: #71717a; }
|
||||
.archive-detail-field dd { overflow-wrap: anywhere; color: #27272a; }
|
||||
:global(.archive-detail-image) {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.archive-detail-dialog { width: 100vw; }
|
||||
.archive-detail-header, .archive-detail-content { padding-left: 16px; padding-right: 16px; }
|
||||
.archive-detail-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import Image from 'next/image'
|
||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||
import type { ArchiveColumnGroup } from './columns'
|
||||
import { ProductArchiveActions } from './ProductArchiveActions'
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
if (!value) return ''
|
||||
@ -55,11 +56,15 @@ export function ProductArchiveTableBody({
|
||||
columns,
|
||||
startIndex,
|
||||
onPreviewImage,
|
||||
onViewRow,
|
||||
onOpenChangeLog,
|
||||
}: {
|
||||
rows: SupplyChainProductArchiveRow[]
|
||||
columns: ArchiveColumnGroup[]
|
||||
startIndex: number
|
||||
onPreviewImage: (image: string, title: string) => void
|
||||
onViewRow: (row: SupplyChainProductArchiveRow) => void
|
||||
onOpenChangeLog: (barcode: string) => void
|
||||
}) {
|
||||
return (
|
||||
<tbody>
|
||||
@ -84,6 +89,13 @@ export function ProductArchiveTableBody({
|
||||
)
|
||||
}),
|
||||
)}
|
||||
<td className="archive-td-actions">
|
||||
<ProductArchiveActions
|
||||
row={row}
|
||||
onView={onViewRow}
|
||||
onChangeLog={onOpenChangeLog}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@ -113,6 +125,20 @@ export function ProductArchiveTableBody({
|
||||
tr:hover .archive-td-field {
|
||||
background: #fafafa;
|
||||
}
|
||||
.archive-td-actions {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
min-width: 138px;
|
||||
padding: 8px 10px;
|
||||
overflow: visible;
|
||||
border: 1px solid #e4e4e7;
|
||||
background: #fff;
|
||||
box-shadow: -5px 0 10px rgba(24, 24, 27, 0.06);
|
||||
text-align: center;
|
||||
}
|
||||
.archive-td-actions:focus-within { z-index: 12; }
|
||||
tr:hover .archive-td-actions { background: #fafafa; }
|
||||
:global(.archive-product-image) {
|
||||
display: block;
|
||||
width: 44px;
|
||||
|
||||
@ -11,7 +11,12 @@ import { ProductArchiveHeaderFilter } from './ProductArchiveHeaderFilter'
|
||||
import { ProductArchiveImagePreview } from './ProductArchiveImagePreview'
|
||||
import { ProductArchiveImageSearchDialog } from './ProductArchiveImageSearchDialog'
|
||||
import { useArchiveColumnResize } from './useArchiveColumnResize'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import { ProductArchiveDetailDialog } from './ProductArchiveDetailDialog'
|
||||
import { ProductArchiveChangeLogDialog } from './ProductArchiveChangeLogDialog'
|
||||
import type {
|
||||
ProductArchiveFilters,
|
||||
SupplyChainProductArchiveRow,
|
||||
} from '@/lib/supply-chain-product-archive'
|
||||
|
||||
export function ProductArchiveView() {
|
||||
const columnSettings = useColumnSettings()
|
||||
@ -23,6 +28,8 @@ export function ProductArchiveView() {
|
||||
const [filters, setFilters] = useState<ProductArchiveFilters>({})
|
||||
const [imagePreview, setImagePreview] = useState<{ image: string; title: string } | null>(null)
|
||||
const [showImageSearch, setShowImageSearch] = useState(false)
|
||||
const [detailRow, setDetailRow] = useState<SupplyChainProductArchiveRow | null>(null)
|
||||
const [changeLogBarcode, setChangeLogBarcode] = useState<string | null>(null)
|
||||
const { rows, pageInfo, loading, error, reload } = useProductArchiveData(
|
||||
currentPage,
|
||||
pageSize,
|
||||
@ -41,6 +48,14 @@ export function ProductArchiveView() {
|
||||
() => effectiveColumns.flatMap((group) => group.columns.map((column) => ({ group: group.group, column }))),
|
||||
[effectiveColumns],
|
||||
)
|
||||
const detailColumns = useMemo(
|
||||
() =>
|
||||
columnSettings.settings.map((group) => ({
|
||||
group: group.group,
|
||||
columns: group.columns.map(({ prop, label, width }) => ({ prop, label, width })),
|
||||
})),
|
||||
[columnSettings.settings],
|
||||
)
|
||||
const activeFilterCount = Object.values(filters).filter((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
).length
|
||||
@ -86,6 +101,7 @@ export function ProductArchiveView() {
|
||||
{visibleColumns.map(({ group, column }) => (
|
||||
<col key={`${group}-${column.prop}`} style={{ width: column.width }} />
|
||||
))}
|
||||
<col style={{ width: 138 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
{/* 一级表头:分组 */}
|
||||
@ -96,6 +112,7 @@ export function ProductArchiveView() {
|
||||
{g.group}
|
||||
</th>
|
||||
))}
|
||||
<th rowSpan={2} className="archive-th-actions">操作</th>
|
||||
</tr>
|
||||
{/* 二级表头:字段 */}
|
||||
<tr>
|
||||
@ -134,6 +151,8 @@ export function ProductArchiveView() {
|
||||
columns={effectiveColumns}
|
||||
startIndex={(currentPage - 1) * pageSize}
|
||||
onPreviewImage={(image, title) => setImagePreview({ image, title })}
|
||||
onViewRow={setDetailRow}
|
||||
onOpenChangeLog={setChangeLogBarcode}
|
||||
/>
|
||||
</table>
|
||||
{loading && <div className="archive-empty">正在加载...</div>}
|
||||
@ -194,6 +213,19 @@ export function ProductArchiveView() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{detailRow && (
|
||||
<ProductArchiveDetailDialog
|
||||
row={detailRow}
|
||||
columns={detailColumns}
|
||||
onClose={() => setDetailRow(null)}
|
||||
/>
|
||||
)}
|
||||
{changeLogBarcode && (
|
||||
<ProductArchiveChangeLogDialog
|
||||
barcode={changeLogBarcode}
|
||||
onClose={() => setChangeLogBarcode(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.archive-page {
|
||||
@ -283,6 +315,13 @@ export function ProductArchiveView() {
|
||||
font-weight: 500; color: #3f3f46;
|
||||
white-space: nowrap; overflow: visible;
|
||||
}
|
||||
:global(.archive-th-actions) {
|
||||
position: sticky; top: 0; right: 0; z-index: 9;
|
||||
width: 138px; min-width: 138px; height: 76px; padding: 0 8px;
|
||||
border: 1px solid #e4e4e7; background: #fff;
|
||||
box-shadow: -5px 0 10px rgba(24, 24, 27, 0.06);
|
||||
color: #27272a; font-size: 13px; font-weight: 600; text-align: center;
|
||||
}
|
||||
.archive-th-field-content {
|
||||
display: flex; height: 37px; align-items: center; justify-content: center; gap: 2px;
|
||||
padding: 0 8px;
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ApiResponse } from '@/server/core/types'
|
||||
import type { ProductArchiveChangeLogPage } from '@/lib/supply-chain-product-archive-change-log'
|
||||
|
||||
const EMPTY_PAGE: ProductArchiveChangeLogPage = {
|
||||
logs: [],
|
||||
pageInfo: { page: 1, pageSize: 50, total: 0, totalPages: 1 },
|
||||
}
|
||||
|
||||
export function useProductArchiveChangeLog(barcode: string, page: number) {
|
||||
const [result, setResult] = useState(EMPTY_PAGE)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
||||
const response = await fetch(
|
||||
`/api/supply-chain/product-archive/${encodeURIComponent(barcode)}/change-log?${params}`,
|
||||
{ signal: controller.signal, cache: 'no-store' },
|
||||
)
|
||||
const payload = (await response.json()) as ApiResponse<ProductArchiveChangeLogPage>
|
||||
if (!response.ok || !payload.success || !payload.data) {
|
||||
throw new Error(payload.error?.message || '商品修改日志读取失败')
|
||||
}
|
||||
setResult(payload.data)
|
||||
} catch (loadError) {
|
||||
if (controller.signal.aborted) return
|
||||
setResult(EMPTY_PAGE)
|
||||
setError(loadError instanceof Error ? loadError.message : '商品修改日志读取失败')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [barcode, page, reloadKey])
|
||||
|
||||
const reload = useCallback(() => setReloadKey((current) => current + 1), [])
|
||||
|
||||
return { ...result, loading, error, reload }
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
export const CATEGORY_CONFIG_ACTIVE = 'ACTIVE' as const
|
||||
export const CATEGORY_CONFIG_INACTIVE = 'INACTIVE' as const
|
||||
|
||||
export type ProductArchiveCategoryConfigStatus =
|
||||
| typeof CATEGORY_CONFIG_ACTIVE
|
||||
| typeof CATEGORY_CONFIG_INACTIVE
|
||||
|
||||
export type ProductArchiveCategoryConfig = {
|
||||
id: string
|
||||
categoryL2: string
|
||||
categoryL1: string
|
||||
status: ProductArchiveCategoryConfigStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type ProductArchiveCategoryConfigInput = {
|
||||
categoryL2: string
|
||||
categoryL1: string
|
||||
}
|
||||
|
||||
export const INITIAL_PRODUCT_ARCHIVE_CATEGORY_CONFIGS: ProductArchiveCategoryConfigInput[] = [
|
||||
{ categoryL2: '住宅家具 - 书桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 鞋柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 门厅/玄关柜/玄关架', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 餐边柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 电视柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 角柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 书架', categoryL1: '架类' },
|
||||
{ categoryL2: '住宅家具 - 书柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 厨房置物架/角架', categoryL1: '架类' },
|
||||
{ categoryL2: '住宅家具 - 床头柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 电脑桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 梳妆台/桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '商业/办公家具 - 演讲台', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 折叠桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 餐桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 电脑椅', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 餐椅', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 梳妆凳', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 衣帽架', categoryL1: '架类' },
|
||||
{ categoryL2: '住宅家具 - 化妆镜', categoryL1: '镜类' },
|
||||
{ categoryL2: '住宅家具 - 花架/花几', categoryL1: '架类' },
|
||||
{ categoryL2: '住宅家具 - 搁板/置物架', categoryL1: '架类' },
|
||||
{ categoryL2: '住宅家具 - 书桌收纳置物架', categoryL1: '收纳类' },
|
||||
{ categoryL2: '住宅家具 - 厨房储物架/厨房置物架', categoryL1: '架类' },
|
||||
{ categoryL2: '住宅家具 - 角几/边几', categoryL1: '几类' },
|
||||
{ categoryL2: '住宅家具 - 茶几', categoryL1: '几类' },
|
||||
{ categoryL2: '住宅家具 - 办公升降台/升降桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '商业/办公家具 - 文具边柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 斗柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 穿衣镜', categoryL1: '镜类' },
|
||||
{ categoryL2: '住宅家具 - 矮凳', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 其它凳子', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 布艺沙发', categoryL1: '沙发类' },
|
||||
{ categoryL2: '住宅家具 - 床垫类', categoryL1: '床类' },
|
||||
{ categoryL2: '住宅家具 - 平板手推车/工具车', categoryL1: '收纳类' },
|
||||
{ categoryL2: '住宅家具 - 炕几', categoryL1: '几类' },
|
||||
{ categoryL2: '住宅家具 - 换鞋凳', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 懒人沙发', categoryL1: '沙发类' },
|
||||
{ categoryL2: '住宅家具 - 家居收纳产品', categoryL1: '收纳类' },
|
||||
{ categoryL2: '住宅家具 - 其它家具配件', categoryL1: '第三方配件' },
|
||||
{ categoryL2: '住宅家具 - 休闲椅', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 折叠床', categoryL1: '床类' },
|
||||
{ categoryL2: '住宅家具 - 窝/屋/帐篷', categoryL1: '户外类' },
|
||||
{ categoryL2: '商业/办公家具 - 会议桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 收纳洞洞板', categoryL1: '收纳类' },
|
||||
{ categoryL2: '住宅家具 - 洞洞板', categoryL1: '收纳类' },
|
||||
{ categoryL2: '住宅家具 - 功能沙发', categoryL1: '沙发类' },
|
||||
{ categoryL2: '住宅家具 - 沙发床', categoryL1: '沙发类' },
|
||||
{ categoryL2: '住宅家具 - 收纳柜', categoryL1: '收纳类' },
|
||||
{ categoryL2: '住宅家具 - 收纳筐', categoryL1: '收纳类' },
|
||||
{ categoryL2: '住宅家具 - 鞋架', categoryL1: '架类' },
|
||||
{ categoryL2: '住宅家具 - 儿童学习桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 衣柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 折叠床/午休床', categoryL1: '床类' },
|
||||
{ categoryL2: '住宅家具 - 折叠椅/躺椅', categoryL1: '户外类' },
|
||||
{ categoryL2: '住宅家具 - 儿童餐椅', categoryL1: '坐具类' },
|
||||
{ categoryL2: '商业/办公家具 - 组合/屏风工作位', categoryL1: '桌类' },
|
||||
{ categoryL2: '其他 - 配件', categoryL1: '其他' },
|
||||
{ categoryL2: '住宅家具 - 衣物洗/晒/护理用具', categoryL1: '架类' },
|
||||
{ categoryL2: '住宅家具 - 实木床', categoryL1: '床类' },
|
||||
{ categoryL2: '住宅家具 - 卫浴/置物用具', categoryL1: '收纳类' },
|
||||
{ categoryL2: '住宅家具 - 儿童学习椅', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 板木结合床', categoryL1: '床类' },
|
||||
{ categoryL2: '住宅家具 - 家用梯', categoryL1: '其他' },
|
||||
{ categoryL2: '其他 - 赠品', categoryL1: '其他' },
|
||||
{ categoryL2: '住宅家具 - 电竞椅', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 酒吧椅', categoryL1: '坐具类' },
|
||||
{ categoryL2: '住宅家具 - 飘窗柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '其他 - 福利品', categoryL1: '其他' },
|
||||
{ categoryL2: '住宅家具 - 电动升降桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 办公桌', categoryL1: '桌类' },
|
||||
{ categoryL2: '其他 - 虚拟产品', categoryL1: '其他' },
|
||||
{ categoryL2: '住宅家具 - 功能床', categoryL1: '床类' },
|
||||
{ categoryL2: '住宅家具 - 皮艺沙发', categoryL1: '沙发类' },
|
||||
{ categoryL2: '住宅家具 - 皮艺床', categoryL1: '床类' },
|
||||
{ categoryL2: '商业/办公家具 - 前台/接待台(新增)', categoryL1: '桌类' },
|
||||
{ categoryL2: '住宅家具 - 吊柜/壁柜', categoryL1: '柜类' },
|
||||
{ categoryL2: '住宅家具 - 主机箱托架', categoryL1: '架类' },
|
||||
]
|
||||
@ -0,0 +1,89 @@
|
||||
export const PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE = 'SUPPLY_CHAIN_PRODUCT_ARCHIVE'
|
||||
|
||||
export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [
|
||||
{ key: 'spu', label: 'SPU' },
|
||||
{ key: 'sku', label: 'SKU' },
|
||||
{ key: 'image', label: '图片' },
|
||||
{ key: 'businessCreatedAt', label: '创建时间' },
|
||||
{ key: 'categoryL1', label: '一级类目' },
|
||||
{ key: 'categoryL2', label: '二级类目' },
|
||||
{ key: 'productName', label: '商品名称' },
|
||||
{ key: 'designer', label: '设计师' },
|
||||
{ key: 'productStatus', label: '商品状态' },
|
||||
{ key: 'material', label: '材质' },
|
||||
{ key: 'color', label: '颜色' },
|
||||
{ key: 'weight', label: '重量' },
|
||||
{ key: 'length', label: '长' },
|
||||
{ key: 'width', label: '宽' },
|
||||
{ key: 'height', label: '高' },
|
||||
{ key: 'volume', label: '体积' },
|
||||
{ key: 'packType', label: '单双包' },
|
||||
{ key: 'productTag', label: '商品标签' },
|
||||
{ key: 'installDocId', label: '安装图纸/视频ID' },
|
||||
{ key: 'spec', label: '产品尺寸' },
|
||||
{ key: 'brand', label: '品牌' },
|
||||
{ key: 'orderType', label: '叫货类型' },
|
||||
{ key: 'storageType', label: '入库/代发' },
|
||||
{ key: 'isSeasonal', label: '是否季节性产品' },
|
||||
{ key: 'replaceCount', label: '替换款数量' },
|
||||
{ key: 'supplierName', label: '供应商' },
|
||||
{ key: 'purchaseOwner', label: '采购' },
|
||||
{ key: 'minimumOrderQuantity', label: '产品起订量' },
|
||||
] as const
|
||||
|
||||
export type ProductArchiveChangeFieldKey =
|
||||
(typeof PRODUCT_ARCHIVE_CHANGE_FIELDS)[number]['key']
|
||||
|
||||
export type ProductArchiveFieldChange = {
|
||||
field: ProductArchiveChangeFieldKey
|
||||
fieldLabel: string
|
||||
previousValue: string
|
||||
nextValue: string
|
||||
}
|
||||
|
||||
export type ProductArchiveChangeLogDetails = {
|
||||
kind: 'IMPORTED' | 'UPDATED'
|
||||
barcode: string
|
||||
field?: ProductArchiveChangeFieldKey
|
||||
fieldLabel?: string
|
||||
previousValue?: string
|
||||
nextValue?: string
|
||||
sourceSystem?: string
|
||||
sourceSchema?: string
|
||||
sourceObject?: string
|
||||
sourceRecordedAt?: string
|
||||
}
|
||||
|
||||
export type ProductArchiveChangeLogItem = {
|
||||
id: string
|
||||
barcode: string
|
||||
action: string
|
||||
source: 'USER' | 'SYSTEM'
|
||||
actorName: string
|
||||
message: string
|
||||
details: ProductArchiveChangeLogDetails
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ProductArchiveChangeLogPage = {
|
||||
logs: ProductArchiveChangeLogItem[]
|
||||
pageInfo: {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export function formatProductArchiveChangeSource(
|
||||
source: 'USER' | 'SYSTEM',
|
||||
actorName: string,
|
||||
details: ProductArchiveChangeLogDetails,
|
||||
) {
|
||||
if (source === 'USER') return actorName || '系统用户'
|
||||
|
||||
const sourceName = [details.sourceSystem, details.sourceSchema, details.sourceObject]
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
return sourceName ? `数据库推送:${sourceName}` : actorName || '数据库推送'
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
export const JST_SKU_ARCHIVE_SOURCE_CODE = 'JST_SKU'
|
||||
export const PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE = 'PRODUCT_DEVELOPMENT'
|
||||
export const CATEGORY_CONFIG_ARCHIVE_SOURCE_CODE = 'CATEGORY_CONFIG'
|
||||
export const YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE = 'YUNSHAN_PRODUCT_EXPORT'
|
||||
|
||||
export type ProductArchiveFieldSource = {
|
||||
sourceCode: string
|
||||
sourceSystem: string
|
||||
sourceSchema: string
|
||||
sourceObject: string
|
||||
sourceColumn: string
|
||||
sourceRecordId: string
|
||||
sourceUpdatedAt: string
|
||||
}
|
||||
|
||||
export type ProductArchiveFieldSourceMap = Record<string, ProductArchiveFieldSource>
|
||||
|
||||
export const JST_SKU_ARCHIVE_FIELD_COLUMNS = {
|
||||
barcode: 'sku_id',
|
||||
spu: 'i_id',
|
||||
sku: 'other_7',
|
||||
productName: 'name',
|
||||
productTag: 'labels',
|
||||
brand: 'brand',
|
||||
supplierName: 'supplier_name',
|
||||
productStatus: 'other_5',
|
||||
installDocId: 'other_9',
|
||||
material: 'other_10',
|
||||
color: 'properties_value',
|
||||
categoryL2: 'category',
|
||||
weight: 'weight',
|
||||
length: 'l',
|
||||
width: 'w',
|
||||
height: 'h',
|
||||
volume: 'l*w*h',
|
||||
packType: 'other_8',
|
||||
spec: 'name',
|
||||
} as const
|
||||
|
||||
export const PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS = {
|
||||
barcode: 'barcode_business_key',
|
||||
spu: 'archive_spu',
|
||||
sku: 'archive_sku',
|
||||
image: 'image_url | archive_image',
|
||||
businessCreatedAt: 'product_development_created_at | source_created_at',
|
||||
categoryL1: 'sales_category_name',
|
||||
categoryL2: 'archive_product_category_name',
|
||||
productName: 'archive_product_name',
|
||||
designer: 'archive_designer',
|
||||
productStatus: 'sku_sales_status',
|
||||
material: 'archive_material',
|
||||
color: 'archive_color',
|
||||
weight: 'sample_weight_kg',
|
||||
length: 'package_length_cm',
|
||||
width: 'package_width_cm',
|
||||
height: 'package_height_cm',
|
||||
volume: 'package_length_cm*package_width_cm*package_height_cm',
|
||||
packType: 'package_set_count',
|
||||
productTag: 'product_tag',
|
||||
installDocId: 'drawing_id',
|
||||
spec: 'archive_spec',
|
||||
brand: 'brand_name',
|
||||
orderType: 'purchase_order_method',
|
||||
storageType: 'archive_shipping_method',
|
||||
supplierName: 'archive_supplier_name',
|
||||
purchaseOwner: 'archive_purchase_owner',
|
||||
minimumOrderQuantity: 'archive_minimum_order_quantity',
|
||||
} as const
|
||||
|
||||
const BUSINESS_CREATED_AT_SOURCE_PRIORITY: Record<string, number> = {
|
||||
[PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE]: 10,
|
||||
[JST_SKU_ARCHIVE_SOURCE_CODE]: 20,
|
||||
[YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE]: 30,
|
||||
}
|
||||
|
||||
export function shouldApplyProductArchiveFieldSource(input: {
|
||||
field: string
|
||||
currentSourceCode?: string
|
||||
incomingSourceCode: string
|
||||
}) {
|
||||
if (!input.currentSourceCode || input.currentSourceCode === input.incomingSourceCode) {
|
||||
return true
|
||||
}
|
||||
if (input.field === 'businessCreatedAt') {
|
||||
return (
|
||||
(BUSINESS_CREATED_AT_SOURCE_PRIORITY[input.incomingSourceCode] ?? 0) >=
|
||||
(BUSINESS_CREATED_AT_SOURCE_PRIORITY[input.currentSourceCode] ?? 0)
|
||||
)
|
||||
}
|
||||
return !(
|
||||
input.incomingSourceCode === PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE &&
|
||||
input.currentSourceCode === JST_SKU_ARCHIVE_SOURCE_CODE
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export function parseProductArchiveFieldSourceMap(
|
||||
value: unknown,
|
||||
): ProductArchiveFieldSourceMap {
|
||||
if (!isRecord(value)) return {}
|
||||
|
||||
const result: ProductArchiveFieldSourceMap = {}
|
||||
Object.entries(value).forEach(([field, rawSource]) => {
|
||||
if (!isRecord(rawSource)) return
|
||||
const sourceCode = text(rawSource.sourceCode)
|
||||
const sourceColumn = text(rawSource.sourceColumn)
|
||||
if (!sourceCode || !sourceColumn) return
|
||||
result[field] = {
|
||||
sourceCode,
|
||||
sourceSystem: text(rawSource.sourceSystem),
|
||||
sourceSchema: text(rawSource.sourceSchema),
|
||||
sourceObject: text(rawSource.sourceObject),
|
||||
sourceColumn,
|
||||
sourceRecordId: text(rawSource.sourceRecordId),
|
||||
sourceUpdatedAt: text(rawSource.sourceUpdatedAt),
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function mergeProductArchiveFieldSourceMaps(
|
||||
current: ProductArchiveFieldSourceMap,
|
||||
incoming: ProductArchiveFieldSourceMap,
|
||||
) {
|
||||
return { ...current, ...incoming }
|
||||
}
|
||||
|
||||
export function createProductArchiveFieldSourceMap(input: {
|
||||
fields: Record<string, string>
|
||||
sourceCode: string
|
||||
sourceSystem: string
|
||||
sourceSchema: string
|
||||
sourceObject: string
|
||||
sourceRecordId: string
|
||||
sourceUpdatedAt?: Date | string | null
|
||||
}) {
|
||||
const sourceUpdatedAt =
|
||||
input.sourceUpdatedAt instanceof Date
|
||||
? input.sourceUpdatedAt.toISOString()
|
||||
: text(input.sourceUpdatedAt)
|
||||
return Object.fromEntries(
|
||||
Object.entries(input.fields).map(([field, sourceColumn]) => [
|
||||
field,
|
||||
{
|
||||
sourceCode: input.sourceCode,
|
||||
sourceSystem: input.sourceSystem,
|
||||
sourceSchema: input.sourceSchema,
|
||||
sourceObject: input.sourceObject,
|
||||
sourceColumn,
|
||||
sourceRecordId: input.sourceRecordId,
|
||||
sourceUpdatedAt,
|
||||
},
|
||||
]),
|
||||
) satisfies ProductArchiveFieldSourceMap
|
||||
}
|
||||
|
||||
export function toProductArchiveFieldSourceJson(
|
||||
value: ProductArchiveFieldSourceMap,
|
||||
): Prisma.InputJsonValue {
|
||||
return value as unknown as Prisma.InputJsonValue
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
function positiveDecimal(value: unknown) {
|
||||
if (value === null || value === undefined) return null
|
||||
const normalized = String(value).trim().replace(/,/g, '')
|
||||
if (!normalized) return null
|
||||
|
||||
try {
|
||||
const decimal = new Prisma.Decimal(normalized)
|
||||
return decimal.isFinite() && decimal.greaterThan(0) ? decimal : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateProductArchiveVolumeDecimal(
|
||||
length: unknown,
|
||||
width: unknown,
|
||||
height: unknown,
|
||||
) {
|
||||
const dimensions = [length, width, height].map(positiveDecimal)
|
||||
if (dimensions.some((value) => value === null)) return null
|
||||
|
||||
const [validLength, validWidth, validHeight] = dimensions as [
|
||||
Prisma.Decimal,
|
||||
Prisma.Decimal,
|
||||
Prisma.Decimal,
|
||||
]
|
||||
return validLength.mul(validWidth).mul(validHeight)
|
||||
}
|
||||
@ -35,6 +35,10 @@ export enum ErrorCode {
|
||||
CATEGORY_NAME_EXISTS = 'CATEGORY_002',
|
||||
CATEGORY_HAS_PRODUCTS = 'CATEGORY_003',
|
||||
|
||||
// 供应链类目配置错误 SUPPLY_CHAIN_CATEGORY_CONFIG_xxx
|
||||
SUPPLY_CHAIN_CATEGORY_CONFIG_NOT_FOUND = 'SUPPLY_CHAIN_CATEGORY_CONFIG_001',
|
||||
SUPPLY_CHAIN_CATEGORY_CONFIG_EXISTS = 'SUPPLY_CHAIN_CATEGORY_CONFIG_002',
|
||||
|
||||
// SKU 错误 SKU_xxx
|
||||
SKU_NOT_FOUND = 'SKU_001',
|
||||
SKU_DUPLICATE_CODE = 'SKU_002',
|
||||
@ -143,6 +147,9 @@ export const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||
[ErrorCode.CATEGORY_NAME_EXISTS]: '分类名称已存在',
|
||||
[ErrorCode.CATEGORY_HAS_PRODUCTS]: '无法删除包含产品的分类',
|
||||
|
||||
[ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_NOT_FOUND]: '类目配置不存在',
|
||||
[ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_EXISTS]: '二级类目配置已存在',
|
||||
|
||||
// SKU 错误
|
||||
[ErrorCode.SKU_NOT_FOUND]: 'SKU 不存在',
|
||||
[ErrorCode.SKU_DUPLICATE_CODE]: 'SKU 编码已存在',
|
||||
@ -245,6 +252,9 @@ export const ERROR_HTTP_STATUS: Record<ErrorCode, number> = {
|
||||
[ErrorCode.CATEGORY_NAME_EXISTS]: HTTP_STATUS.CONFLICT,
|
||||
[ErrorCode.CATEGORY_HAS_PRODUCTS]: HTTP_STATUS.CONFLICT,
|
||||
|
||||
[ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_NOT_FOUND]: HTTP_STATUS.NOT_FOUND,
|
||||
[ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_EXISTS]: HTTP_STATUS.CONFLICT,
|
||||
|
||||
// SKU 错误 -> 404/409
|
||||
[ErrorCode.SKU_NOT_FOUND]: HTTP_STATUS.NOT_FOUND,
|
||||
[ErrorCode.SKU_DUPLICATE_CODE]: HTTP_STATUS.CONFLICT,
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
import sql from 'mssql'
|
||||
|
||||
export type JstSkuProductArchiveSourceRecord = {
|
||||
autoid: number
|
||||
sku_id: string | null
|
||||
i_id: string | null
|
||||
name: string | null
|
||||
labels: string | null
|
||||
brand: string | null
|
||||
supplier_name: string | null
|
||||
other_5: string | null
|
||||
other_7: string | null
|
||||
other_8: string | null
|
||||
other_9: string | null
|
||||
other_10: string | null
|
||||
properties_value: string | null
|
||||
category: string | null
|
||||
weight: number | null
|
||||
l: number | null
|
||||
w: number | null
|
||||
h: number | null
|
||||
created: Date | null
|
||||
modified: Date | null
|
||||
local_updated: Date | null
|
||||
}
|
||||
|
||||
export interface JstSkuProductArchiveSourceRepository {
|
||||
listFinishedBySpu(spu: string): Promise<JstSkuProductArchiveSourceRecord[]>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function requiredEnv(name: string) {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
function booleanEnv(name: string, fallback: boolean) {
|
||||
const value = process.env[name]?.trim().toLowerCase()
|
||||
if (!value) return fallback
|
||||
return value === 'true' || value === '1'
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string, label: string) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error(`${label} is not a valid SQL Server identifier`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
class JstSkuProductArchiveSourceRepositoryClass
|
||||
implements JstSkuProductArchiveSourceRepository
|
||||
{
|
||||
private pool: sql.ConnectionPool | null = null
|
||||
|
||||
private async getPool() {
|
||||
if (this.pool?.connected) return this.pool
|
||||
|
||||
this.pool = await new sql.ConnectionPool({
|
||||
server: requiredEnv('JST_REPORT_DB_HOST'),
|
||||
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
|
||||
database: requiredEnv('JST_REPORT_DB_NAME'),
|
||||
user: requiredEnv('JST_REPORT_DB_USER'),
|
||||
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
|
||||
options: {
|
||||
encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true),
|
||||
trustServerCertificate: booleanEnv('JST_REPORT_DB_TRUST_SERVER_CERTIFICATE', true),
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 4,
|
||||
idleTimeoutMillis: 30_000,
|
||||
},
|
||||
requestTimeout: 60_000,
|
||||
connectionTimeout: 20_000,
|
||||
}).connect()
|
||||
return this.pool
|
||||
}
|
||||
|
||||
async listFinishedBySpu(spu: string) {
|
||||
const normalizedSpu = spu.trim().toUpperCase()
|
||||
if (!normalizedSpu) return []
|
||||
|
||||
const schema = safeIdentifier(
|
||||
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
|
||||
'JST_REPORT_DB_SCHEMA',
|
||||
)
|
||||
const pool = await this.getPool()
|
||||
const result = await pool
|
||||
.request()
|
||||
.input('spu', sql.NVarChar(120), normalizedSpu)
|
||||
.query<JstSkuProductArchiveSourceRecord>(`
|
||||
SELECT
|
||||
autoid,
|
||||
sku_id,
|
||||
i_id,
|
||||
name,
|
||||
labels,
|
||||
brand,
|
||||
supplier_name,
|
||||
other_5,
|
||||
other_7,
|
||||
other_8,
|
||||
other_9,
|
||||
other_10,
|
||||
properties_value,
|
||||
category,
|
||||
weight,
|
||||
l,
|
||||
w,
|
||||
h,
|
||||
TODATETIMEOFFSET(created, '+08:00') AS created,
|
||||
modified,
|
||||
local_updated
|
||||
FROM [${schema}].[jst_sku]
|
||||
WHERE item_type = N'成品'
|
||||
AND UPPER(LTRIM(RTRIM(i_id))) = @spu
|
||||
ORDER BY autoid
|
||||
`)
|
||||
return result.recordset
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.pool) return
|
||||
await this.pool.close()
|
||||
this.pool = null
|
||||
}
|
||||
}
|
||||
|
||||
export const jstSkuProductArchiveSourceRepository =
|
||||
new JstSkuProductArchiveSourceRepositoryClass()
|
||||
@ -0,0 +1,262 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS,
|
||||
PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE,
|
||||
createProductArchiveFieldSourceMap,
|
||||
mergeProductArchiveFieldSourceMaps,
|
||||
parseProductArchiveFieldSourceMap,
|
||||
shouldApplyProductArchiveFieldSource,
|
||||
toProductArchiveFieldSourceJson,
|
||||
type ProductArchiveFieldSourceMap,
|
||||
} from '@/lib/supply-chain-product-archive-field-source'
|
||||
|
||||
export type JstSkuProductArchivePatch = {
|
||||
spu?: string
|
||||
sku?: string
|
||||
productName?: string
|
||||
categoryL1?: string
|
||||
productTag?: string
|
||||
brand?: string
|
||||
supplierName?: string
|
||||
productStatus?: string
|
||||
installDocId?: string
|
||||
material?: string
|
||||
color?: string
|
||||
categoryL2?: string
|
||||
weight?: Prisma.Decimal
|
||||
length?: Prisma.Decimal
|
||||
width?: Prisma.Decimal
|
||||
height?: Prisma.Decimal
|
||||
volume?: Prisma.Decimal
|
||||
packType?: string
|
||||
spec?: string
|
||||
}
|
||||
|
||||
export type JstSkuProductArchiveInput = {
|
||||
barcode: string
|
||||
businessCreatedAt: Date
|
||||
patch: JstSkuProductArchivePatch
|
||||
fieldSourceMap: ProductArchiveFieldSourceMap
|
||||
source: {
|
||||
sourceRecordId: string
|
||||
sourceProductId: string
|
||||
sourceSkuItemId: string
|
||||
sourceSystem: string
|
||||
sourceSchema: string
|
||||
sourceObject: string
|
||||
sourceCreatedAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
export type JstSkuProductArchiveUpsertResult = {
|
||||
status: 'created' | 'updated' | 'unchanged'
|
||||
changedFields: string[]
|
||||
}
|
||||
|
||||
export interface JstSkuProductArchiveRepository {
|
||||
upsert(input: JstSkuProductArchiveInput): Promise<JstSkuProductArchiveUpsertResult>
|
||||
}
|
||||
|
||||
const currentArchiveSelect = {
|
||||
id: true,
|
||||
barcode: true,
|
||||
firstSourceCode: true,
|
||||
spu: true,
|
||||
sku: true,
|
||||
image: true,
|
||||
businessCreatedAt: true,
|
||||
categoryL1: true,
|
||||
categoryL2: true,
|
||||
productName: true,
|
||||
designer: true,
|
||||
productStatus: true,
|
||||
material: true,
|
||||
color: true,
|
||||
weight: true,
|
||||
length: true,
|
||||
width: true,
|
||||
height: true,
|
||||
volume: true,
|
||||
packType: true,
|
||||
productTag: true,
|
||||
installDocId: true,
|
||||
spec: true,
|
||||
brand: true,
|
||||
orderType: true,
|
||||
storageType: true,
|
||||
isSeasonal: true,
|
||||
replaceCount: true,
|
||||
supplierName: true,
|
||||
purchaseOwner: true,
|
||||
minimumOrderQuantity: true,
|
||||
fieldSourceMap: true,
|
||||
sources: {
|
||||
where: { sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE },
|
||||
orderBy: { lastSyncedAt: 'desc' as const },
|
||||
take: 1,
|
||||
},
|
||||
} satisfies Prisma.SupplyChainProductArchiveSelect
|
||||
|
||||
type CurrentArchive = Prisma.SupplyChainProductArchiveGetPayload<{
|
||||
select: typeof currentArchiveSelect
|
||||
}>
|
||||
|
||||
function comparable(value: unknown) {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function hasValue(value: unknown) {
|
||||
return comparable(value).trim().length > 0
|
||||
}
|
||||
|
||||
function backfillProductDevelopmentFieldSources(
|
||||
archive: CurrentArchive,
|
||||
current: ProductArchiveFieldSourceMap,
|
||||
) {
|
||||
const source = archive.sources[0]
|
||||
if (!source) return current
|
||||
|
||||
const archiveRecord = archive as unknown as Record<string, unknown>
|
||||
const missingFields = Object.fromEntries(
|
||||
Object.entries(PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS).filter(
|
||||
([field]) => !current[field] && hasValue(archiveRecord[field]),
|
||||
),
|
||||
)
|
||||
if (Object.keys(missingFields).length === 0) return current
|
||||
|
||||
return mergeProductArchiveFieldSourceMaps(
|
||||
current,
|
||||
createProductArchiveFieldSourceMap({
|
||||
fields: missingFields,
|
||||
sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE,
|
||||
sourceSystem: source.sourceSystem,
|
||||
sourceSchema: source.sourceSchema,
|
||||
sourceObject: source.sourceObject,
|
||||
sourceRecordId: source.sourceRecordId,
|
||||
sourceUpdatedAt: source.lastSyncedAt,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function changedPatchFields(previous: CurrentArchive, patch: JstSkuProductArchivePatch) {
|
||||
const previousRecord = previous as unknown as Record<string, unknown>
|
||||
return Object.entries(patch)
|
||||
.filter(([field, value]) => comparable(previousRecord[field]) !== comparable(value))
|
||||
.map(([field]) => field)
|
||||
}
|
||||
|
||||
class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveRepository {
|
||||
async upsert(input: JstSkuProductArchiveInput) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.supplyChainProductArchive.findUnique({
|
||||
where: { barcode: input.barcode },
|
||||
select: currentArchiveSelect,
|
||||
})
|
||||
|
||||
const currentFieldSources = previous
|
||||
? backfillProductDevelopmentFieldSources(
|
||||
previous,
|
||||
parseProductArchiveFieldSourceMap(previous.fieldSourceMap),
|
||||
)
|
||||
: {}
|
||||
const incomingFieldSources = { ...input.fieldSourceMap }
|
||||
const shouldApplyBusinessCreatedAt = shouldApplyProductArchiveFieldSource({
|
||||
field: 'businessCreatedAt',
|
||||
currentSourceCode: currentFieldSources.businessCreatedAt?.sourceCode,
|
||||
incomingSourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
})
|
||||
if (shouldApplyBusinessCreatedAt) {
|
||||
incomingFieldSources.businessCreatedAt = {
|
||||
...incomingFieldSources.barcode,
|
||||
sourceColumn: 'created',
|
||||
}
|
||||
}
|
||||
const nextFieldSources = mergeProductArchiveFieldSourceMaps(
|
||||
currentFieldSources,
|
||||
incomingFieldSources,
|
||||
)
|
||||
const shouldUpdateBusinessCreatedAt =
|
||||
previous !== null &&
|
||||
shouldApplyBusinessCreatedAt &&
|
||||
comparable(previous.businessCreatedAt) !== comparable(input.businessCreatedAt)
|
||||
const changedFields = previous
|
||||
? [
|
||||
...changedPatchFields(previous, input.patch),
|
||||
...(shouldUpdateBusinessCreatedAt ? ['businessCreatedAt'] : []),
|
||||
]
|
||||
: []
|
||||
const sourceMapChanged =
|
||||
JSON.stringify(parseProductArchiveFieldSourceMap(previous?.fieldSourceMap)) !==
|
||||
JSON.stringify(nextFieldSources)
|
||||
|
||||
let archiveId: string
|
||||
let status: JstSkuProductArchiveUpsertResult['status']
|
||||
if (!previous) {
|
||||
const archive = await tx.supplyChainProductArchive.create({
|
||||
data: {
|
||||
barcode: input.barcode,
|
||||
businessCreatedAt: input.businessCreatedAt,
|
||||
firstSourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
fieldSourceMap: toProductArchiveFieldSourceJson(nextFieldSources),
|
||||
...input.patch,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
archiveId = archive.id
|
||||
status = 'created'
|
||||
} else {
|
||||
archiveId = previous.id
|
||||
if (changedFields.length > 0 || sourceMapChanged) {
|
||||
await tx.supplyChainProductArchive.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
...input.patch,
|
||||
...(shouldUpdateBusinessCreatedAt
|
||||
? { businessCreatedAt: input.businessCreatedAt }
|
||||
: {}),
|
||||
fieldSourceMap: toProductArchiveFieldSourceJson(nextFieldSources),
|
||||
},
|
||||
})
|
||||
status = 'updated'
|
||||
} else {
|
||||
status = 'unchanged'
|
||||
}
|
||||
}
|
||||
|
||||
await tx.supplyChainProductArchiveSource.upsert({
|
||||
where: {
|
||||
sourceCode_sourceRecordId: {
|
||||
sourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
sourceRecordId: input.source.sourceRecordId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
archiveId,
|
||||
sourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
entryMode: 'AUTO',
|
||||
...input.source,
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
archiveId,
|
||||
sourceProductId: input.source.sourceProductId,
|
||||
sourceSkuItemId: input.source.sourceSkuItemId,
|
||||
sourceSystem: input.source.sourceSystem,
|
||||
sourceSchema: input.source.sourceSchema,
|
||||
sourceObject: input.source.sourceObject,
|
||||
sourceCreatedAt: input.source.sourceCreatedAt,
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return { status, changedFields }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const jstSkuProductArchiveRepository =
|
||||
new JstSkuProductArchiveRepositoryClass()
|
||||
@ -0,0 +1,112 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { ProductArchiveCategoryConfigStatus } from '@/lib/supply-chain-product-archive-category-config'
|
||||
|
||||
const categoryConfigSelect = {
|
||||
id: true,
|
||||
categoryL2: true,
|
||||
categoryL1: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.SupplyChainProductArchiveCategoryConfigSelect
|
||||
|
||||
export type ProductArchiveCategoryConfigRecord = Prisma.SupplyChainProductArchiveCategoryConfigGetPayload<{
|
||||
select: typeof categoryConfigSelect
|
||||
}>
|
||||
|
||||
export interface SupplyChainProductArchiveCategoryConfigRepository {
|
||||
listPage(input: {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
status?: ProductArchiveCategoryConfigStatus
|
||||
}): Promise<{ records: ProductArchiveCategoryConfigRecord[]; total: number }>
|
||||
create(input: { categoryL2: string; categoryL1: string }): Promise<ProductArchiveCategoryConfigRecord>
|
||||
update(input: {
|
||||
id: string
|
||||
categoryL2: string
|
||||
categoryL1: string
|
||||
status: ProductArchiveCategoryConfigStatus
|
||||
}): Promise<ProductArchiveCategoryConfigRecord>
|
||||
findById(id: string): Promise<ProductArchiveCategoryConfigRecord | null>
|
||||
findActiveByCategoryL2(categoryL2: string): Promise<ProductArchiveCategoryConfigRecord | null>
|
||||
}
|
||||
|
||||
class SupplyChainProductArchiveCategoryConfigRepositoryClass
|
||||
implements SupplyChainProductArchiveCategoryConfigRepository
|
||||
{
|
||||
async listPage(input: {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
status?: ProductArchiveCategoryConfigStatus
|
||||
}) {
|
||||
const where: Prisma.SupplyChainProductArchiveCategoryConfigWhereInput = {
|
||||
...(input.search
|
||||
? {
|
||||
OR: [
|
||||
{ categoryL2: { contains: input.search, mode: 'insensitive' } },
|
||||
{ categoryL1: { contains: input.search, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
}
|
||||
const skip = (input.page - 1) * input.pageSize
|
||||
const [records, total] = await Promise.all([
|
||||
prisma.supplyChainProductArchiveCategoryConfig.findMany({
|
||||
where,
|
||||
select: categoryConfigSelect,
|
||||
orderBy: [{ categoryL2: 'asc' }, { id: 'asc' }],
|
||||
skip,
|
||||
take: input.pageSize,
|
||||
}),
|
||||
prisma.supplyChainProductArchiveCategoryConfig.count({ where }),
|
||||
])
|
||||
return { records, total }
|
||||
}
|
||||
|
||||
async create(input: { categoryL2: string; categoryL1: string }) {
|
||||
return prisma.supplyChainProductArchiveCategoryConfig.create({
|
||||
data: input,
|
||||
select: categoryConfigSelect,
|
||||
})
|
||||
}
|
||||
|
||||
async update(input: {
|
||||
id: string
|
||||
categoryL2: string
|
||||
categoryL1: string
|
||||
status: ProductArchiveCategoryConfigStatus
|
||||
}) {
|
||||
return prisma.supplyChainProductArchiveCategoryConfig.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
categoryL2: input.categoryL2,
|
||||
categoryL1: input.categoryL1,
|
||||
status: input.status,
|
||||
},
|
||||
select: categoryConfigSelect,
|
||||
})
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return prisma.supplyChainProductArchiveCategoryConfig.findUnique({
|
||||
where: { id },
|
||||
select: categoryConfigSelect,
|
||||
})
|
||||
}
|
||||
|
||||
async findActiveByCategoryL2(categoryL2: string) {
|
||||
return prisma.supplyChainProductArchiveCategoryConfig.findFirst({
|
||||
where: { categoryL2: categoryL2.trim(), status: 'ACTIVE' },
|
||||
select: categoryConfigSelect,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const supplyChainProductArchiveCategoryConfigRepository =
|
||||
new SupplyChainProductArchiveCategoryConfigRepositoryClass()
|
||||
|
||||
export { SupplyChainProductArchiveCategoryConfigRepositoryClass }
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
OperationLogAction,
|
||||
type OperationLog,
|
||||
Prisma,
|
||||
} from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE } from '@/lib/supply-chain-product-archive-change-log'
|
||||
|
||||
const ARCHIVE_CHANGE_ACTIONS = [
|
||||
OperationLogAction.INTAKE_PRODUCT_WRITE,
|
||||
OperationLogAction.JUSHUITAN_PRODUCT_PUSH,
|
||||
] as const
|
||||
|
||||
export type SupplyChainProductArchiveChangeLogRecord = Pick<
|
||||
OperationLog,
|
||||
| 'id'
|
||||
| 'action'
|
||||
| 'source'
|
||||
| 'actorName'
|
||||
| 'skuCode'
|
||||
| 'message'
|
||||
| 'details'
|
||||
| 'createdAt'
|
||||
> & {
|
||||
actor: { name: string } | null
|
||||
}
|
||||
|
||||
export interface SupplyChainProductArchiveChangeLogRepository {
|
||||
listByBarcode(input: {
|
||||
barcode: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}): Promise<{
|
||||
items: SupplyChainProductArchiveChangeLogRecord[]
|
||||
total: number
|
||||
}>
|
||||
}
|
||||
|
||||
class SupplyChainProductArchiveChangeLogRepositoryClass
|
||||
implements SupplyChainProductArchiveChangeLogRepository
|
||||
{
|
||||
async listByBarcode(input: {
|
||||
barcode: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}) {
|
||||
const where: Prisma.OperationLogWhereInput = {
|
||||
skuCode: input.barcode,
|
||||
workspaceCategory: PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE,
|
||||
action: { in: [...ARCHIVE_CHANGE_ACTIONS] },
|
||||
}
|
||||
const skip = (input.page - 1) * input.pageSize
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.operationLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: input.pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
source: true,
|
||||
actorName: true,
|
||||
skuCode: true,
|
||||
message: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
actor: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.operationLog.count({ where }),
|
||||
])
|
||||
|
||||
return { items, total }
|
||||
}
|
||||
}
|
||||
|
||||
export const supplyChainProductArchiveChangeLogRepository =
|
||||
new SupplyChainProductArchiveChangeLogRepositoryClass()
|
||||
|
||||
export { SupplyChainProductArchiveChangeLogRepositoryClass }
|
||||
@ -1,5 +1,16 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
mergeProductArchiveFieldSourceMaps,
|
||||
parseProductArchiveFieldSourceMap,
|
||||
shouldApplyProductArchiveFieldSource,
|
||||
toProductArchiveFieldSourceJson,
|
||||
type ProductArchiveFieldSourceMap,
|
||||
} from '@/lib/supply-chain-product-archive-field-source'
|
||||
import {
|
||||
PRODUCT_ARCHIVE_CHANGE_FIELDS,
|
||||
type ProductArchiveFieldChange,
|
||||
} from '@/lib/supply-chain-product-archive-change-log'
|
||||
|
||||
const PRODUCT_DEVELOPMENT_SOURCE_CODE = 'PRODUCT_DEVELOPMENT'
|
||||
const EXTERNAL_CATALOG_SCHEMA = 'external'
|
||||
@ -59,19 +70,92 @@ export type ProductDevelopmentArchiveInput = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ProductArchiveUpsertResult = {
|
||||
created: boolean
|
||||
changes: ProductArchiveFieldChange[]
|
||||
}
|
||||
|
||||
export interface SupplyChainProductArchiveSyncRepository {
|
||||
listProductDevelopmentSources(productId?: string): Promise<ProductCatalogWideArchiveRecord[]>
|
||||
upsertProductDevelopmentArchive(input: ProductDevelopmentArchiveInput): Promise<void>
|
||||
upsertProductDevelopmentArchive(
|
||||
input: ProductDevelopmentArchiveInput,
|
||||
): Promise<ProductArchiveUpsertResult>
|
||||
removeStaleProductDevelopmentSources(input: {
|
||||
activeSourceRecordIds: readonly string[]
|
||||
productId?: string
|
||||
}): Promise<{ sourceRowsRemoved: number; archiveRowsRemoved: number }>
|
||||
}
|
||||
|
||||
const archiveComparableSelect = {
|
||||
spu: true,
|
||||
sku: true,
|
||||
image: true,
|
||||
businessCreatedAt: true,
|
||||
categoryL1: true,
|
||||
categoryL2: true,
|
||||
productName: true,
|
||||
designer: true,
|
||||
productStatus: true,
|
||||
material: true,
|
||||
color: true,
|
||||
weight: true,
|
||||
length: true,
|
||||
width: true,
|
||||
height: true,
|
||||
volume: true,
|
||||
packType: true,
|
||||
productTag: true,
|
||||
installDocId: true,
|
||||
spec: true,
|
||||
brand: true,
|
||||
orderType: true,
|
||||
storageType: true,
|
||||
isSeasonal: true,
|
||||
replaceCount: true,
|
||||
supplierName: true,
|
||||
purchaseOwner: true,
|
||||
minimumOrderQuantity: true,
|
||||
fieldSourceMap: true,
|
||||
} satisfies Prisma.SupplyChainProductArchiveSelect
|
||||
|
||||
type ArchiveComparableRecord = Prisma.SupplyChainProductArchiveGetPayload<{
|
||||
select: typeof archiveComparableSelect
|
||||
}>
|
||||
|
||||
function comparable(value: unknown) {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function buildArchiveChanges(
|
||||
previous: ArchiveComparableRecord,
|
||||
next:
|
||||
| Prisma.SupplyChainProductArchiveUncheckedCreateInput
|
||||
| Prisma.SupplyChainProductArchiveUncheckedUpdateInput,
|
||||
) {
|
||||
const nextRecord = next as Record<string, unknown>
|
||||
return PRODUCT_ARCHIVE_CHANGE_FIELDS.flatMap((field) => {
|
||||
if (!(field.key in nextRecord)) return []
|
||||
const previousValue = comparable(previous[field.key as keyof ArchiveComparableRecord])
|
||||
const nextValue = comparable(nextRecord[field.key])
|
||||
if (previousValue === nextValue) return []
|
||||
return [
|
||||
{
|
||||
field: field.key,
|
||||
fieldLabel: field.label,
|
||||
previousValue,
|
||||
nextValue,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function buildArchiveUpdate(
|
||||
archive: Prisma.SupplyChainProductArchiveUncheckedCreateInput,
|
||||
previousFieldSourceMap: unknown,
|
||||
): Prisma.SupplyChainProductArchiveUncheckedUpdateInput {
|
||||
return {
|
||||
const update: Prisma.SupplyChainProductArchiveUncheckedUpdateInput = {
|
||||
spu: archive.spu,
|
||||
sku: archive.sku,
|
||||
image: archive.image,
|
||||
@ -101,6 +185,28 @@ function buildArchiveUpdate(
|
||||
purchaseOwner: archive.purchaseOwner,
|
||||
minimumOrderQuantity: archive.minimumOrderQuantity,
|
||||
}
|
||||
const currentFieldSources = parseProductArchiveFieldSourceMap(previousFieldSourceMap)
|
||||
const incomingFieldSources = parseProductArchiveFieldSourceMap(archive.fieldSourceMap)
|
||||
const appliedFieldSources: ProductArchiveFieldSourceMap = {}
|
||||
const updateRecord = update as Record<string, unknown>
|
||||
|
||||
Object.entries(incomingFieldSources).forEach(([field, source]) => {
|
||||
if (
|
||||
!shouldApplyProductArchiveFieldSource({
|
||||
field,
|
||||
currentSourceCode: currentFieldSources[field]?.sourceCode,
|
||||
incomingSourceCode: source.sourceCode,
|
||||
})
|
||||
) {
|
||||
delete updateRecord[field]
|
||||
return
|
||||
}
|
||||
appliedFieldSources[field] = source
|
||||
})
|
||||
update.fieldSourceMap = toProductArchiveFieldSourceJson(
|
||||
mergeProductArchiveFieldSourceMaps(currentFieldSources, appliedFieldSources),
|
||||
)
|
||||
return update
|
||||
}
|
||||
|
||||
class SupplyChainProductArchiveSyncRepositoryClass
|
||||
@ -199,11 +305,16 @@ class SupplyChainProductArchiveSyncRepositoryClass
|
||||
}
|
||||
|
||||
async upsertProductDevelopmentArchive(input: ProductDevelopmentArchiveInput) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.supplyChainProductArchive.findUnique({
|
||||
where: { barcode: input.barcode },
|
||||
select: archiveComparableSelect,
|
||||
})
|
||||
const archiveUpdate = buildArchiveUpdate(input.archive, previous?.fieldSourceMap)
|
||||
const archive = await tx.supplyChainProductArchive.upsert({
|
||||
where: { barcode: input.barcode },
|
||||
create: input.archive,
|
||||
update: buildArchiveUpdate(input.archive),
|
||||
update: archiveUpdate,
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
@ -231,6 +342,11 @@ class SupplyChainProductArchiveSyncRepositoryClass
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
created: !previous,
|
||||
changes: previous ? buildArchiveChanges(previous, archiveUpdate) : [],
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { ProductArchiveFieldSourceMap } from '@/lib/supply-chain-product-archive-field-source'
|
||||
|
||||
export type YunshanCreatedAtArchiveRecord = {
|
||||
id: string
|
||||
barcode: string
|
||||
businessCreatedAt: Date
|
||||
fieldSourceMap: unknown
|
||||
}
|
||||
|
||||
export type YunshanCreatedAtApplyRecord = {
|
||||
archiveId: string
|
||||
barcode: string
|
||||
businessCreatedAt: Date
|
||||
fieldSourceMap: ProductArchiveFieldSourceMap
|
||||
}
|
||||
|
||||
export type YunshanCreatedAtSourceMetadata = {
|
||||
sourceCode: string
|
||||
sourceSystem: string
|
||||
sourceSchema: string
|
||||
sourceObject: string
|
||||
}
|
||||
|
||||
export interface YunshanProductCreatedAtRepository {
|
||||
listArchives(): Promise<YunshanCreatedAtArchiveRecord[]>
|
||||
applyCreatedAt(input: {
|
||||
records: YunshanCreatedAtApplyRecord[]
|
||||
source: YunshanCreatedAtSourceMetadata
|
||||
}): Promise<number>
|
||||
}
|
||||
|
||||
const APPLY_BATCH_SIZE = 250
|
||||
|
||||
function chunks<T>(items: T[], size: number) {
|
||||
const result: T[][] = []
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
result.push(items.slice(index, index + size))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
class YunshanProductCreatedAtRepositoryClass
|
||||
implements YunshanProductCreatedAtRepository
|
||||
{
|
||||
async listArchives() {
|
||||
return prisma.supplyChainProductArchive.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
barcode: true,
|
||||
businessCreatedAt: true,
|
||||
fieldSourceMap: true,
|
||||
},
|
||||
orderBy: { barcode: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
async applyCreatedAt(input: {
|
||||
records: YunshanCreatedAtApplyRecord[]
|
||||
source: YunshanCreatedAtSourceMetadata
|
||||
}) {
|
||||
let updated = 0
|
||||
for (const batch of chunks(input.records, APPLY_BATCH_SIZE)) {
|
||||
updated += await prisma.$transaction(async (tx) => {
|
||||
const archiveValues = Prisma.join(
|
||||
batch.map((record) =>
|
||||
Prisma.sql`(
|
||||
${record.archiveId}::text,
|
||||
${record.businessCreatedAt}::timestamptz,
|
||||
${JSON.stringify(record.fieldSourceMap)}::jsonb
|
||||
)`,
|
||||
),
|
||||
)
|
||||
const updatedArchives = await tx.$executeRaw(Prisma.sql`
|
||||
WITH incoming("archiveId", "businessCreatedAt", "fieldSourceMap") AS (
|
||||
VALUES ${archiveValues}
|
||||
)
|
||||
UPDATE "SupplyChainProductArchive" AS archive
|
||||
SET
|
||||
"businessCreatedAt" = incoming."businessCreatedAt",
|
||||
"fieldSourceMap" = incoming."fieldSourceMap",
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
FROM incoming
|
||||
WHERE archive."id" = incoming."archiveId"
|
||||
`)
|
||||
|
||||
const sourceValues = Prisma.join(
|
||||
batch.map((record) =>
|
||||
Prisma.sql`(
|
||||
${randomUUID()}::text,
|
||||
${record.archiveId}::text,
|
||||
${input.source.sourceCode}::text,
|
||||
'IMPORT'::text,
|
||||
${record.barcode}::text,
|
||||
${record.barcode}::text,
|
||||
${record.barcode}::text,
|
||||
${input.source.sourceSystem}::text,
|
||||
${input.source.sourceSchema}::text,
|
||||
${input.source.sourceObject}::text,
|
||||
${record.businessCreatedAt}::timestamptz,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)`,
|
||||
),
|
||||
)
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
INSERT INTO "SupplyChainProductArchiveSource" (
|
||||
"id",
|
||||
"archiveId",
|
||||
"sourceCode",
|
||||
"entryMode",
|
||||
"sourceRecordId",
|
||||
"sourceProductId",
|
||||
"sourceSkuItemId",
|
||||
"sourceSystem",
|
||||
"sourceSchema",
|
||||
"sourceObject",
|
||||
"sourceCreatedAt",
|
||||
"lastSyncedAt",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
VALUES ${sourceValues}
|
||||
ON CONFLICT ("sourceCode", "sourceRecordId") DO UPDATE SET
|
||||
"archiveId" = EXCLUDED."archiveId",
|
||||
"entryMode" = EXCLUDED."entryMode",
|
||||
"sourceProductId" = EXCLUDED."sourceProductId",
|
||||
"sourceSkuItemId" = EXCLUDED."sourceSkuItemId",
|
||||
"sourceSystem" = EXCLUDED."sourceSystem",
|
||||
"sourceSchema" = EXCLUDED."sourceSchema",
|
||||
"sourceObject" = EXCLUDED."sourceObject",
|
||||
"sourceCreatedAt" = EXCLUDED."sourceCreatedAt",
|
||||
"lastSyncedAt" = EXCLUDED."lastSyncedAt",
|
||||
"updatedAt" = EXCLUDED."updatedAt"
|
||||
`)
|
||||
return updatedArchives
|
||||
})
|
||||
}
|
||||
return updated
|
||||
}
|
||||
}
|
||||
|
||||
export const yunshanProductCreatedAtRepository =
|
||||
new YunshanProductCreatedAtRepositoryClass()
|
||||
@ -0,0 +1,171 @@
|
||||
import {
|
||||
JstSkuProductArchiveSyncService,
|
||||
extractJstSkuArchiveSpec,
|
||||
mapJstSkuProductArchiveSource,
|
||||
normalizeJstSkuPackType,
|
||||
} from '../jst-sku-product-archive-sync.service'
|
||||
import type { JstSkuProductArchiveSourceRecord } from '@/server/repositories/jst-sku-product-archive-source.repository'
|
||||
import type { SupplyChainProductArchiveCategoryConfigRepository } from '@/server/repositories/supply-chain-product-archive-category-config.repository'
|
||||
|
||||
jest.mock('@/server/repositories/jst-sku-product-archive-source.repository', () => ({
|
||||
jstSkuProductArchiveSourceRepository: {
|
||||
listFinishedBySpu: jest.fn(),
|
||||
close: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function createSourceRecord(
|
||||
overrides: Partial<JstSkuProductArchiveSourceRecord> = {},
|
||||
): JstSkuProductArchiveSourceRecord {
|
||||
return {
|
||||
autoid: 200595,
|
||||
sku_id: 'ZSJ008913N1042V01',
|
||||
labels: '1688-yijian-daifa,matched',
|
||||
brand: 'other-brand-standard',
|
||||
supplier_name: '景瓴十方',
|
||||
i_id: 'ZSJ0089',
|
||||
name: '款式八 61.5-80/17/35 原木色',
|
||||
other_5: '停产',
|
||||
other_7: 'ZSJ008913',
|
||||
other_8: '非双包',
|
||||
other_9: 'ZSJ0089K08AV01',
|
||||
other_10: '12生态板A级',
|
||||
properties_value: '原木色',
|
||||
category: '住宅家具 - 书桌收纳置物架',
|
||||
weight: 4.32,
|
||||
l: 49,
|
||||
w: 18.5,
|
||||
h: 10.5,
|
||||
created: new Date('2025-06-23T11:12:33.000Z'),
|
||||
modified: new Date('2026-07-29T15:57:35.000Z'),
|
||||
local_updated: new Date('2026-07-29T16:22:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createCategoryConfig() {
|
||||
return {
|
||||
id: 'category-config-test',
|
||||
categoryL2: createSourceRecord().category ?? '',
|
||||
categoryL1: 'table-category',
|
||||
status: 'ACTIVE' as const,
|
||||
createdAt: new Date('2026-08-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-07T00:00:00.000Z'),
|
||||
}
|
||||
}
|
||||
|
||||
describe('JST SKU product archive sync', () => {
|
||||
it.each([
|
||||
['1', '非双包'],
|
||||
['2', '双包'],
|
||||
['9', '九包'],
|
||||
['三包', '三包'],
|
||||
['-', ''],
|
||||
['非订单式', ''],
|
||||
])('normalizes package count %s to %s', (value, expected) => {
|
||||
expect(normalizeJstSkuPackType(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['款式一 60/20/43 原木色', '60/20/43'],
|
||||
['款式八 61.5-80/17/35 原木色', '61.5-80/17/35'],
|
||||
['黄衫木色120*40*148cm', '120/40/148'],
|
||||
['科技布180x65x30cm', '180/65/30'],
|
||||
])('extracts the three-axis size from %s', (name, expected) => {
|
||||
expect(extractJstSkuArchiveSpec(name)).toBe(expected)
|
||||
})
|
||||
|
||||
it('maps only the confirmed JST SKU archive fields and records their columns', () => {
|
||||
const mapped = mapJstSkuProductArchiveSource(createSourceRecord())
|
||||
|
||||
expect(mapped).toEqual(
|
||||
expect.objectContaining({
|
||||
barcode: 'ZSJ008913N1042V01',
|
||||
patch: expect.objectContaining({
|
||||
productTag: '1688-yijian-daifa,matched',
|
||||
brand: 'other-brand-standard',
|
||||
supplierName: '景瓴十方',
|
||||
spu: 'ZSJ0089',
|
||||
sku: 'ZSJ008913',
|
||||
productName: '款式八 61.5-80/17/35 原木色',
|
||||
productStatus: '停产',
|
||||
installDocId: 'ZSJ0089K08AV01',
|
||||
material: '12生态板A级',
|
||||
color: '原木色',
|
||||
categoryL2: '住宅家具 - 书桌收纳置物架',
|
||||
packType: '非双包',
|
||||
spec: '61.5-80/17/35',
|
||||
}),
|
||||
source: expect.objectContaining({
|
||||
sourceRecordId: '200595',
|
||||
sourceProductId: 'ZSJ0089',
|
||||
sourceSkuItemId: 'ZSJ008913N1042V01',
|
||||
sourceSchema: 'rpt',
|
||||
sourceObject: 'jst_sku',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mapped?.fieldSourceMap.barcode.sourceColumn).toBe('sku_id')
|
||||
expect(mapped?.fieldSourceMap.sku.sourceColumn).toBe('other_7')
|
||||
expect(mapped?.fieldSourceMap.supplierName.sourceColumn).toBe('supplier_name')
|
||||
expect(mapped?.patch.volume?.toString()).toBe('9518.25')
|
||||
expect(mapped?.fieldSourceMap.volume.sourceColumn).toBe('l*w*h')
|
||||
expect(mapped?.fieldSourceMap.spec.sourceColumn).toBe('name')
|
||||
expect(mapped?.fieldSourceMap).not.toHaveProperty('image')
|
||||
})
|
||||
|
||||
it('maps the configured primary category and records its config source', () => {
|
||||
const mapped = mapJstSkuProductArchiveSource(createSourceRecord(), createCategoryConfig())
|
||||
|
||||
expect(mapped?.patch.categoryL1).toBe('table-category')
|
||||
expect(mapped?.fieldSourceMap.categoryL1).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceCode: 'CATEGORY_CONFIG',
|
||||
sourceColumn: 'categoryL1',
|
||||
sourceRecordId: 'category-config-test',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not invent values for blank or invalid source fields', () => {
|
||||
const mapped = mapJstSkuProductArchiveSource(
|
||||
createSourceRecord({ other_5: '未知', other_7: '', other_10: '', weight: null }),
|
||||
)
|
||||
|
||||
expect(mapped?.patch).not.toHaveProperty('productStatus')
|
||||
expect(mapped?.patch).not.toHaveProperty('sku')
|
||||
expect(mapped?.patch).not.toHaveProperty('material')
|
||||
expect(mapped?.patch).not.toHaveProperty('weight')
|
||||
expect(mapped?.fieldSourceMap).not.toHaveProperty('productStatus')
|
||||
})
|
||||
|
||||
it('dry-runs without writing the archive database', async () => {
|
||||
const sourceRepository = {
|
||||
listFinishedBySpu: jest.fn().mockResolvedValue([createSourceRecord()]),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
const archiveRepository = {
|
||||
upsert: jest.fn(),
|
||||
}
|
||||
const categoryConfigRepository = {
|
||||
findActiveByCategoryL2: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as SupplyChainProductArchiveCategoryConfigRepository
|
||||
const service = new JstSkuProductArchiveSyncService(
|
||||
sourceRepository,
|
||||
archiveRepository,
|
||||
categoryConfigRepository,
|
||||
)
|
||||
|
||||
await expect(service.syncSpu({ spu: 'zsj0089' })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
apply: false,
|
||||
spu: 'ZSJ0089',
|
||||
found: 1,
|
||||
mapped: 1,
|
||||
skipped: 0,
|
||||
}),
|
||||
)
|
||||
expect(archiveRepository.upsert).not.toHaveBeenCalled()
|
||||
expect(sourceRepository.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,102 @@
|
||||
import { OperationLogAction, OperationLogStatus } from '@prisma/client'
|
||||
import { operationLogService } from '../operation-log.service'
|
||||
import {
|
||||
recordProductArchiveFieldChanges,
|
||||
recordProductArchiveImported,
|
||||
} from '../supply-chain-product-archive-change-log.service'
|
||||
|
||||
jest.mock('../operation-log.service', () => ({
|
||||
operationLogService: { record: jest.fn() },
|
||||
}))
|
||||
|
||||
const recordMock = jest.mocked(operationLogService.record)
|
||||
|
||||
describe('supply-chain product archive change log', () => {
|
||||
beforeEach(() => {
|
||||
recordMock.mockReset()
|
||||
})
|
||||
|
||||
it('records a first import as one database-source operation', async () => {
|
||||
await recordProductArchiveImported({
|
||||
barcode: 'BJ021801N3370V01',
|
||||
actor: { source: 'SYSTEM', actorName: '数据库推送' },
|
||||
source: {
|
||||
sourceSystem: '家具产品开发系统',
|
||||
sourceSchema: 'external',
|
||||
sourceObject: 'product_catalog_wide_v1',
|
||||
sourceRecordedAt: new Date('2026-07-16T16:48:47.555Z'),
|
||||
},
|
||||
})
|
||||
|
||||
expect(recordMock).toHaveBeenCalledTimes(1)
|
||||
expect(recordMock).toHaveBeenCalledWith({
|
||||
action: OperationLogAction.JUSHUITAN_PRODUCT_PUSH,
|
||||
status: OperationLogStatus.SUCCESS,
|
||||
source: 'SYSTEM',
|
||||
actorId: undefined,
|
||||
actorName: '数据库推送',
|
||||
skuCode: 'BJ021801N3370V01',
|
||||
workspaceCategory: 'SUPPLY_CHAIN_PRODUCT_ARCHIVE',
|
||||
message: '数据库推送:导入商品档案',
|
||||
details: {
|
||||
kind: 'IMPORTED',
|
||||
barcode: 'BJ021801N3370V01',
|
||||
sourceSystem: '家具产品开发系统',
|
||||
sourceSchema: 'external',
|
||||
sourceObject: 'product_catalog_wide_v1',
|
||||
sourceRecordedAt: '2026-07-16T16:48:47.555Z',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('records each changed field with the actual system user', async () => {
|
||||
await recordProductArchiveFieldChanges({
|
||||
barcode: 'BJ021801N3370V01',
|
||||
actor: {
|
||||
source: 'USER',
|
||||
actorId: 'user-1',
|
||||
actorName: '测试用户',
|
||||
},
|
||||
changes: [
|
||||
{
|
||||
field: 'productStatus',
|
||||
fieldLabel: '商品状态',
|
||||
previousValue: '销售',
|
||||
nextValue: '退市',
|
||||
},
|
||||
{
|
||||
field: 'color',
|
||||
fieldLabel: '颜色',
|
||||
previousValue: '',
|
||||
nextValue: '胡桃色',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(recordMock).toHaveBeenCalledTimes(2)
|
||||
expect(recordMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: OperationLogAction.INTAKE_PRODUCT_WRITE,
|
||||
source: 'USER',
|
||||
actorId: 'user-1',
|
||||
actorName: '测试用户',
|
||||
message: '系统修改:商品状态',
|
||||
details: expect.objectContaining({
|
||||
previousValue: '销售',
|
||||
nextValue: '退市',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(recordMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
message: '系统修改:颜色',
|
||||
details: expect.objectContaining({
|
||||
previousValue: '',
|
||||
nextValue: '胡桃色',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -5,6 +5,15 @@ import {
|
||||
SupplyChainProductArchiveSyncService,
|
||||
mapProductCatalogWideArchiveSource,
|
||||
} from '../supply-chain-product-archive-sync.service'
|
||||
import {
|
||||
recordProductArchiveFieldChanges,
|
||||
recordProductArchiveImported,
|
||||
} from '../supply-chain-product-archive-change-log.service'
|
||||
|
||||
jest.mock('../supply-chain-product-archive-change-log.service', () => ({
|
||||
recordProductArchiveFieldChanges: jest.fn(),
|
||||
recordProductArchiveImported: jest.fn(),
|
||||
}))
|
||||
|
||||
function createSourceRecord() {
|
||||
return {
|
||||
@ -36,7 +45,7 @@ function createSourceRecord() {
|
||||
package_length_cm: '15',
|
||||
package_width_cm: '72',
|
||||
package_height_cm: '50',
|
||||
package_volume_cm3: '54000',
|
||||
package_volume_cm3: '1',
|
||||
package_set_count: '1',
|
||||
purchase_order_method: '非订单式',
|
||||
archive_shipping_method: '代发',
|
||||
@ -46,6 +55,10 @@ function createSourceRecord() {
|
||||
}
|
||||
|
||||
describe('supply-chain product archive wide-catalog sync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('maps the unified catalog row and keeps source lineage', () => {
|
||||
const mapped = mapProductCatalogWideArchiveSource(createSourceRecord())
|
||||
|
||||
@ -116,6 +129,73 @@ describe('supply-chain product archive wide-catalog sync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('records one import entry when a barcode first enters the archive', async () => {
|
||||
const repository = {
|
||||
listProductDevelopmentSources: jest.fn().mockResolvedValue([createSourceRecord()]),
|
||||
upsertProductDevelopmentArchive: jest.fn().mockResolvedValue({
|
||||
created: true,
|
||||
changes: [],
|
||||
}),
|
||||
removeStaleProductDevelopmentSources: jest.fn().mockResolvedValue({
|
||||
sourceRowsRemoved: 0,
|
||||
archiveRowsRemoved: 0,
|
||||
}),
|
||||
}
|
||||
const service = new SupplyChainProductArchiveSyncService(repository as never)
|
||||
|
||||
await service.syncProductDevelopment('product-1')
|
||||
|
||||
expect(recordProductArchiveImported).toHaveBeenCalledTimes(1)
|
||||
expect(recordProductArchiveImported).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
barcode: 'BJ021801N3370V01',
|
||||
actor: { source: 'SYSTEM', actorName: '数据库推送' },
|
||||
source: expect.objectContaining({
|
||||
sourceSystem: '家具产品开发系统',
|
||||
sourceSchema: 'external',
|
||||
sourceObject: 'product_catalog_wide_v1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(recordProductArchiveFieldChanges).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records field changes with the system user who saved the product', async () => {
|
||||
const changes = [
|
||||
{
|
||||
field: 'productStatus',
|
||||
fieldLabel: '商品状态',
|
||||
previousValue: '销售',
|
||||
nextValue: '退市',
|
||||
},
|
||||
]
|
||||
const repository = {
|
||||
listProductDevelopmentSources: jest.fn().mockResolvedValue([createSourceRecord()]),
|
||||
upsertProductDevelopmentArchive: jest.fn().mockResolvedValue({
|
||||
created: false,
|
||||
changes,
|
||||
}),
|
||||
removeStaleProductDevelopmentSources: jest.fn().mockResolvedValue({
|
||||
sourceRowsRemoved: 0,
|
||||
archiveRowsRemoved: 0,
|
||||
}),
|
||||
}
|
||||
const service = new SupplyChainProductArchiveSyncService(repository as never)
|
||||
const actor = { source: 'USER' as const, actorId: 'user-1', actorName: '测试用户' }
|
||||
|
||||
await service.syncProductDevelopment('product-1', actor)
|
||||
|
||||
expect(recordProductArchiveFieldChanges).toHaveBeenCalledTimes(1)
|
||||
expect(recordProductArchiveFieldChanges).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
barcode: 'BJ021801N3370V01',
|
||||
actor,
|
||||
changes,
|
||||
}),
|
||||
)
|
||||
expect(recordProductArchiveImported).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not turn an already-saved product into a save failure when sync fails', async () => {
|
||||
const repository = {
|
||||
listProductDevelopmentSources: jest.fn().mockRejectedValue(new Error('archive unavailable')),
|
||||
|
||||
@ -4,42 +4,11 @@ import {
|
||||
import {
|
||||
calculateProductArchiveVolume,
|
||||
normalizeProductArchiveSalesStatus,
|
||||
resolveProductArchiveCategory,
|
||||
resolveProductArchivePackageType,
|
||||
} from '../supply-chain-product-archive-sync.service'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
describe('supply-chain product archive field mapping', () => {
|
||||
it('maps sales category and product category into the two archive category levels', () => {
|
||||
expect(resolveProductArchiveCategory('桌类', '电脑桌')).toEqual({
|
||||
categoryL1: '桌类',
|
||||
categoryL2: '住宅家具 - 电脑桌',
|
||||
})
|
||||
expect(resolveProductArchiveCategory('几类', '角几/边几')).toEqual({
|
||||
categoryL1: '几类',
|
||||
categoryL2: '住宅家具 - 角几/边几',
|
||||
})
|
||||
expect(resolveProductArchiveCategory('其他', '配件')).toEqual({
|
||||
categoryL1: '其他',
|
||||
categoryL2: '其他 - 配件',
|
||||
})
|
||||
expect(resolveProductArchiveCategory('桌类', '会议桌')).toEqual({
|
||||
categoryL1: '桌类',
|
||||
categoryL2: '商业/办公家具 - 会议桌',
|
||||
})
|
||||
expect(resolveProductArchiveCategory('柜类', '前台/接待台(新增)')).toEqual({
|
||||
categoryL1: '柜类',
|
||||
categoryL2: '商业/办公家具 - 前台/接待台(新增)',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves missing category values blank', () => {
|
||||
expect(resolveProductArchiveCategory('', '')).toEqual({
|
||||
categoryL1: '',
|
||||
categoryL2: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('only exposes the three confirmed sales statuses', () => {
|
||||
expect(normalizeProductArchiveSalesStatus('销售')).toBe('销售')
|
||||
expect(normalizeProductArchiveSalesStatus('停产')).toBe('停产')
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import {
|
||||
YunshanProductCreatedAtService,
|
||||
parseYunshanCreatedAt,
|
||||
} from '../yunshan-product-created-at.service'
|
||||
|
||||
describe('Yunshan product created-at import', () => {
|
||||
it('parses the workbook time as Beijing local time', () => {
|
||||
expect(parseYunshanCreatedAt('2019-10-20 21:45:56')?.toISOString()).toBe(
|
||||
'2019-10-20T13:45:56.000Z',
|
||||
)
|
||||
expect(parseYunshanCreatedAt('')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches by barcode and records the workbook field source', async () => {
|
||||
const repository = {
|
||||
listArchives: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'archive-1',
|
||||
barcode: '164300',
|
||||
businessCreatedAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
fieldSourceMap: {},
|
||||
},
|
||||
]),
|
||||
applyCreatedAt: jest.fn().mockImplementation(async ({ records }) => records.length),
|
||||
}
|
||||
const service = new YunshanProductCreatedAtService(repository)
|
||||
|
||||
const result = await service.importRows({
|
||||
rows: [
|
||||
{ rowNumber: 2, barcode: ' 164300 ', createdAtText: '2019-10-20 21:45:56' },
|
||||
{ rowNumber: 3, barcode: 'missing', createdAtText: '2019-10-20 21:45:57' },
|
||||
],
|
||||
apply: true,
|
||||
fileName: '云杉商品资料.xlsx',
|
||||
sheetName: '商品资料导出(含明细)',
|
||||
fileModifiedAt: new Date('2026-08-06T05:10:47.000Z'),
|
||||
})
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceRows: 2,
|
||||
uniqueBarcodes: 2,
|
||||
matchedArchives: 1,
|
||||
unmatchedBarcodes: 1,
|
||||
updated: 1,
|
||||
}),
|
||||
)
|
||||
expect(repository.applyCreatedAt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
records: [
|
||||
expect.objectContaining({
|
||||
archiveId: 'archive-1',
|
||||
barcode: '164300',
|
||||
businessCreatedAt: new Date('2019-10-20T13:45:56.000Z'),
|
||||
fieldSourceMap: {
|
||||
businessCreatedAt: expect.objectContaining({
|
||||
sourceCode: 'YUNSHAN_PRODUCT_EXPORT',
|
||||
sourceColumn: '创建时间',
|
||||
sourceRecordId: '164300',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks apply when one barcode has conflicting creation times', async () => {
|
||||
const repository = {
|
||||
listArchives: jest.fn().mockResolvedValue([]),
|
||||
applyCreatedAt: jest.fn(),
|
||||
}
|
||||
const service = new YunshanProductCreatedAtService(repository)
|
||||
|
||||
await expect(
|
||||
service.importRows({
|
||||
rows: [
|
||||
{ rowNumber: 2, barcode: 'A001', createdAtText: '2019-10-20 21:45:56' },
|
||||
{ rowNumber: 3, barcode: 'A001', createdAtText: '2019-10-21 21:45:56' },
|
||||
],
|
||||
apply: true,
|
||||
fileName: '云杉商品资料.xlsx',
|
||||
sheetName: '商品资料导出(含明细)',
|
||||
fileModifiedAt: new Date('2026-08-06T05:10:47.000Z'),
|
||||
}),
|
||||
).rejects.toThrow('1 个条码对应多个创建时间')
|
||||
expect(repository.applyCreatedAt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -87,6 +87,8 @@ import {
|
||||
} from './intake-non-integrated-operation-log.service'
|
||||
import { skuQualityInspectionService } from './sku-quality-inspection.service'
|
||||
import { spuNumberReservationService } from './spu-number-reservation.service'
|
||||
import { supplyChainProductArchiveSyncService } from './supply-chain-product-archive-sync.service'
|
||||
import { archiveChangeActorFromUser } from './supply-chain-product-archive-change-log.service'
|
||||
|
||||
type NonIntegratedUploadPresentationPreview = {
|
||||
pdfUrl: string
|
||||
@ -6746,6 +6748,11 @@ class IntakeNonIntegratedProductService {
|
||||
user,
|
||||
changes: workspaceConfirmationChanges,
|
||||
})
|
||||
await supplyChainProductArchiveSyncService.syncProductDevelopmentAfterSave(
|
||||
savedProduct.id,
|
||||
archiveChangeActorFromUser(user),
|
||||
)
|
||||
|
||||
return savedProduct
|
||||
}
|
||||
|
||||
@ -7095,6 +7102,11 @@ class IntakeNonIntegratedProductService {
|
||||
user,
|
||||
changes: workspaceConfirmationChanges,
|
||||
})
|
||||
await supplyChainProductArchiveSyncService.syncProductDevelopmentAfterSave(
|
||||
savedProduct.id,
|
||||
archiveChangeActorFromUser(user),
|
||||
)
|
||||
|
||||
return savedProduct
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,293 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import {
|
||||
CATEGORY_CONFIG_ARCHIVE_SOURCE_CODE,
|
||||
JST_SKU_ARCHIVE_FIELD_COLUMNS,
|
||||
JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
createProductArchiveFieldSourceMap,
|
||||
} from '@/lib/supply-chain-product-archive-field-source'
|
||||
import { calculateProductArchiveVolumeDecimal } from '@/lib/supply-chain-product-archive-volume'
|
||||
import {
|
||||
jstSkuProductArchiveSourceRepository,
|
||||
type JstSkuProductArchiveSourceRecord,
|
||||
type JstSkuProductArchiveSourceRepository,
|
||||
} from '@/server/repositories/jst-sku-product-archive-source.repository'
|
||||
import {
|
||||
jstSkuProductArchiveRepository,
|
||||
type JstSkuProductArchiveInput,
|
||||
type JstSkuProductArchivePatch,
|
||||
type JstSkuProductArchiveRepository,
|
||||
} from '@/server/repositories/jst-sku-product-archive.repository'
|
||||
import {
|
||||
supplyChainProductArchiveCategoryConfigRepository,
|
||||
type ProductArchiveCategoryConfigRecord,
|
||||
type SupplyChainProductArchiveCategoryConfigRepository,
|
||||
} from '@/server/repositories/supply-chain-product-archive-category-config.repository'
|
||||
|
||||
const SOURCE_SYSTEM = '聚水潭 JSReportReplica'
|
||||
const SOURCE_SCHEMA = 'rpt'
|
||||
const SOURCE_OBJECT = 'jst_sku'
|
||||
const CATEGORY_CONFIG_SOURCE_SYSTEM = '供应链中心类目配置'
|
||||
const CATEGORY_CONFIG_SOURCE_SCHEMA = 'public'
|
||||
const CATEGORY_CONFIG_SOURCE_OBJECT = 'SupplyChainProductArchiveCategoryConfig'
|
||||
const SALES_STATUSES = new Set(['销售', '停产', '退市'])
|
||||
const PACK_TYPE_BY_COUNT: Record<string, string> = {
|
||||
'1': '非双包',
|
||||
'2': '双包',
|
||||
'3': '三包',
|
||||
'4': '四包',
|
||||
'5': '五包',
|
||||
'6': '六包',
|
||||
'7': '七包',
|
||||
'8': '八包',
|
||||
'9': '九包',
|
||||
}
|
||||
const PACK_TYPE_LABELS = new Set([
|
||||
'非双包',
|
||||
'双包',
|
||||
'三包',
|
||||
'四包',
|
||||
'五包',
|
||||
'六包',
|
||||
'七包',
|
||||
'八包',
|
||||
'九包',
|
||||
])
|
||||
|
||||
export type JstSkuProductArchiveSyncResult = {
|
||||
apply: boolean
|
||||
spu: string
|
||||
found: number
|
||||
mapped: number
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
skipped: number
|
||||
preview: Array<{
|
||||
barcode: string
|
||||
patch: JstSkuProductArchivePatch
|
||||
sourceColumns: Record<string, string>
|
||||
}>
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
if (value === null || value === undefined) return ''
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
function decimal(value: unknown) {
|
||||
const normalized = text(value)
|
||||
if (!normalized) return null
|
||||
const number = Number(normalized.replace(/,/g, ''))
|
||||
return Number.isFinite(number) ? new Prisma.Decimal(number) : null
|
||||
}
|
||||
|
||||
export function normalizeJstSkuPackType(value: unknown) {
|
||||
const normalized = text(value)
|
||||
if (!normalized || normalized === '-') return ''
|
||||
if (PACK_TYPE_BY_COUNT[normalized]) return PACK_TYPE_BY_COUNT[normalized]
|
||||
return PACK_TYPE_LABELS.has(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
export function extractJstSkuArchiveSpec(name: string) {
|
||||
const match = name.match(
|
||||
/\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?(?:\s*(?:\/|[xX×*])\s*\(?\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?\)?){2}/,
|
||||
)
|
||||
return (
|
||||
match?.[0]
|
||||
?.replace(/[xX×*]/g, '/')
|
||||
.replace(/[()()]/g, '')
|
||||
.replace(/\s+/g, '') ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function addTextField(
|
||||
patch: JstSkuProductArchivePatch,
|
||||
fieldColumns: Record<string, string>,
|
||||
field: keyof JstSkuProductArchivePatch,
|
||||
value: unknown,
|
||||
sourceColumn: string,
|
||||
) {
|
||||
const normalized = text(value)
|
||||
if (!normalized) return
|
||||
;(patch as Record<string, unknown>)[field] = normalized
|
||||
fieldColumns[field] = sourceColumn
|
||||
}
|
||||
|
||||
function addDecimalField(
|
||||
patch: JstSkuProductArchivePatch,
|
||||
fieldColumns: Record<string, string>,
|
||||
field: 'weight' | 'length' | 'width' | 'height',
|
||||
value: unknown,
|
||||
sourceColumn: string,
|
||||
) {
|
||||
const normalized = decimal(value)
|
||||
if (!normalized) return
|
||||
patch[field] = normalized
|
||||
fieldColumns[field] = sourceColumn
|
||||
}
|
||||
|
||||
export function mapJstSkuProductArchiveSource(
|
||||
record: JstSkuProductArchiveSourceRecord,
|
||||
categoryConfig?: ProductArchiveCategoryConfigRecord | null,
|
||||
): JstSkuProductArchiveInput | null {
|
||||
const barcode = text(record.sku_id).toUpperCase()
|
||||
const sourceRecordId = text(record.autoid)
|
||||
const businessCreatedAt = record.created
|
||||
if (!barcode || !sourceRecordId || !businessCreatedAt) return null
|
||||
|
||||
const patch: JstSkuProductArchivePatch = {}
|
||||
const fieldColumns: Record<string, string> = {
|
||||
barcode: JST_SKU_ARCHIVE_FIELD_COLUMNS.barcode,
|
||||
}
|
||||
addTextField(patch, fieldColumns, 'spu', text(record.i_id).toUpperCase(), 'i_id')
|
||||
addTextField(patch, fieldColumns, 'sku', text(record.other_7).toUpperCase(), 'other_7')
|
||||
addTextField(patch, fieldColumns, 'productName', record.name, 'name')
|
||||
addTextField(patch, fieldColumns, 'productTag', record.labels, 'labels')
|
||||
addTextField(patch, fieldColumns, 'brand', record.brand, 'brand')
|
||||
addTextField(patch, fieldColumns, 'supplierName', record.supplier_name, 'supplier_name')
|
||||
|
||||
const productStatus = text(record.other_5)
|
||||
if (SALES_STATUSES.has(productStatus)) {
|
||||
addTextField(patch, fieldColumns, 'productStatus', productStatus, 'other_5')
|
||||
}
|
||||
addTextField(patch, fieldColumns, 'installDocId', record.other_9, 'other_9')
|
||||
addTextField(patch, fieldColumns, 'material', record.other_10, 'other_10')
|
||||
addTextField(patch, fieldColumns, 'color', record.properties_value, 'properties_value')
|
||||
addTextField(patch, fieldColumns, 'categoryL2', record.category, 'category')
|
||||
const categoryL1 = text(categoryConfig?.categoryL1)
|
||||
if (categoryL1) patch.categoryL1 = categoryL1
|
||||
addDecimalField(patch, fieldColumns, 'weight', record.weight, 'weight')
|
||||
addDecimalField(patch, fieldColumns, 'length', record.l, 'l')
|
||||
addDecimalField(patch, fieldColumns, 'width', record.w, 'w')
|
||||
addDecimalField(patch, fieldColumns, 'height', record.h, 'h')
|
||||
const volume = calculateProductArchiveVolumeDecimal(record.l, record.w, record.h)
|
||||
if (volume) {
|
||||
patch.volume = volume
|
||||
fieldColumns.volume = JST_SKU_ARCHIVE_FIELD_COLUMNS.volume
|
||||
}
|
||||
addTextField(
|
||||
patch,
|
||||
fieldColumns,
|
||||
'packType',
|
||||
normalizeJstSkuPackType(record.other_8),
|
||||
'other_8',
|
||||
)
|
||||
|
||||
const spec = extractJstSkuArchiveSpec(text(record.name))
|
||||
addTextField(patch, fieldColumns, 'spec', spec, 'name')
|
||||
|
||||
const sourceUpdatedAt = record.local_updated ?? record.modified
|
||||
const fieldSourceMap = createProductArchiveFieldSourceMap({
|
||||
fields: fieldColumns,
|
||||
sourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
sourceSystem: SOURCE_SYSTEM,
|
||||
sourceSchema: SOURCE_SCHEMA,
|
||||
sourceObject: SOURCE_OBJECT,
|
||||
sourceRecordId,
|
||||
sourceUpdatedAt,
|
||||
})
|
||||
if (categoryConfig && categoryL1) {
|
||||
Object.assign(
|
||||
fieldSourceMap,
|
||||
createProductArchiveFieldSourceMap({
|
||||
fields: { categoryL1: 'categoryL1' },
|
||||
sourceCode: CATEGORY_CONFIG_ARCHIVE_SOURCE_CODE,
|
||||
sourceSystem: CATEGORY_CONFIG_SOURCE_SYSTEM,
|
||||
sourceSchema: CATEGORY_CONFIG_SOURCE_SCHEMA,
|
||||
sourceObject: CATEGORY_CONFIG_SOURCE_OBJECT,
|
||||
sourceRecordId: categoryConfig.id,
|
||||
sourceUpdatedAt: categoryConfig.updatedAt,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return {
|
||||
barcode,
|
||||
businessCreatedAt,
|
||||
patch,
|
||||
fieldSourceMap,
|
||||
source: {
|
||||
sourceRecordId,
|
||||
sourceProductId: text(record.i_id).toUpperCase(),
|
||||
sourceSkuItemId: barcode,
|
||||
sourceSystem: SOURCE_SYSTEM,
|
||||
sourceSchema: SOURCE_SCHEMA,
|
||||
sourceObject: SOURCE_OBJECT,
|
||||
sourceCreatedAt: businessCreatedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class JstSkuProductArchiveSyncService {
|
||||
constructor(
|
||||
private readonly sourceRepository: JstSkuProductArchiveSourceRepository =
|
||||
jstSkuProductArchiveSourceRepository,
|
||||
private readonly archiveRepository: JstSkuProductArchiveRepository =
|
||||
jstSkuProductArchiveRepository,
|
||||
private readonly categoryConfigRepository: SupplyChainProductArchiveCategoryConfigRepository =
|
||||
supplyChainProductArchiveCategoryConfigRepository,
|
||||
) {}
|
||||
|
||||
async syncSpu(input: { spu: string; apply?: boolean }): Promise<JstSkuProductArchiveSyncResult> {
|
||||
const spu = input.spu.trim().toUpperCase()
|
||||
if (!spu) throw new Error('SPU is required')
|
||||
|
||||
try {
|
||||
const records = await this.sourceRepository.listFinishedBySpu(spu)
|
||||
const categoryL2Values = Array.from(
|
||||
new Set(records.map((record) => text(record.category)).filter(Boolean)),
|
||||
)
|
||||
const categoryConfigs = new Map(
|
||||
(
|
||||
await Promise.all(
|
||||
categoryL2Values.map(async (categoryL2) => [
|
||||
categoryL2,
|
||||
await this.categoryConfigRepository.findActiveByCategoryL2(categoryL2),
|
||||
] as const),
|
||||
)
|
||||
).filter((entry): entry is readonly [string, ProductArchiveCategoryConfigRecord] =>
|
||||
entry[1] !== null,
|
||||
),
|
||||
)
|
||||
const mapped = records
|
||||
.map((record) =>
|
||||
mapJstSkuProductArchiveSource(
|
||||
record,
|
||||
categoryConfigs.get(text(record.category)),
|
||||
),
|
||||
)
|
||||
.filter((record): record is JstSkuProductArchiveInput => record !== null)
|
||||
const result: JstSkuProductArchiveSyncResult = {
|
||||
apply: input.apply === true,
|
||||
spu,
|
||||
found: records.length,
|
||||
mapped: mapped.length,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
skipped: records.length - mapped.length,
|
||||
preview: mapped.slice(0, 10).map((record) => ({
|
||||
barcode: record.barcode,
|
||||
patch: record.patch,
|
||||
sourceColumns: Object.fromEntries(
|
||||
Object.entries(record.fieldSourceMap).map(([field, source]) => [
|
||||
field,
|
||||
source.sourceColumn,
|
||||
]),
|
||||
),
|
||||
})),
|
||||
}
|
||||
|
||||
if (!result.apply) return result
|
||||
|
||||
for (const record of mapped) {
|
||||
const upserted = await this.archiveRepository.upsert(record)
|
||||
result[upserted.status] += 1
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
await this.sourceRepository.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const jstSkuProductArchiveSyncService = new JstSkuProductArchiveSyncService()
|
||||
@ -0,0 +1,130 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import {
|
||||
CATEGORY_CONFIG_ACTIVE,
|
||||
CATEGORY_CONFIG_INACTIVE,
|
||||
type ProductArchiveCategoryConfigStatus,
|
||||
} from '@/lib/supply-chain-product-archive-category-config'
|
||||
import {
|
||||
supplyChainProductArchiveCategoryConfigRepository,
|
||||
type ProductArchiveCategoryConfigRecord,
|
||||
type SupplyChainProductArchiveCategoryConfigRepository,
|
||||
} from '@/server/repositories/supply-chain-product-archive-category-config.repository'
|
||||
|
||||
const CATEGORY_MAX_LENGTH = 160
|
||||
|
||||
export type CategoryConfigDTO = {
|
||||
id: string
|
||||
categoryL2: string
|
||||
categoryL1: string
|
||||
status: ProductArchiveCategoryConfigStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CategoryConfigListResult = {
|
||||
items: CategoryConfigDTO[]
|
||||
pageInfo: { page: number; pageSize: number; total: number; totalPages: number }
|
||||
}
|
||||
|
||||
function mapRecord(record: ProductArchiveCategoryConfigRecord): CategoryConfigDTO {
|
||||
return {
|
||||
id: record.id,
|
||||
categoryL2: record.categoryL2,
|
||||
categoryL1: record.categoryL1,
|
||||
status: record.status as ProductArchiveCategoryConfigStatus,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function cleanCategory(value: string, label: string) {
|
||||
const cleaned = value.trim()
|
||||
if (!cleaned) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${label}不能为空`)
|
||||
}
|
||||
if (cleaned.length > CATEGORY_MAX_LENGTH) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `${label}不能超过${CATEGORY_MAX_LENGTH}个字符`)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
function cleanStatus(value: string): ProductArchiveCategoryConfigStatus {
|
||||
if (value !== CATEGORY_CONFIG_ACTIVE && value !== CATEGORY_CONFIG_INACTIVE) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '状态不正确')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'
|
||||
}
|
||||
|
||||
export class SupplyChainProductArchiveCategoryConfigService {
|
||||
constructor(
|
||||
private readonly repository: SupplyChainProductArchiveCategoryConfigRepository =
|
||||
supplyChainProductArchiveCategoryConfigRepository,
|
||||
) {}
|
||||
|
||||
async list(input: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
search?: string
|
||||
status?: ProductArchiveCategoryConfigStatus
|
||||
}): Promise<CategoryConfigListResult> {
|
||||
const page = Math.max(1, Math.floor(input.page ?? 1))
|
||||
const pageSize = Math.min(200, Math.max(1, Math.floor(input.pageSize ?? 100)))
|
||||
const search = input.search?.trim().slice(0, CATEGORY_MAX_LENGTH) || undefined
|
||||
const result = await this.repository.listPage({ page, pageSize, search, status: input.status })
|
||||
|
||||
return {
|
||||
items: result.records.map(mapRecord),
|
||||
pageInfo: {
|
||||
page,
|
||||
pageSize,
|
||||
total: result.total,
|
||||
totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: { categoryL2: string; categoryL1: string }) {
|
||||
const categoryL2 = cleanCategory(input.categoryL2, '二级类目')
|
||||
const categoryL1 = cleanCategory(input.categoryL1, '一级类目')
|
||||
try {
|
||||
return mapRecord(await this.repository.create({ categoryL2, categoryL1 }))
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_EXISTS, '二级类目配置已存在')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async update(input: {
|
||||
id: string
|
||||
categoryL2: string
|
||||
categoryL1: string
|
||||
status: string
|
||||
}) {
|
||||
const id = input.id.trim()
|
||||
if (!id) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '配置 ID 不能为空')
|
||||
const categoryL2 = cleanCategory(input.categoryL2, '二级类目')
|
||||
const categoryL1 = cleanCategory(input.categoryL1, '一级类目')
|
||||
const status = cleanStatus(input.status)
|
||||
if (!(await this.repository.findById(id))) {
|
||||
throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_NOT_FOUND, '类目配置不存在')
|
||||
}
|
||||
try {
|
||||
return mapRecord(await this.repository.update({ id, categoryL2, categoryL1, status }))
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_EXISTS, '二级类目配置已存在')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const supplyChainProductArchiveCategoryConfigService =
|
||||
new SupplyChainProductArchiveCategoryConfigService()
|
||||
@ -0,0 +1,176 @@
|
||||
import { OperationLogAction, OperationLogStatus } from '@prisma/client'
|
||||
import {
|
||||
PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE,
|
||||
type ProductArchiveChangeLogDetails,
|
||||
type ProductArchiveChangeLogPage,
|
||||
type ProductArchiveFieldChange,
|
||||
} from '@/lib/supply-chain-product-archive-change-log'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import {
|
||||
supplyChainProductArchiveChangeLogRepository,
|
||||
type SupplyChainProductArchiveChangeLogRepository,
|
||||
} from '@/server/repositories/supply-chain-product-archive-change-log.repository'
|
||||
import {
|
||||
operationLogService,
|
||||
type OperationLogInput,
|
||||
} from './operation-log.service'
|
||||
|
||||
export type ProductArchiveChangeActor = {
|
||||
source: 'USER' | 'SYSTEM'
|
||||
actorId?: string | null
|
||||
actorName: string
|
||||
}
|
||||
|
||||
export type ProductArchiveSyncSource = {
|
||||
sourceSystem: string
|
||||
sourceSchema: string
|
||||
sourceObject: string
|
||||
sourceRecordedAt?: Date | null
|
||||
}
|
||||
|
||||
function toSourceDetails(source: ProductArchiveSyncSource) {
|
||||
return {
|
||||
sourceSystem: source.sourceSystem,
|
||||
sourceSchema: source.sourceSchema,
|
||||
sourceObject: source.sourceObject,
|
||||
...(source.sourceRecordedAt
|
||||
? { sourceRecordedAt: source.sourceRecordedAt.toISOString() }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function record(input: OperationLogInput) {
|
||||
await operationLogService.record(input)
|
||||
}
|
||||
|
||||
export async function recordProductArchiveImported(input: {
|
||||
barcode: string
|
||||
actor: ProductArchiveChangeActor
|
||||
source?: ProductArchiveSyncSource
|
||||
}) {
|
||||
const details: ProductArchiveChangeLogDetails = {
|
||||
kind: 'IMPORTED',
|
||||
barcode: input.barcode,
|
||||
...(input.source ? toSourceDetails(input.source) : {}),
|
||||
}
|
||||
await record({
|
||||
action:
|
||||
input.actor.source === 'SYSTEM'
|
||||
? OperationLogAction.JUSHUITAN_PRODUCT_PUSH
|
||||
: OperationLogAction.INTAKE_PRODUCT_WRITE,
|
||||
status: OperationLogStatus.SUCCESS,
|
||||
source: input.actor.source,
|
||||
actorId: input.actor.actorId,
|
||||
actorName: input.actor.actorName,
|
||||
skuCode: input.barcode,
|
||||
workspaceCategory: PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE,
|
||||
message:
|
||||
input.actor.source === 'SYSTEM'
|
||||
? '数据库推送:导入商品档案'
|
||||
: '系统修改:导入商品档案',
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
export async function recordProductArchiveFieldChanges(input: {
|
||||
barcode: string
|
||||
actor: ProductArchiveChangeActor
|
||||
source?: ProductArchiveSyncSource
|
||||
changes: readonly ProductArchiveFieldChange[]
|
||||
}) {
|
||||
const sourceDetails = input.source ? toSourceDetails(input.source) : {}
|
||||
await Promise.all(
|
||||
input.changes.map((change) => {
|
||||
const details: ProductArchiveChangeLogDetails = {
|
||||
kind: 'UPDATED',
|
||||
barcode: input.barcode,
|
||||
field: change.field,
|
||||
fieldLabel: change.fieldLabel,
|
||||
previousValue: change.previousValue,
|
||||
nextValue: change.nextValue,
|
||||
...sourceDetails,
|
||||
}
|
||||
return record({
|
||||
action:
|
||||
input.actor.source === 'SYSTEM'
|
||||
? OperationLogAction.JUSHUITAN_PRODUCT_PUSH
|
||||
: OperationLogAction.INTAKE_PRODUCT_WRITE,
|
||||
status: OperationLogStatus.SUCCESS,
|
||||
source: input.actor.source,
|
||||
actorId: input.actor.actorId,
|
||||
actorName: input.actor.actorName,
|
||||
skuCode: input.barcode,
|
||||
workspaceCategory: PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE,
|
||||
message:
|
||||
input.actor.source === 'SYSTEM'
|
||||
? `数据库推送:${change.fieldLabel}已更新`
|
||||
: `系统修改:${change.fieldLabel}`,
|
||||
details,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function isDetails(value: unknown): value is ProductArchiveChangeLogDetails {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'kind' in value &&
|
||||
'barcode' in value,
|
||||
)
|
||||
}
|
||||
|
||||
class SupplyChainProductArchiveChangeLogService {
|
||||
constructor(
|
||||
private readonly repository: SupplyChainProductArchiveChangeLogRepository =
|
||||
supplyChainProductArchiveChangeLogRepository,
|
||||
) {}
|
||||
|
||||
async listByBarcode(input: {
|
||||
barcode: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<ProductArchiveChangeLogPage> {
|
||||
const page = Math.max(1, Math.floor(input.page ?? 1))
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(input.pageSize ?? 50)))
|
||||
const result = await this.repository.listByBarcode({
|
||||
barcode: input.barcode,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
|
||||
return {
|
||||
logs: result.items.map((item) => ({
|
||||
id: item.id,
|
||||
barcode: item.skuCode ?? input.barcode,
|
||||
action: item.action,
|
||||
source: item.source,
|
||||
actorName: item.actor?.name || item.actorName,
|
||||
message: item.message,
|
||||
details: isDetails(item.details)
|
||||
? item.details
|
||||
: { kind: 'UPDATED', barcode: input.barcode },
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
})),
|
||||
pageInfo: {
|
||||
page,
|
||||
pageSize,
|
||||
total: result.total,
|
||||
totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const supplyChainProductArchiveChangeLogService =
|
||||
new SupplyChainProductArchiveChangeLogService()
|
||||
|
||||
export { SupplyChainProductArchiveChangeLogService }
|
||||
|
||||
export function archiveChangeActorFromUser(user: AuthUser): ProductArchiveChangeActor {
|
||||
return {
|
||||
source: 'USER',
|
||||
actorId: user.id,
|
||||
actorName: user.name,
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,10 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import {
|
||||
PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS,
|
||||
createProductArchiveFieldSourceMap,
|
||||
toProductArchiveFieldSourceJson,
|
||||
} from '@/lib/supply-chain-product-archive-field-source'
|
||||
import { calculateProductArchiveVolumeDecimal } from '@/lib/supply-chain-product-archive-volume'
|
||||
import {
|
||||
PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE,
|
||||
supplyChainProductArchiveSyncRepository,
|
||||
@ -6,10 +12,20 @@ import {
|
||||
type ProductDevelopmentArchiveInput,
|
||||
type SupplyChainProductArchiveSyncRepository,
|
||||
} from '@/server/repositories/supply-chain-product-archive-sync.repository'
|
||||
import {
|
||||
recordProductArchiveFieldChanges,
|
||||
recordProductArchiveImported,
|
||||
type ProductArchiveChangeActor,
|
||||
} from './supply-chain-product-archive-change-log.service'
|
||||
|
||||
export const PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE = 'PRODUCT_DEVELOPMENT'
|
||||
export const PRODUCT_DEVELOPMENT_ARCHIVE_ENTRY_MODE = 'AUTO'
|
||||
|
||||
const SYSTEM_SYNC_ACTOR: ProductArchiveChangeActor = {
|
||||
source: 'SYSTEM',
|
||||
actorName: '数据库推送',
|
||||
}
|
||||
|
||||
const SALES_STATUSES = new Set(['销售', '停产', '退市'])
|
||||
const OTHER_PRODUCT_CATEGORIES = new Set(['配件', '福利品', '赠品', '虚拟产品'])
|
||||
const COMMERCIAL_PRODUCT_CATEGORIES = new Set([
|
||||
@ -70,9 +86,7 @@ export function calculateProductArchiveVolume(
|
||||
width: string,
|
||||
height: string,
|
||||
) {
|
||||
const values = [length, width, height].map((item) => Number(item))
|
||||
if (!values.every(Number.isFinite) || values.some((item) => item <= 0)) return ''
|
||||
return String(values[0] * values[1] * values[2])
|
||||
return calculateProductArchiveVolumeDecimal(length, width, height)?.toString() ?? ''
|
||||
}
|
||||
|
||||
export function mapProductCatalogWideArchiveSource(
|
||||
@ -98,6 +112,15 @@ export function mapProductCatalogWideArchiveSource(
|
||||
const length = text(record.package_length_cm)
|
||||
const width = text(record.package_width_cm)
|
||||
const height = text(record.package_height_cm)
|
||||
const fieldSourceMap = createProductArchiveFieldSourceMap({
|
||||
fields: PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS,
|
||||
sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE,
|
||||
sourceSystem: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE.sourceSystem,
|
||||
sourceSchema: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE.sourceSchema,
|
||||
sourceObject: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE.sourceObject,
|
||||
sourceRecordId,
|
||||
sourceUpdatedAt: record.updated_at ?? sourceCreatedAt,
|
||||
})
|
||||
|
||||
return {
|
||||
barcode,
|
||||
@ -118,9 +141,7 @@ export function mapProductCatalogWideArchiveSource(
|
||||
length: decimal(length),
|
||||
width: decimal(width),
|
||||
height: decimal(height),
|
||||
volume:
|
||||
decimal(record.package_volume_cm3) ??
|
||||
decimal(calculateProductArchiveVolume(length, width, height)),
|
||||
volume: calculateProductArchiveVolumeDecimal(length, width, height),
|
||||
packType: resolveProductArchivePackageType(text(record.package_set_count)),
|
||||
productTag: text(record.product_tag),
|
||||
installDocId: text(record.drawing_id),
|
||||
@ -134,6 +155,7 @@ export function mapProductCatalogWideArchiveSource(
|
||||
purchaseOwner: text(record.archive_purchase_owner),
|
||||
minimumOrderQuantity: text(record.archive_minimum_order_quantity),
|
||||
firstSourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE,
|
||||
fieldSourceMap: toProductArchiveFieldSourceJson(fieldSourceMap),
|
||||
},
|
||||
source: {
|
||||
sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE,
|
||||
@ -153,7 +175,10 @@ export class SupplyChainProductArchiveSyncService {
|
||||
supplyChainProductArchiveSyncRepository,
|
||||
) {}
|
||||
|
||||
async syncProductDevelopment(productId?: string) {
|
||||
async syncProductDevelopment(
|
||||
productId?: string,
|
||||
actor: ProductArchiveChangeActor = SYSTEM_SYNC_ACTOR,
|
||||
) {
|
||||
const sources = await this.repository.listProductDevelopmentSources(productId)
|
||||
let synced = 0
|
||||
let skipped = 0
|
||||
@ -165,7 +190,27 @@ export class SupplyChainProductArchiveSyncService {
|
||||
continue
|
||||
}
|
||||
activeSourceRecordIds.push(input.source.sourceRecordId)
|
||||
await this.repository.upsertProductDevelopmentArchive(input)
|
||||
const result = await this.repository.upsertProductDevelopmentArchive(input)
|
||||
if (result?.created) {
|
||||
await recordProductArchiveImported({
|
||||
barcode: input.barcode,
|
||||
actor,
|
||||
source: {
|
||||
...input.source,
|
||||
sourceRecordedAt: input.source.sourceCreatedAt,
|
||||
},
|
||||
})
|
||||
} else if (result?.changes.length) {
|
||||
await recordProductArchiveFieldChanges({
|
||||
barcode: input.barcode,
|
||||
actor,
|
||||
source: {
|
||||
...input.source,
|
||||
sourceRecordedAt: input.source.sourceCreatedAt,
|
||||
},
|
||||
changes: result.changes,
|
||||
})
|
||||
}
|
||||
synced += 1
|
||||
}
|
||||
const removed = await this.repository.removeStaleProductDevelopmentSources({
|
||||
@ -175,9 +220,12 @@ export class SupplyChainProductArchiveSyncService {
|
||||
return { found: sources.length, synced, skipped, ...removed }
|
||||
}
|
||||
|
||||
async syncProductDevelopmentAfterSave(productId: string) {
|
||||
async syncProductDevelopmentAfterSave(
|
||||
productId: string,
|
||||
actor?: ProductArchiveChangeActor,
|
||||
) {
|
||||
try {
|
||||
await this.syncProductDevelopment(productId)
|
||||
await this.syncProductDevelopment(productId, actor ?? SYSTEM_SYNC_ACTOR)
|
||||
} catch (error) {
|
||||
console.error('[SupplyChainProductArchive] failed to sync saved product', {
|
||||
productId,
|
||||
|
||||
@ -0,0 +1,188 @@
|
||||
import {
|
||||
YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE,
|
||||
createProductArchiveFieldSourceMap,
|
||||
mergeProductArchiveFieldSourceMaps,
|
||||
parseProductArchiveFieldSourceMap,
|
||||
} from '@/lib/supply-chain-product-archive-field-source'
|
||||
import {
|
||||
yunshanProductCreatedAtRepository,
|
||||
type YunshanProductCreatedAtRepository,
|
||||
} from '@/server/repositories/yunshan-product-created-at.repository'
|
||||
|
||||
const SOURCE_SYSTEM = '云杉商品资料'
|
||||
const SOURCE_COLUMN = '创建时间'
|
||||
|
||||
export type YunshanCreatedAtSourceRow = {
|
||||
rowNumber: number
|
||||
barcode: string
|
||||
createdAtText: string
|
||||
}
|
||||
|
||||
export type YunshanCreatedAtImportResult = {
|
||||
apply: boolean
|
||||
sourceRows: number
|
||||
validRows: number
|
||||
uniqueBarcodes: number
|
||||
duplicateRows: number
|
||||
invalidRows: number
|
||||
conflictingBarcodes: number
|
||||
matchedArchives: number
|
||||
unmatchedBarcodes: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
preview: Array<{
|
||||
barcode: string
|
||||
rowNumber: number
|
||||
previousCreatedAt: string
|
||||
nextCreatedAt: string
|
||||
}>
|
||||
conflictPreview: Array<{
|
||||
barcode: string
|
||||
values: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
type NormalizedCreatedAtRow = {
|
||||
rowNumber: number
|
||||
barcode: string
|
||||
businessCreatedAt: Date
|
||||
}
|
||||
|
||||
function normalizeBarcode(value: string) {
|
||||
return value.trim().toUpperCase()
|
||||
}
|
||||
|
||||
export function parseYunshanCreatedAt(value: string) {
|
||||
const normalized = value.trim()
|
||||
const match = normalized.match(
|
||||
/^(\d{4})[-/]([01]?\d)[-/]([0-3]?\d)(?:\s+|T)([0-2]?\d):([0-5]?\d)(?::([0-5]?\d))?$/,
|
||||
)
|
||||
if (!match) return null
|
||||
|
||||
const [, year, month, day, hour, minute, second = '0'] = match
|
||||
const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}+08:00`
|
||||
const date = new Date(iso)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function normalizeRows(rows: YunshanCreatedAtSourceRow[]) {
|
||||
const byBarcode = new Map<string, NormalizedCreatedAtRow>()
|
||||
const conflicts = new Map<string, Set<string>>()
|
||||
let validRows = 0
|
||||
let duplicateRows = 0
|
||||
let invalidRows = 0
|
||||
|
||||
rows.forEach((row) => {
|
||||
const barcode = normalizeBarcode(row.barcode)
|
||||
const businessCreatedAt = parseYunshanCreatedAt(row.createdAtText)
|
||||
if (!barcode || !businessCreatedAt) {
|
||||
invalidRows += 1
|
||||
return
|
||||
}
|
||||
validRows += 1
|
||||
const existing = byBarcode.get(barcode)
|
||||
if (!existing) {
|
||||
byBarcode.set(barcode, { rowNumber: row.rowNumber, barcode, businessCreatedAt })
|
||||
return
|
||||
}
|
||||
|
||||
duplicateRows += 1
|
||||
if (existing.businessCreatedAt.getTime() === businessCreatedAt.getTime()) return
|
||||
const values = conflicts.get(barcode) ?? new Set([existing.businessCreatedAt.toISOString()])
|
||||
values.add(businessCreatedAt.toISOString())
|
||||
conflicts.set(barcode, values)
|
||||
})
|
||||
|
||||
return { byBarcode, conflicts, validRows, duplicateRows, invalidRows }
|
||||
}
|
||||
|
||||
export class YunshanProductCreatedAtService {
|
||||
constructor(
|
||||
private readonly repository: YunshanProductCreatedAtRepository =
|
||||
yunshanProductCreatedAtRepository,
|
||||
) {}
|
||||
|
||||
async importRows(input: {
|
||||
rows: YunshanCreatedAtSourceRow[]
|
||||
apply?: boolean
|
||||
fileName: string
|
||||
sheetName: string
|
||||
fileModifiedAt: Date
|
||||
}): Promise<YunshanCreatedAtImportResult> {
|
||||
const normalized = normalizeRows(input.rows)
|
||||
const archives = await this.repository.listArchives()
|
||||
const archiveByBarcode = new Map(
|
||||
archives.map((archive) => [normalizeBarcode(archive.barcode), archive]),
|
||||
)
|
||||
const matches = Array.from(normalized.byBarcode.values()).flatMap((sourceRow) => {
|
||||
const archive = archiveByBarcode.get(sourceRow.barcode)
|
||||
return archive ? [{ archive, sourceRow }] : []
|
||||
})
|
||||
const source = {
|
||||
sourceCode: YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE,
|
||||
sourceSystem: SOURCE_SYSTEM,
|
||||
sourceSchema: input.sheetName,
|
||||
sourceObject: input.fileName,
|
||||
}
|
||||
const applyRecords = matches.flatMap(({ archive, sourceRow }) => {
|
||||
const currentSources = parseProductArchiveFieldSourceMap(archive.fieldSourceMap)
|
||||
const nextSources = mergeProductArchiveFieldSourceMaps(
|
||||
currentSources,
|
||||
createProductArchiveFieldSourceMap({
|
||||
fields: { businessCreatedAt: SOURCE_COLUMN },
|
||||
...source,
|
||||
sourceRecordId: sourceRow.barcode,
|
||||
sourceUpdatedAt: input.fileModifiedAt,
|
||||
}),
|
||||
)
|
||||
const unchanged =
|
||||
archive.businessCreatedAt.getTime() === sourceRow.businessCreatedAt.getTime() &&
|
||||
currentSources.businessCreatedAt?.sourceCode ===
|
||||
YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE &&
|
||||
JSON.stringify(currentSources) === JSON.stringify(nextSources)
|
||||
return unchanged
|
||||
? []
|
||||
: [
|
||||
{
|
||||
archiveId: archive.id,
|
||||
barcode: sourceRow.barcode,
|
||||
businessCreatedAt: sourceRow.businessCreatedAt,
|
||||
fieldSourceMap: nextSources,
|
||||
},
|
||||
]
|
||||
})
|
||||
const result: YunshanCreatedAtImportResult = {
|
||||
apply: input.apply === true,
|
||||
sourceRows: input.rows.length,
|
||||
validRows: normalized.validRows,
|
||||
uniqueBarcodes: normalized.byBarcode.size,
|
||||
duplicateRows: normalized.duplicateRows,
|
||||
invalidRows: normalized.invalidRows,
|
||||
conflictingBarcodes: normalized.conflicts.size,
|
||||
matchedArchives: matches.length,
|
||||
unmatchedBarcodes: normalized.byBarcode.size - matches.length,
|
||||
updated: 0,
|
||||
unchanged: matches.length - applyRecords.length,
|
||||
preview: matches.slice(0, 10).map(({ archive, sourceRow }) => ({
|
||||
barcode: sourceRow.barcode,
|
||||
rowNumber: sourceRow.rowNumber,
|
||||
previousCreatedAt: archive.businessCreatedAt.toISOString(),
|
||||
nextCreatedAt: sourceRow.businessCreatedAt.toISOString(),
|
||||
})),
|
||||
conflictPreview: Array.from(normalized.conflicts.entries())
|
||||
.slice(0, 10)
|
||||
.map(([barcode, values]) => ({ barcode, values: Array.from(values).sort() })),
|
||||
}
|
||||
|
||||
if (!result.apply) return result
|
||||
if (normalized.conflicts.size > 0) {
|
||||
throw new Error(
|
||||
`云杉商品资料存在 ${normalized.conflicts.size} 个条码对应多个创建时间,已阻止写入`,
|
||||
)
|
||||
}
|
||||
result.updated = await this.repository.applyCreatedAt({ records: applyRecords, source })
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const yunshanProductCreatedAtService = new YunshanProductCreatedAtService()
|
||||
Loading…
x
Reference in New Issue
Block a user