feat(supply-chain): complete product archive data pipeline

This commit is contained in:
WuYongJie 2026-08-07 12:17:47 +08:00
parent 501f561eae
commit 2bd31cc06b
46 changed files with 4428 additions and 47 deletions

View File

@ -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

View 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 文件名 |

View File

@ -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",

View File

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

View File

@ -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";

View File

@ -0,0 +1,2 @@
ALTER TABLE "SupplyChainProductArchive"
ADD COLUMN "fieldSourceMap" JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

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

View File

@ -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

View 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
})

View 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
})

View File

@ -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'],
},
)
}

View File

@ -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'],
},
)
}

View File

@ -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'],
},
)
}

View File

@ -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': '产品变价',
}

View File

@ -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' },
],
},
],

View File

@ -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>
)
}

View File

@ -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 />
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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;

View File

@ -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;

View File

@ -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 }
}

View File

@ -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: '架类' },
]

View File

@ -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 || '数据库推送'
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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,

View File

@ -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()

View File

@ -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()

View File

@ -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 }

View File

@ -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 }

View File

@ -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) : [],
}
})
}
}

View File

@ -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()

View File

@ -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)
})
})

View File

@ -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: '胡桃色',
}),
}),
)
})
})

View File

@ -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')),

View File

@ -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('停产')

View File

@ -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()
})
})

View File

@ -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
}

View File

@ -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()

View File

@ -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()

View File

@ -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,
}
}

View File

@ -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,

View File

@ -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()