From b47f745b83237c5f578fd5dc9174c4d14e69b3ce Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 11 Aug 2026 10:23:00 +0800 Subject: [PATCH] feat(supply-chain): persist archive enrichment and image cache --- .../供应链产品全量档案字段来源对照表.md | 44 ++- furniture-3d-system/package.json | 1 + .../migration.sql | 17 ++ .../migration.sql | 35 +++ furniture-3d-system/prisma/schema.prisma | 39 +++ ...supply-chain-product-archive-sync-timer.sh | 81 ++++++ ...supply-chain-product-archive-enrichment.ts | 118 +++++++++ ...supply-chain-product-archive-change-log.ts | 17 +- ...pply-chain-product-archive-field-source.ts | 8 +- .../jst-sku-product-archive.repository.ts | 48 +++- ...duct-archive-enrichment-sync.repository.ts | 250 ++++++++++++++++++ ...-product-archive-image-cache.repository.ts | 225 ++++++++++++++++ ...y-chain-product-archive-sync.repository.ts | 14 +- ...supply-chain-product-archive.repository.ts | 16 ++ ...-product-archive-full-sync.service.test.ts | 19 +- ...t-sku-product-archive-sync.service.test.ts | 11 - ...uct-archive-material-image.service.test.ts | 121 ++++++++- ...ct-archive-enrichment-sync.service.test.ts | 155 +++++++++++ ...chain-product-archive-sync.service.test.ts | 20 ++ ...pply-chain-product-archive.service.test.ts | 130 ++------- ...t-sku-product-archive-full-sync.service.ts | 6 +- .../jst-sku-product-archive-sync.service.ts | 42 ++- .../product-archive-material-image.service.ts | 96 ++++++- ...-product-archive-enrichment-sync.mapper.ts | 219 +++++++++++++++ ...product-archive-enrichment-sync.service.ts | 161 +++++++++++ ...pply-chain-product-archive-sync.service.ts | 14 +- .../supply-chain-product-archive.mapper.ts | 73 ++--- .../supply-chain-product-archive.service.ts | 166 +----------- 28 files changed, 1763 insertions(+), 383 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260810173000_persist_supply_chain_archive_enrichment/migration.sql create mode 100644 furniture-3d-system/prisma/migrations/20260811100000_add_supply_chain_product_archive_image_cache/migration.sql create mode 100644 furniture-3d-system/scripts/install-supply-chain-product-archive-sync-timer.sh create mode 100644 furniture-3d-system/scripts/sync-supply-chain-product-archive-enrichment.ts create mode 100644 furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts create mode 100644 furniture-3d-system/src/server/repositories/supply-chain-product-archive-image-cache.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts diff --git a/furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md b/furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md index 271202ea..5e8bb3f9 100644 --- a/furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md +++ b/furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md @@ -1,6 +1,6 @@ # 供应链产品全量档案字段来源对照表 -更新时间:2026-08-07 +更新时间:2026-08-10 ## 1. 合并与追踪规则 @@ -8,6 +8,11 @@ - 唯一合并键:`barcode` - 来源记录:`SupplyChainProductArchiveSource` - 字段级来源:`SupplyChainProductArchive.fieldSourceMap` +- 除图片外,产品全量档案页面使用的来源数据必须先写入 `SupplyChainProductArchive`;页面列表不再临时联查来源库。 +- 图片由素材库接口按条码/SKU获取;匹配结果的链接和来源信息保存在独立的 `SupplyChainProductArchiveImageCache` 表,不把图片二进制写进 PostgreSQL,也不进入商品修改日志。历史 `image` 值只作兼容兜底,不再由后续档案同步更新。 +- 首次建立商品档案只记录一条“商品档案导入”;后续来源值实际变化时,按条码、字段、旧值、新值和来源写入 `OperationLog`。 +- 扩展字段通过 `sync:supply-chain-product-archive-enrichment` 分批同步;重复执行时只有值发生变化的字段才更新主表并写日志。 +- 生产定时入口为 `scripts/install-supply-chain-product-archive-sync-timer.sh`,默认每 15 分钟先同步 `rpt.jst_sku` 基础档案,再同步供应商、仓库、生产周期、日销和价格等扩展字段。 - 聚水潭来源只读取 `JSReportReplica.rpt.jst_sku` 中 `item_type = '成品'` 的行。 - `jst_sku` 只更新本表明确列出的字段;空值不覆盖其他来源的已有值。 - 已由 `JST_SKU` 提供且有值的字段,不再被产品开发同步覆盖。 @@ -24,7 +29,7 @@ | `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 图片待单独确认 | +| `image` | 图片 | | | 素材库接口 + `SupplyChainProductArchiveImageCache.previewUrl` | 条码优先、SKU 图兜底、最后白底图;成功链接长期复用,未命中 24 小时后可重试,不写档案主表 | | `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 原样保留完整类目文本 | @@ -43,13 +48,26 @@ | `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` | | | +| `orderType` | 叫货类型 | `rpt.jst_sku.other_price_2` | `purchase_order_method` | | 按现有枚举归一 | +| `storageType` | 入库/代发 | | | `SupplyChainProductArchiveWarehouseConfig.storageType` | 按出入库仓库名称匹配配置表 | | `isSeasonal` | 是否季节性产品 | 暂无来源 | 暂无来源 | | 保持空值 | | `replaceCount` | 替换款数量 | 暂无来源 | 暂无来源 | | 保持空值 | | `supplierName` | 供应商 | `rpt.jst_sku.supplier_name` | `archive_supplier_name` | | 原样保留 | -| `purchaseOwner` | 采购 | 暂不接入 | `archive_purchase_owner` | | 只取流程采购负责人,不用产品负责人兜底 | -| `minimumOrderQuantity` | 产品起订量 | 暂不接入 | `archive_minimum_order_quantity` | | | +| `supplierType` | 供应商类型 | | | `rpt.js_supplier.scmtype` | 按供应商名称匹配后写主表 | +| `supplierStatus` | 供应商状态 | | | `rpt.js_supplier.coopersgn` | `1=合作`、`2=不合作` | +| `purchaseOwner` | 采购 | | `archive_purchase_owner` | `rpt.js_supplier.buyer` | 供应商采购非空时覆盖产品开发采购 | +| `settlementMethod` | 结算方式 | | | `rpt.js_supplier.paycyclesgn` | 按供应商名称匹配 | +| `boardProvider` | 板材提供方 | | | `rpt.js_supplier.undefined2` | 按供应商名称匹配 | +| `penaltyStatus` | 违约金赔付状态 | | | `rpt.js_supplier.undefined1` | 按供应商名称匹配 | +| `warehouseType` | 仓库属性 | | | `rpt.jst_warehouseInfo.remark2` | 按出入库仓库名称匹配 | +| `warehouseHead` | 仓库负责人 | | | `SupplyChainProductArchiveWarehouseConfig.warehouseHead` | 按出入库仓库名称匹配配置表 | +| `minimumOrderQuantity` | 产品起订量 | | `archive_minimum_order_quantity` | `rpt.pm_stockMonitor.minimum` | stock monitor 非空时覆盖产品开发起订量 | +| `firstLeadTime` | 首单生产周期 | | | `rpt.pm_stockMonitor.produceCycle` | 条码优先、SKU 唯一匹配兜底 | +| `palletSpec` | 托盘参数 | | | `rpt.pm_stockMonitor.trayParameter` | 条码优先、SKU 唯一匹配兜底 | +| `dailySales` | 采购日销 | | | `rpt.pm_stockMonitor.purchaseSale` | 按条码匹配最新记录 | +| `sales3d` | 3日日销 | | | `rpt.pm_stockMonitor.threeAvgSale` | 按条码匹配最新记录 | +| `sales7d` | 7日日销 | | | `rpt.pm_stockMonitor.sevenAvgSale` | 按条码匹配最新记录 | +| `sales15d` | 15日日销 | | | `rpt.pm_stockMonitor.fifteenAvgSale` | 按条码匹配最新记录 | | `purchasePrice` | 采购价 | `rpt.jst_sku.purchase_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 | | `costPrice` | 成本价(聚水潭) | `rpt.jst_sku.cost_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 | | `costPriceSecondary` | 成本价(新品) | | `external.product_catalog_wide_v1.cost_price` | | 按档案条码精确匹配 `barcode_business_key`,只读取 `catalog_source = INTAKE_READY` 的新品行;匹配不到或来源为空时留空 | @@ -85,4 +103,18 @@ | `JST_SKU` | 聚水潭 JSReportReplica | `rpt` | `jst_sku` | | `PRODUCT_DEVELOPMENT` | 家具产品开发系统 | `external` | `product_catalog_wide_v1` | | `CATEGORY_CONFIG` | 供应链中心类目配置 | `public` | `SupplyChainProductArchiveCategoryConfig` | +| `JST_SUPPLIER` | 聚水潭 JSReportReplica | `rpt` | `js_supplier` | +| `JST_STOCK_MONITOR` | 聚水潭 JSReportReplica | `rpt` | `pm_stockMonitor` | +| `JST_WAREHOUSE_INFO` | 聚水潭 JSReportReplica | `rpt` | `jst_warehouseInfo` | +| `JST_SKU_PRICE` | 聚水潭 JSReportReplica | `rpt` | `jst_sku` | +| `PRODUCT_CATALOG_COST_PRICE` | 家具产品开发系统 | `external` | `product_catalog_wide_v1` | +| `WAREHOUSE_CONFIG` | 供应链中心 | `public` | `SupplyChainProductArchiveWarehouseConfig` | | `YUNSHAN_PRODUCT_EXPORT` | 云杉商品资料 | Excel 工作表名 | Excel 文件名 | + +## 5. 生产同步入口 + +- 产品开发页面保存成功后,会按当前产品即时同步 `external.product_catalog_wide_v1` 的档案字段。 +- 聚水潭及其关联表由 systemd timer 周期检查。同步过程可以重复执行,主表只写实际变化,商品修改日志只记录后续真实变化。 +- 定时任务安装前必须在生产服务器的 `shared/supply-chain-product-archive-sync.env` 填写目标 PostgreSQL 和来源 SQL Server 的身份校验值;文件权限固定为 `600`,其中不保存数据库密码。 +- 安装命令只在发布完成并获得生产操作授权后执行:`bash scripts/install-supply-chain-product-archive-sync-timer.sh`。 +- 图片不属于此定时任务。页面优先读取 `SupplyChainProductArchiveImageCache`,没有缓存时才按条码/SKU调用素材库接口并回写链接。 diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index 93577884..4e95d7e1 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -17,6 +17,7 @@ "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:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts", "sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts", "sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts", "backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts", diff --git a/furniture-3d-system/prisma/migrations/20260810173000_persist_supply_chain_archive_enrichment/migration.sql b/furniture-3d-system/prisma/migrations/20260810173000_persist_supply_chain_archive_enrichment/migration.sql new file mode 100644 index 00000000..df286ce2 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260810173000_persist_supply_chain_archive_enrichment/migration.sql @@ -0,0 +1,17 @@ +ALTER TABLE "SupplyChainProductArchive" + ADD COLUMN "supplierType" TEXT NOT NULL DEFAULT '', + ADD COLUMN "supplierStatus" TEXT NOT NULL DEFAULT '', + ADD COLUMN "settlementMethod" TEXT NOT NULL DEFAULT '', + ADD COLUMN "boardProvider" TEXT NOT NULL DEFAULT '', + ADD COLUMN "penaltyStatus" TEXT NOT NULL DEFAULT '', + ADD COLUMN "warehouseType" TEXT NOT NULL DEFAULT '', + ADD COLUMN "warehouseHead" TEXT NOT NULL DEFAULT '', + ADD COLUMN "firstLeadTime" TEXT NOT NULL DEFAULT '', + ADD COLUMN "palletSpec" TEXT NOT NULL DEFAULT '', + ADD COLUMN "dailySales" TEXT NOT NULL DEFAULT '', + ADD COLUMN "sales3d" TEXT NOT NULL DEFAULT '', + ADD COLUMN "sales7d" TEXT NOT NULL DEFAULT '', + ADD COLUMN "sales15d" TEXT NOT NULL DEFAULT '', + ADD COLUMN "purchasePrice" TEXT NOT NULL DEFAULT '', + ADD COLUMN "costPrice" TEXT NOT NULL DEFAULT '', + ADD COLUMN "costPriceSecondary" TEXT NOT NULL DEFAULT ''; diff --git a/furniture-3d-system/prisma/migrations/20260811100000_add_supply_chain_product_archive_image_cache/migration.sql b/furniture-3d-system/prisma/migrations/20260811100000_add_supply_chain_product_archive_image_cache/migration.sql new file mode 100644 index 00000000..6647053b --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260811100000_add_supply_chain_product_archive_image_cache/migration.sql @@ -0,0 +1,35 @@ +CREATE TABLE "SupplyChainProductArchiveImageCache" ( + "id" TEXT NOT NULL, + "archiveId" TEXT NOT NULL, + "barcode" TEXT NOT NULL DEFAULT '', + "spu" TEXT NOT NULL DEFAULT '', + "sku" TEXT NOT NULL DEFAULT '', + "previewUrl" TEXT, + "source" TEXT NOT NULL DEFAULT '', + "viewKey" TEXT NOT NULL DEFAULT '', + "viewLabel" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'NOT_FOUND', + "checkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "error" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SupplyChainProductArchiveImageCache_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "SupplyChainProductArchiveImageCache_archiveId_key" +ON "SupplyChainProductArchiveImageCache"("archiveId"); + +CREATE INDEX "SupplyChainProductArchiveImageCache_barcode_idx" +ON "SupplyChainProductArchiveImageCache"("barcode"); + +CREATE INDEX "SupplyChainProductArchiveImageCache_spu_sku_idx" +ON "SupplyChainProductArchiveImageCache"("spu", "sku"); + +CREATE INDEX "SupplyChainProductArchiveImageCache_status_checkedAt_idx" +ON "SupplyChainProductArchiveImageCache"("status", "checkedAt"); + +ALTER TABLE "SupplyChainProductArchiveImageCache" +ADD CONSTRAINT "SupplyChainProductArchiveImageCache_archiveId_fkey" +FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 0fc6667d..43da667c 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -482,11 +482,28 @@ model SupplyChainProductArchive { replaceCount String @default("") supplierName String @default("") purchaseOwner String @default("") + supplierType String @default("") + supplierStatus String @default("") minimumOrderQuantity String @default("") + settlementMethod String @default("") + boardProvider String @default("") + penaltyStatus String @default("") + warehouseType String @default("") + warehouseHead String @default("") firstSourceCode String @default("PRODUCT_DEVELOPMENT") + firstLeadTime String @default("") + palletSpec String @default("") + dailySales String @default("") + sales3d String @default("") + sales7d String @default("") + sales15d String @default("") + purchasePrice String @default("") + costPrice String @default("") + costPriceSecondary String @default("") fieldSourceMap Json @default("{}") sources SupplyChainProductArchiveSource[] imageEmbedding ProductArchiveImageEmbedding? + imageCache SupplyChainProductArchiveImageCache? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -501,6 +518,28 @@ model SupplyChainProductArchive { } // 图纸与视频导出来源:按条码保存当前有效的图纸/视频状态,支持月度增量覆盖。 +model SupplyChainProductArchiveImageCache { + id String @id @default(cuid()) + archiveId String @unique + archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade) + barcode String @default("") + spu String @default("") + sku String @default("") + previewUrl String? @db.Text + source String @default("") + viewKey String @default("") + viewLabel String @default("") + status String @default("NOT_FOUND") + checkedAt DateTime @default(now()) + error String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([barcode]) + @@index([spu, sku]) + @@index([status, checkedAt]) +} + model SupplyChainProductArchiveDrawingMedia { id String @id @default(cuid()) barcode String @unique diff --git a/furniture-3d-system/scripts/install-supply-chain-product-archive-sync-timer.sh b/furniture-3d-system/scripts/install-supply-chain-product-archive-sync-timer.sh new file mode 100644 index 00000000..b0a10bf9 --- /dev/null +++ b/furniture-3d-system/scripts/install-supply-chain-product-archive-sync-timer.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${SUPPLY_CHAIN_ARCHIVE_SYNC_SERVICE_NAME:-furniture-supply-chain-product-archive-sync}" +APP_ROOT="$BASE/current/furniture-3d-system" +SHARED="$BASE/shared" +APP_ENV="$SHARED/.env" +SYNC_ENV="$SHARED/supply-chain-product-archive-sync.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" +TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer" +SYNC_ON_BOOT_SEC="${SUPPLY_CHAIN_ARCHIVE_SYNC_ON_BOOT_SEC:-3min}" +SYNC_INTERVAL="${SUPPLY_CHAIN_ARCHIVE_SYNC_INTERVAL:-15min}" + +if [ "$(id -u)" -ne 0 ]; then + echo "This installer must run as root." >&2 + exit 1 +fi + +if [ ! -d "$APP_ROOT" ]; then + echo "Missing app root: $APP_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$APP_ENV" ]; then + echo "Missing app env: $APP_ENV" >&2 + exit 1 +fi + +mkdir -p "$SHARED" +if [ ! -f "$SYNC_ENV" ]; then + umask 077 + cat >"$SYNC_ENV" <<'EOF' +# Product archive source sync guard identities. +# Fill these values on the production server. Do not commit secrets to Git. +SUPPLY_CHAIN_ARCHIVE_EXPECTED_DATABASE_IDENTITY= +SUPPLY_CHAIN_ARCHIVE_EXPECTED_SOURCE_DATABASE_IDENTITY= +SUPPLY_CHAIN_ARCHIVE_ENRICHMENT_BATCH_SIZE=200 +SUPPLY_CHAIN_ARCHIVE_JST_BATCH_SIZE=500 +SUPPLY_CHAIN_ARCHIVE_JST_CONCURRENCY=8 +EOF + echo "Created env template: $SYNC_ENV" +fi + +chmod 600 "$SYNC_ENV" + +cat >"$SERVICE_FILE" <"$TIMER_FILE" < argument.startsWith(prefix))?.slice(prefix.length) ?? '' +} + +function databaseIdentity(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() { + const host = process.env.JST_REPORT_DB_HOST?.trim() ?? '' + const database = process.env.JST_REPORT_DB_NAME?.trim() ?? '' + const user = process.env.JST_REPORT_DB_USER?.trim() ?? '' + if (!host || !database || !user) return '' + return [ + 'sqlserver', + user, + host, + process.env.JST_REPORT_DB_PORT?.trim() || '1433', + database, + process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', + ].join('|') +} + +async function main() { + if (!process.argv.includes('--apply')) { + throw new Error('This sync writes the archive. Pass --apply explicitly.') + } + if (readArgument('confirm') !== CONFIRM_TOKEN) { + throw new Error(`Pass --confirm=${CONFIRM_TOKEN}.`) + } + + const databaseUrl = process.env.DATABASE_URL?.trim() ?? '' + if (!databaseUrl) throw new Error('DATABASE_URL is required.') + const actualDatabaseIdentity = databaseIdentity(databaseUrl) + if (readArgument('expected-database-identity') !== actualDatabaseIdentity) { + throw new Error( + `Pass --expected-database-identity=${actualDatabaseIdentity}.`, + ) + } + + const actualSourceIdentity = sourceDatabaseIdentity() + if (!actualSourceIdentity) throw new Error('JST_REPORT_DB_* is required.') + if (readArgument('expected-source-database-identity') !== actualSourceIdentity) { + throw new Error( + `Pass --expected-source-database-identity=${actualSourceIdentity}.`, + ) + } + + const batchSizeValue = Number(readArgument('batch-size') || '200') + if (!Number.isSafeInteger(batchSizeValue) || batchSizeValue < 1 || batchSizeValue > 200) { + throw new Error('--batch-size must be an integer between 1 and 200.') + } + + const syncJstBase = process.argv.includes('--sync-jst-base') + const jstBatchSize = Number(readArgument('jst-batch-size') || '500') + if ( + !Number.isSafeInteger(jstBatchSize) || + jstBatchSize < 1 || + jstBatchSize > 5_000 + ) { + throw new Error('--jst-batch-size must be an integer between 1 and 5000.') + } + const jstConcurrency = Number(readArgument('jst-concurrency') || '8') + if ( + !Number.isSafeInteger(jstConcurrency) || + jstConcurrency < 1 || + jstConcurrency > 32 + ) { + throw new Error('--jst-concurrency must be an integer between 1 and 32.') + } + + const [ + { prisma }, + { supplyChainProductArchiveEnrichmentSyncService }, + { jstSkuProductArchiveFullSyncService }, + ] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/supply-chain-product-archive-enrichment-sync.service'), + syncJstBase + ? import('../src/server/services/jst-sku-product-archive-full-sync.service') + : Promise.resolve({ jstSkuProductArchiveFullSyncService: null }), + ]) + try { + const baseResult = jstSkuProductArchiveFullSyncService + ? await jstSkuProductArchiveFullSyncService.syncAll({ + apply: true, + batchSize: jstBatchSize, + concurrency: jstConcurrency, + }) + : null + const enrichmentResult = await supplyChainProductArchiveEnrichmentSyncService.syncAll({ + batchSize: batchSizeValue, + }) + console.log(JSON.stringify({ baseResult, enrichmentResult }, null, 2)) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts b/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts index eec0314c..3768f716 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts @@ -4,7 +4,6 @@ export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [ { key: 'inOutWarehouse', label: '出入库仓库(采购类型)' }, { key: 'spu', label: 'SPU' }, { key: 'sku', label: 'SKU' }, - { key: 'image', label: '图片' }, { key: 'businessCreatedAt', label: '创建时间' }, { key: 'categoryL1', label: '一级类目' }, { key: 'categoryL2', label: '二级类目' }, @@ -30,8 +29,24 @@ export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [ { key: 'isSeasonal', label: '是否季节性产品' }, { key: 'replaceCount', label: '替换款数量' }, { key: 'supplierName', label: '供应商' }, + { key: 'supplierType', label: '供应商类型' }, + { key: 'supplierStatus', label: '供应商状态' }, { key: 'purchaseOwner', label: '采购' }, + { key: 'settlementMethod', label: '结算方式' }, + { key: 'boardProvider', label: '板材提供方' }, + { key: 'penaltyStatus', label: '违约金赔付状态' }, + { key: 'warehouseType', label: '仓库属性' }, + { key: 'warehouseHead', label: '仓库负责人' }, { key: 'minimumOrderQuantity', label: '产品起订量' }, + { key: 'firstLeadTime', label: '首单生产周期' }, + { key: 'palletSpec', label: '托盘参数' }, + { key: 'dailySales', label: '采购日销' }, + { key: 'sales3d', label: '3日日销' }, + { key: 'sales7d', label: '7日日销' }, + { key: 'sales15d', label: '15日日销' }, + { key: 'purchasePrice', label: '采购价' }, + { key: 'costPrice', label: '成本价(聚水潭)' }, + { key: 'costPriceSecondary', label: '成本价(新品)' }, ] as const export type ProductArchiveChangeFieldKey = diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive-field-source.ts b/furniture-3d-system/src/lib/supply-chain-product-archive-field-source.ts index 9628e883..8e3df351 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive-field-source.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive-field-source.ts @@ -5,6 +5,12 @@ 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 const DRAWING_MEDIA_ARCHIVE_SOURCE_CODE = 'DRAWING_MEDIA_EXPORT' +export const JST_SUPPLIER_ARCHIVE_SOURCE_CODE = 'JST_SUPPLIER' +export const JST_STOCK_MONITOR_ARCHIVE_SOURCE_CODE = 'JST_STOCK_MONITOR' +export const JST_WAREHOUSE_ARCHIVE_SOURCE_CODE = 'JST_WAREHOUSE_INFO' +export const JST_SKU_PRICE_ARCHIVE_SOURCE_CODE = 'JST_SKU_PRICE' +export const PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE = 'PRODUCT_CATALOG_COST_PRICE' +export const WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE = 'WAREHOUSE_CONFIG' export type ProductArchiveFieldSource = { sourceCode: string @@ -22,7 +28,6 @@ export const JST_SKU_ARCHIVE_FIELD_COLUMNS = { barcode: 'sku_id', spu: 'i_id', sku: 'other_7', - image: 'pic_big | pic', productName: 'name', productTag: 'labels', brand: 'brand', @@ -47,7 +52,6 @@ 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', diff --git a/furniture-3d-system/src/server/repositories/jst-sku-product-archive.repository.ts b/furniture-3d-system/src/server/repositories/jst-sku-product-archive.repository.ts index 5e81e477..2a52cff5 100644 --- a/furniture-3d-system/src/server/repositories/jst-sku-product-archive.repository.ts +++ b/furniture-3d-system/src/server/repositories/jst-sku-product-archive.repository.ts @@ -11,11 +11,14 @@ import { 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' export type JstSkuProductArchivePatch = { spu?: string sku?: string - image?: string productName?: string categoryL1?: string productTag?: string @@ -40,6 +43,7 @@ export type JstSkuProductArchivePatch = { export type JstSkuProductArchiveInput = { barcode: string businessCreatedAt: Date + sourceUpdatedAt: Date | null patch: JstSkuProductArchivePatch fieldSourceMap: ProductArchiveFieldSourceMap source: { @@ -56,6 +60,7 @@ export type JstSkuProductArchiveInput = { export type JstSkuProductArchiveUpsertResult = { status: 'created' | 'updated' | 'unchanged' changedFields: string[] + changes: ProductArchiveFieldChange[] } export interface JstSkuProductArchiveRepository { @@ -68,7 +73,6 @@ const currentArchiveSelect = { firstSourceCode: true, spu: true, sku: true, - image: true, businessCreatedAt: true, categoryL1: true, categoryL2: true, @@ -153,6 +157,36 @@ function changedPatchFields(previous: CurrentArchive, patch: JstSkuProductArchiv .map(([field]) => field) } +const changeFieldLabels = new Map( + PRODUCT_ARCHIVE_CHANGE_FIELDS.map((field) => [field.key, field.label]), +) + +function buildChanges( + previous: CurrentArchive, + patch: JstSkuProductArchivePatch, + includeBusinessCreatedAt: boolean, + businessCreatedAt: Date, +) { + const previousRecord = previous as unknown as Record + const values: Record = { + ...patch, + ...(includeBusinessCreatedAt ? { businessCreatedAt } : {}), + } + return Object.entries(values).flatMap(([field, value]) => { + const fieldLabel = changeFieldLabels.get(field as never) + if (!fieldLabel) return [] + const previousValue = comparable(previousRecord[field]) + const nextValue = comparable(value) + if (previousValue === nextValue) return [] + return [{ + field: field as ProductArchiveFieldChange['field'], + fieldLabel, + previousValue, + nextValue, + }] + }) satisfies ProductArchiveFieldChange[] +} + class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveRepository { async upsert(input: JstSkuProductArchiveInput) { return prisma.$transaction(async (tx) => { @@ -193,6 +227,14 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit ...(shouldUpdateBusinessCreatedAt ? ['businessCreatedAt'] : []), ] : [] + const changes = previous + ? buildChanges( + previous, + input.patch, + shouldUpdateBusinessCreatedAt, + input.businessCreatedAt, + ) + : [] const sourceMapChanged = JSON.stringify(parseProductArchiveFieldSourceMap(previous?.fieldSourceMap)) !== JSON.stringify(nextFieldSources) @@ -257,7 +299,7 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit }, }) - return { status, changedFields } + return { status, changedFields, changes } }) } } diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts new file mode 100644 index 00000000..2640a7e7 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts @@ -0,0 +1,250 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { + PRODUCT_ARCHIVE_CHANGE_FIELDS, + type ProductArchiveFieldChange, +} from '@/lib/supply-chain-product-archive-change-log' +import { + createProductArchiveFieldSourceMap, + mergeProductArchiveFieldSourceMaps, + parseProductArchiveFieldSourceMap, + toProductArchiveFieldSourceJson, +} from '@/lib/supply-chain-product-archive-field-source' + +export const PRODUCT_ARCHIVE_ENRICHMENT_FIELDS = [ + 'supplierType', + 'supplierStatus', + 'purchaseOwner', + 'settlementMethod', + 'boardProvider', + 'penaltyStatus', + 'warehouseType', + 'warehouseHead', + 'storageType', + 'minimumOrderQuantity', + 'firstLeadTime', + 'palletSpec', + 'dailySales', + 'sales3d', + 'sales7d', + 'sales15d', + 'purchasePrice', + 'costPrice', + 'costPriceSecondary', +] as const + +export type ProductArchiveEnrichmentField = + (typeof PRODUCT_ARCHIVE_ENRICHMENT_FIELDS)[number] + +export type ProductArchiveEnrichmentPatch = Partial< + Record +> + +export type ProductArchiveEnrichmentSource = { + sourceCode: string + sourceSystem: string + sourceSchema: string + sourceObject: string + sourceRecordId: string + sourceColumns: Partial> + sourceRecordedAt?: Date | null +} + +const syncTargetSelect = { + id: true, + barcode: true, + spu: true, + sku: true, + supplierName: true, + inOutWarehouse: true, +} satisfies Prisma.SupplyChainProductArchiveSelect + +export type ProductArchiveEnrichmentTarget = + Prisma.SupplyChainProductArchiveGetPayload<{ select: typeof syncTargetSelect }> + +const currentArchiveSelect = { + id: true, + barcode: true, + spu: true, + fieldSourceMap: true, + supplierType: true, + supplierStatus: true, + purchaseOwner: true, + settlementMethod: true, + boardProvider: true, + penaltyStatus: true, + warehouseType: true, + warehouseHead: true, + storageType: true, + minimumOrderQuantity: true, + firstLeadTime: true, + palletSpec: true, + dailySales: true, + sales3d: true, + sales7d: true, + sales15d: true, + purchasePrice: true, + costPrice: true, + costPriceSecondary: true, +} satisfies Prisma.SupplyChainProductArchiveSelect + +type CurrentArchive = Prisma.SupplyChainProductArchiveGetPayload<{ + select: typeof currentArchiveSelect +}> + +const fieldLabels = new Map( + PRODUCT_ARCHIVE_CHANGE_FIELDS.map((field) => [field.key, field.label]), +) + +function comparable(value: unknown) { + if (value === null || value === undefined) return '' + return String(value) +} + +function changedValues( + previous: CurrentArchive, + patch: ProductArchiveEnrichmentPatch, +): ProductArchiveFieldChange[] { + const previousRecord = previous as unknown as Record + return Object.entries(patch).flatMap(([field, next]) => { + const previousValue = comparable(previousRecord[field]) + const nextValue = comparable(next) + if (previousValue === nextValue) return [] + const fieldLabel = fieldLabels.get(field as never) + if (!fieldLabel) throw new Error(`Missing product archive field label: ${field}`) + return [{ + field: field as ProductArchiveFieldChange['field'], + fieldLabel, + previousValue, + nextValue, + }] + }) +} + +export interface SupplyChainProductArchiveEnrichmentSyncRepository { + listTargets(input: { + afterId?: string + limit: number + }): Promise + applyPatch(input: { + archiveId: string + patch: ProductArchiveEnrichmentPatch + source: ProductArchiveEnrichmentSource + }): Promise<{ changes: ProductArchiveFieldChange[]; sourceInitialized: boolean }> +} + +class SupplyChainProductArchiveEnrichmentSyncRepositoryClass + implements SupplyChainProductArchiveEnrichmentSyncRepository +{ + async listTargets(input: { afterId?: string; limit: number }) { + return prisma.supplyChainProductArchive.findMany({ + where: input.afterId ? { id: { gt: input.afterId } } : undefined, + select: syncTargetSelect, + orderBy: { id: 'asc' }, + take: Math.min(200, Math.max(1, Math.trunc(input.limit))), + }) + } + + async applyPatch(input: { + archiveId: string + patch: ProductArchiveEnrichmentPatch + source: ProductArchiveEnrichmentSource + }) { + return prisma.$transaction(async (tx) => { + const previous = await tx.supplyChainProductArchive.findUniqueOrThrow({ + where: { id: input.archiveId }, + select: currentArchiveSelect, + }) + const changes = changedValues(previous, input.patch) + const sourceRecordId = previous.barcode + const fieldSourceRecordId = `${input.source.sourceRecordId}:${previous.barcode}` + const syncedAt = new Date() + const existingSource = await tx.supplyChainProductArchiveSource.findUnique({ + where: { + sourceCode_sourceRecordId: { + sourceCode: input.source.sourceCode, + sourceRecordId, + }, + }, + select: { id: true }, + }) + + if (changes.length > 0) { + const changedPatch = Object.fromEntries( + changes.map((change) => [ + change.field, + input.patch[change.field as ProductArchiveEnrichmentField] ?? '', + ]), + ) as ProductArchiveEnrichmentPatch + const sourceFields = Object.fromEntries( + changes.map((change) => [ + change.field, + input.source.sourceColumns[ + change.field as ProductArchiveEnrichmentField + ] ?? change.field, + ]), + ) + const incomingSources = createProductArchiveFieldSourceMap({ + fields: sourceFields, + sourceCode: input.source.sourceCode, + sourceSystem: input.source.sourceSystem, + sourceSchema: input.source.sourceSchema, + sourceObject: input.source.sourceObject, + sourceRecordId: fieldSourceRecordId, + sourceUpdatedAt: input.source.sourceRecordedAt ?? syncedAt, + }) + await tx.supplyChainProductArchive.update({ + where: { id: input.archiveId }, + data: { + ...changedPatch, + fieldSourceMap: toProductArchiveFieldSourceJson( + mergeProductArchiveFieldSourceMaps( + parseProductArchiveFieldSourceMap(previous.fieldSourceMap), + incomingSources, + ), + ), + }, + }) + } + + await tx.supplyChainProductArchiveSource.upsert({ + where: { + sourceCode_sourceRecordId: { + sourceCode: input.source.sourceCode, + sourceRecordId, + }, + }, + create: { + archiveId: previous.id, + sourceCode: input.source.sourceCode, + entryMode: 'AUTO', + sourceRecordId, + sourceProductId: previous.spu, + sourceSkuItemId: previous.barcode, + sourceSystem: input.source.sourceSystem, + sourceSchema: input.source.sourceSchema, + sourceObject: input.source.sourceObject, + sourceCreatedAt: input.source.sourceRecordedAt, + lastSyncedAt: syncedAt, + }, + update: { + archiveId: previous.id, + sourceProductId: previous.spu, + sourceSkuItemId: previous.barcode, + sourceSystem: input.source.sourceSystem, + sourceSchema: input.source.sourceSchema, + sourceObject: input.source.sourceObject, + sourceCreatedAt: input.source.sourceRecordedAt, + lastSyncedAt: syncedAt, + }, + }) + + return { changes, sourceInitialized: existingSource === null } + }) + } +} + +export const supplyChainProductArchiveEnrichmentSyncRepository = + new SupplyChainProductArchiveEnrichmentSyncRepositoryClass() + +export { SupplyChainProductArchiveEnrichmentSyncRepositoryClass } diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-image-cache.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-image-cache.repository.ts new file mode 100644 index 00000000..4865db37 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-image-cache.repository.ts @@ -0,0 +1,225 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import type { ProductArchiveMaterialImage } from '@/lib/supply-chain-product-archive-material-image' + +export type ProductArchiveImageCacheStatus = 'RESOLVED' | 'NOT_FOUND' | 'ERROR' + +const imageCacheSelect = { + id: true, + archiveId: true, + barcode: true, + spu: true, + sku: true, + previewUrl: true, + source: true, + viewKey: true, + viewLabel: true, + status: true, + checkedAt: true, + error: true, +} satisfies Prisma.SupplyChainProductArchiveImageCacheSelect + +export type ProductArchiveImageCacheRecord = Prisma.SupplyChainProductArchiveImageCacheGetPayload<{ + select: typeof imageCacheSelect +}> + +export type ProductArchiveImageCacheLookup = { + archiveId?: string + barcode?: string + spu?: string + sku?: string +} + +export type ProductArchiveIdentity = { + id: string + barcode: string + spu: string + sku: string +} + +export interface SupplyChainProductArchiveImageCacheRepository { + findByArchiveIds(archiveIds: readonly string[]): Promise> + findByIdentifiers(input: ProductArchiveImageCacheLookup): Promise + findArchiveIdentityByIdentifiers(input: ProductArchiveImageCacheLookup): Promise + saveResolved(input: { + archiveId: string + barcode: string + spu: string + sku: string + image: ProductArchiveMaterialImage + }): Promise + saveNotFound(input: { + archiveId: string + barcode: string + spu: string + sku: string + }): Promise + saveError(input: { + archiveId: string + barcode: string + spu: string + sku: string + error: string + }): Promise +} + +function normalize(value: string | undefined) { + return value?.trim() ?? '' +} + +function normalizedIds(ids: readonly string[]) { + return Array.from(new Set(ids.map(normalize).filter(Boolean))).slice(0, 200) +} + +function baseData(input: { + archiveId: string + barcode: string + spu: string + sku: string +}) { + return { + archiveId: normalize(input.archiveId), + barcode: normalize(input.barcode), + spu: normalize(input.spu), + sku: normalize(input.sku), + checkedAt: new Date(), + } +} + +class SupplyChainProductArchiveImageCacheRepositoryClass + implements SupplyChainProductArchiveImageCacheRepository +{ + async findByArchiveIds(archiveIds: readonly string[]) { + const ids = normalizedIds(archiveIds) + if (ids.length === 0) return new Map() + + const rows = await prisma.supplyChainProductArchiveImageCache.findMany({ + where: { archiveId: { in: ids } }, + select: imageCacheSelect, + }) + return new Map(rows.map((row) => [row.archiveId, row])) + } + + async findByIdentifiers(input: ProductArchiveImageCacheLookup) { + const archiveId = normalize(input.archiveId) + if (archiveId) { + return prisma.supplyChainProductArchiveImageCache.findUnique({ + where: { archiveId }, + select: imageCacheSelect, + }) + } + + const barcode = normalize(input.barcode) + if (barcode) { + return prisma.supplyChainProductArchiveImageCache.findFirst({ + where: { barcode }, + select: imageCacheSelect, + }) + } + + const sku = normalize(input.sku) + if (!sku) return null + return prisma.supplyChainProductArchiveImageCache.findFirst({ + where: { + sku, + ...(input.spu?.trim() ? { spu: normalize(input.spu) } : {}), + }, + orderBy: { updatedAt: 'desc' }, + select: imageCacheSelect, + }) + } + + async findArchiveIdentityByIdentifiers(input: ProductArchiveImageCacheLookup) { + const barcode = normalize(input.barcode) + if (barcode) { + return prisma.supplyChainProductArchive.findUnique({ + where: { barcode }, + select: { id: true, barcode: true, spu: true, sku: true }, + }) + } + + const sku = normalize(input.sku) + if (!sku) return null + return prisma.supplyChainProductArchive.findFirst({ + where: { + sku, + ...(input.spu?.trim() ? { spu: normalize(input.spu) } : {}), + }, + orderBy: { updatedAt: 'desc' }, + select: { id: true, barcode: true, spu: true, sku: true }, + }) + } + + async saveResolved(input: { + archiveId: string + barcode: string + spu: string + sku: string + image: ProductArchiveMaterialImage + }) { + const data = { + ...baseData(input), + previewUrl: input.image.previewUrl, + source: input.image.source, + viewKey: input.image.viewKey ?? '', + viewLabel: input.image.viewLabel ?? '', + status: 'RESOLVED' as const, + error: '', + } + await prisma.supplyChainProductArchiveImageCache.upsert({ + where: { archiveId: data.archiveId }, + create: data, + update: data, + }) + } + + async saveNotFound(input: { + archiveId: string + barcode: string + spu: string + sku: string + }) { + const data = { + ...baseData(input), + previewUrl: null, + source: '', + viewKey: '', + viewLabel: '', + status: 'NOT_FOUND' as const, + error: '', + } + await prisma.supplyChainProductArchiveImageCache.upsert({ + where: { archiveId: data.archiveId }, + create: data, + update: data, + }) + } + + async saveError(input: { + archiveId: string + barcode: string + spu: string + sku: string + error: string + }) { + const data = { + ...baseData(input), + previewUrl: null, + source: '', + viewKey: '', + viewLabel: '', + status: 'ERROR' as const, + error: input.error.slice(0, 1000), + } + await prisma.supplyChainProductArchiveImageCache.upsert({ + where: { archiveId: data.archiveId }, + create: data, + update: data, + }) + } +} + +export const supplyChainProductArchiveImageCacheRepository = + new SupplyChainProductArchiveImageCacheRepositoryClass() + +export { SupplyChainProductArchiveImageCacheRepositoryClass } diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sync.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sync.repository.ts index ba05377b..5ab53f54 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sync.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sync.repository.ts @@ -42,12 +42,12 @@ export type ProductCatalogWideArchiveRecord = { archive_purchase_owner: string | null archive_minimum_order_quantity: string | null product_tag: string | null - sample_weight_kg: string | number | null - package_length_cm: string | number | null - package_width_cm: string | number | null - package_height_cm: string | number | null - package_volume_cm3: string | number | null - package_set_count: string | number | null + sample_weight_kg: string | number | Prisma.Decimal | null + package_length_cm: string | number | Prisma.Decimal | null + package_width_cm: string | number | Prisma.Decimal | null + package_height_cm: string | number | Prisma.Decimal | null + package_volume_cm3: string | number | Prisma.Decimal | null + package_set_count: string | number | Prisma.Decimal | null purchase_order_method: string | null factory_warehouse: string | null image_url: string | null @@ -89,7 +89,6 @@ export interface SupplyChainProductArchiveSyncRepository { const archiveComparableSelect = { spu: true, sku: true, - image: true, businessCreatedAt: true, categoryL1: true, categoryL2: true, @@ -161,7 +160,6 @@ function buildArchiveUpdate( const update: Prisma.SupplyChainProductArchiveUncheckedUpdateInput = { spu: archive.spu, sku: archive.sku, - image: archive.image, businessCreatedAt: archive.businessCreatedAt, categoryL1: archive.categoryL1, categoryL2: archive.categoryL2, diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts index 07355299..3d979d37 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts @@ -37,8 +37,24 @@ const productArchiveSelect = { isSeasonal: true, replaceCount: true, supplierName: true, + supplierType: true, + supplierStatus: true, purchaseOwner: true, + settlementMethod: true, + boardProvider: true, + penaltyStatus: true, + warehouseType: true, + warehouseHead: true, minimumOrderQuantity: true, + firstLeadTime: true, + palletSpec: true, + dailySales: true, + sales3d: true, + sales7d: true, + sales15d: true, + purchasePrice: true, + costPrice: true, + costPriceSecondary: true, createdAt: true, updatedAt: true, } satisfies Prisma.SupplyChainProductArchiveSelect diff --git a/furniture-3d-system/src/server/services/__tests__/jst-sku-product-archive-full-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/jst-sku-product-archive-full-sync.service.test.ts index 84d83ccd..2409e5de 100644 --- a/furniture-3d-system/src/server/services/__tests__/jst-sku-product-archive-full-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/jst-sku-product-archive-full-sync.service.test.ts @@ -11,6 +11,10 @@ jest.mock('@/server/repositories/jst-sku-product-archive.repository', () => ({ jest.mock('@/server/repositories/supply-chain-product-archive-category-config.repository', () => ({ supplyChainProductArchiveCategoryConfigRepository: {}, })) +jest.mock('../supply-chain-product-archive-change-log.service', () => ({ + recordProductArchiveFieldChanges: jest.fn().mockResolvedValue(undefined), + recordProductArchiveImported: jest.fn().mockResolvedValue(undefined), +})) function sourceRecord(autoid: number): JstSkuProductArchiveSourceRecord { return { @@ -49,9 +53,18 @@ function dependencies(pages: JstSkuProductArchiveSourceRecord[][]) { const archiveRepository = { upsert: jest .fn() - .mockResolvedValueOnce({ status: 'created', changedFields: [] }) - .mockResolvedValueOnce({ status: 'updated', changedFields: ['productName'] }) - .mockResolvedValue({ status: 'unchanged', changedFields: [] }), + .mockResolvedValueOnce({ status: 'created', changedFields: [], changes: [] }) + .mockResolvedValueOnce({ + status: 'updated', + changedFields: ['productName'], + changes: [{ + field: 'productName', + fieldLabel: '商品名称', + previousValue: '旧名称', + nextValue: '新名称', + }], + }) + .mockResolvedValue({ status: 'unchanged', changedFields: [], changes: [] }), } const categoryConfigRepository = { listPage: jest.fn().mockResolvedValue({ diff --git a/furniture-3d-system/src/server/services/__tests__/jst-sku-product-archive-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/jst-sku-product-archive-sync.service.test.ts index 0a349827..aec87800 100644 --- a/furniture-3d-system/src/server/services/__tests__/jst-sku-product-archive-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/jst-sku-product-archive-sync.service.test.ts @@ -88,7 +88,6 @@ describe('JST SKU product archive sync', () => { productTag: '1688-yijian-daifa,matched', brand: 'other-brand-standard', supplierName: '景瓴十方', - image: 'https://images.example.com/large.webp', spu: 'ZSJ0089', sku: 'ZSJ008913', productName: '款式八 61.5-80/17/35 原木色', @@ -112,21 +111,11 @@ describe('JST SKU product archive sync', () => { 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?.fieldSourceMap.image.sourceColumn).toBe('pic_big') expect(mapped?.patch.volume?.toString()).toBe('9518.25') expect(mapped?.fieldSourceMap.volume.sourceColumn).toBe('l*w*h') expect(mapped?.fieldSourceMap.spec.sourceColumn).toBe('name') }) - it('falls back to pic and records the exact source column', () => { - const mapped = mapJstSkuProductArchiveSource( - createSourceRecord({ pic_big: '', pic: 'https://images.example.com/normal.webp' }), - ) - - expect(mapped?.patch.image).toBe('https://images.example.com/normal.webp') - expect(mapped?.fieldSourceMap.image.sourceColumn).toBe('pic') - }) - it('maps the configured primary category and records its config source', () => { const mapped = mapJstSkuProductArchiveSource(createSourceRecord(), createCategoryConfig()) diff --git a/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts index 56c8793c..1b7b470b 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts @@ -1,5 +1,9 @@ import JSZip from 'jszip' import { ProductArchiveMaterialImageService } from '../product-archive-material-image.service' +import type { + ProductArchiveImageCacheRecord, + SupplyChainProductArchiveImageCacheRepository, +} from '@/server/repositories/supply-chain-product-archive-image-cache.repository' function jsonResponse(status: number, payload: unknown) { return { @@ -36,6 +40,72 @@ const config = () => ({ apiKey: 'test-key', }) +function createImageCacheRepository(): SupplyChainProductArchiveImageCacheRepository { + const records = new Map() + const now = () => new Date() + const save = (input: { + archiveId: string + barcode: string + spu: string + sku: string + status: string + previewUrl?: string | null + source?: string + viewKey?: string + viewLabel?: string + error?: string + }) => { + const record = { + id: `cache-${input.archiveId}`, + archiveId: input.archiveId, + barcode: input.barcode, + spu: input.spu, + sku: input.sku, + previewUrl: input.previewUrl ?? null, + source: input.source ?? '', + viewKey: input.viewKey ?? '', + viewLabel: input.viewLabel ?? '', + status: input.status, + checkedAt: now(), + error: input.error ?? '', + } as ProductArchiveImageCacheRecord + records.set(input.archiveId, record) + } + + return { + findByArchiveIds: async (ids) => new Map( + ids.map((id) => [id, records.get(id)] as const) + .filter((entry): entry is readonly [string, ProductArchiveImageCacheRecord] => Boolean(entry[1])), + ), + findByIdentifiers: async (input) => { + if (input.archiveId) return records.get(input.archiveId) ?? null + return [...records.values()].find((record) => + (input.barcode && record.barcode === input.barcode) + || (input.sku && record.sku === input.sku && (!input.spu || record.spu === input.spu)), + ) ?? null + }, + findArchiveIdentityByIdentifiers: async (input) => { + const record = [...records.values()].find((item) => + (input.barcode && item.barcode === input.barcode) + || (input.sku && item.sku === input.sku && (!input.spu || item.spu === input.spu)), + ) + return record + ? { id: record.archiveId, barcode: record.barcode, spu: record.spu, sku: record.sku } + : null + }, + saveResolved: async (input) => save({ + ...input, + status: 'RESOLVED', + previewUrl: input.image.previewUrl, + source: input.image.source, + viewKey: input.image.viewKey, + viewLabel: input.image.viewLabel, + }), + saveNotFound: async (input) => save({ ...input, status: 'NOT_FOUND' }), + saveError: async (input) => save({ ...input, status: 'ERROR', error: input.error }), + } +} + describe('product archive material image service', () => { it('limits a page batch to four concurrent material-library requests', async () => { let activeRequests = 0 @@ -57,7 +127,7 @@ describe('product archive material image service', () => { }, }) }) as unknown as typeof fetch - const service = new ProductArchiveMaterialImageService(fetchImpl, config) + const service = new ProductArchiveMaterialImageService(fetchImpl, config, Date.now, createImageCacheRepository()) const items = Array.from({ length: 9 }, (_, index) => ({ archiveId: `archive-${index}`, spu: `SPU-${index}`, @@ -73,6 +143,47 @@ describe('product archive material image service', () => { expect(maxActiveRequests).toBeLessThanOrEqual(4) }) + it('uses the persistent image cache before calling the material library', async () => { + const cacheRepository = createImageCacheRepository() + const firstFetch = jest.fn(async () => jsonResponse(200, { + data: { + items: [{ previewUrl: 'https://image.example.com/cached.webp', isLatest: true }], + }, + })) as unknown as typeof fetch + const firstService = new ProductArchiveMaterialImageService( + firstFetch, + config, + Date.now, + cacheRepository, + ) + const request = { + archiveId: 'archive-cached', + barcode: 'BARCODE-CACHED', + sku: 'SKU-CACHED', + } + await expect(firstService.resolve(request)).resolves.toEqual({ + previewUrl: 'https://image.example.com/cached.webp', + source: 'barcode', + viewKey: 'latest', + viewLabel: '条码 SKU 图', + }) + + const secondFetch = jest.fn() as unknown as typeof fetch + const secondService = new ProductArchiveMaterialImageService( + secondFetch, + config, + Date.now, + cacheRepository, + ) + await expect(secondService.resolve(request)).resolves.toEqual({ + previewUrl: 'https://image.example.com/cached.webp', + source: 'barcode', + viewKey: 'latest', + viewLabel: '条码 SKU 图', + }) + expect(secondFetch).not.toHaveBeenCalled() + }) + it('uses the latest barcode image when the barcode lookup succeeds', async () => { const fetchImpl = jest.fn(async () => jsonResponse(200, { data: { @@ -82,7 +193,7 @@ describe('product archive material image service', () => { ], }, })) as unknown as typeof fetch - const service = new ProductArchiveMaterialImageService(fetchImpl, config) + const service = new ProductArchiveMaterialImageService(fetchImpl, config, Date.now, createImageCacheRepository()) await expect(service.resolve({ spu: 'CJ0031', barcode: '69001', sku: 'SKU-1' })).resolves.toEqual({ previewUrl: 'https://image.example.com/latest.webp', @@ -115,7 +226,7 @@ describe('product archive material image service', () => { data: { downloadUrls: { all: 'https://image.example.com/archive.zip' } }, })) .mockResolvedValueOnce(bufferResponse(archive)) as unknown as typeof fetch - const service = new ProductArchiveMaterialImageService(fetchImpl, config) + const service = new ProductArchiveMaterialImageService(fetchImpl, config, Date.now, createImageCacheRepository()) await expect(service.resolve({ spu: 'CJ0031', @@ -148,7 +259,7 @@ describe('product archive material image service', () => { data: { downloadUrls: { all: 'https://image.example.com/archive.zip' } }, })) .mockResolvedValueOnce(bufferResponse(archive)) as unknown as typeof fetch - const skuService = new ProductArchiveMaterialImageService(skuFetchImpl, config) + const skuService = new ProductArchiveMaterialImageService(skuFetchImpl, config, Date.now, createImageCacheRepository()) await expect(skuService.resolve({ spu: 'cj0031', @@ -181,7 +292,7 @@ describe('product archive material image service', () => { }, }, })) as unknown as typeof fetch - const service = new ProductArchiveMaterialImageService(fetchImpl, config) + const service = new ProductArchiveMaterialImageService(fetchImpl, config, Date.now, createImageCacheRepository()) await expect(service.resolve({ spu: 'CJ0031', sku: 'CJ003155' })).resolves.toEqual({ previewUrl: 'https://image.example.com/left-front.webp', diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts new file mode 100644 index 00000000..3ef9295f --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts @@ -0,0 +1,155 @@ +jest.mock('../supply-chain-product-archive-change-log.service', () => ({ + recordProductArchiveFieldChanges: jest.fn().mockResolvedValue(undefined), +})) +jest.mock('@/server/repositories/jst-sku-price.repository', () => ({ + jstSkuPriceRepository: {}, +})) +jest.mock('@/server/repositories/jst-stock-monitor.repository', () => ({ + jstStockMonitorRepository: {}, +})) +jest.mock('@/server/repositories/jst-supplier-profile.repository', () => ({ + jstSupplierProfileRepository: {}, +})) +jest.mock('@/server/repositories/jst-warehouse-profile.repository', () => ({ + jstWarehouseProfileRepository: {}, +})) +jest.mock('@/server/repositories/product-catalog-cost-price.repository', () => ({ + productCatalogCostPriceRepository: {}, +})) +jest.mock('@/server/repositories/supply-chain-product-archive-warehouse-config.repository', () => ({ + supplyChainProductArchiveWarehouseConfigRepository: {}, +})) +jest.mock('@/server/repositories/supply-chain-product-archive-enrichment-sync.repository', () => ({})) + +import { recordProductArchiveFieldChanges } from '../supply-chain-product-archive-change-log.service' +import { SupplyChainProductArchiveEnrichmentSyncService } from '../supply-chain-product-archive-enrichment-sync.service' + +describe('supply-chain product archive enrichment sync', () => { + it('persists every external group and logs only later value changes', async () => { + const target = { + id: 'archive-1', + barcode: 'BAR-001', + spu: 'SPU-001', + sku: 'SKU-001', + supplierName: '源沃家具', + inOutWarehouse: 'WH-1', + } + const repository = { + listTargets: jest + .fn() + .mockResolvedValueOnce([target]) + .mockResolvedValueOnce([]), + applyPatch: jest.fn().mockImplementation(async (input) => { + if ('purchasePrice' in input.patch) { + return { + sourceInitialized: false, + changes: [{ + field: 'purchasePrice', + fieldLabel: '采购价', + previousValue: '88.50', + nextValue: '90.00', + }], + } + } + return { sourceInitialized: true, changes: [] } + }), + } + const supplierRepository = { + listByNames: jest.fn().mockResolvedValue([{ + supplierName: '源沃家具', + supplierType: '家具厂', + supplierStatusCode: '1', + purchaser: '千里眼', + settlementMethod: '月结', + boardProvider: '简尚', + penaltyStatus: '执行', + }]), + close: jest.fn().mockResolvedValue(undefined), + } + const stockRepository = { + listByKeys: jest.fn().mockResolvedValue([{ + sku: 'SKU-001', + barcode: 'BAR-001', + minimum: '12', + produceCycle: '7', + trayParameter: '40', + purchaseSale: '35.39', + threeAvgSale: '30', + sevenAvgSale: '32.77', + fifteenAvgSale: '34.72', + }]), + close: jest.fn().mockResolvedValue(undefined), + } + const warehouseRepository = { + listByNames: jest.fn().mockResolvedValue([{ + warehouseName: 'WH-1', + warehouseType: '成品发货仓-真实库存', + }]), + close: jest.fn().mockResolvedValue(undefined), + } + const priceRepository = { + listByBarcodes: jest.fn().mockResolvedValue([{ + barcode: 'BAR-001', + purchasePrice: '90.00', + costPrice: '76.20', + }]), + close: jest.fn().mockResolvedValue(undefined), + } + const service = new SupplyChainProductArchiveEnrichmentSyncService({ + repository, + supplierRepository, + stockRepository, + warehouseRepository, + priceRepository, + catalogCostRepository: { + listByBarcodes: jest.fn().mockResolvedValue([{ + barcode: 'BAR-001', + costPrice: '72.30', + }]), + }, + configRepository: { + listActive: jest.fn().mockResolvedValue([{ + id: 'warehouse-config-1', + warehouseName: 'WH-1', + warehouseHead: '负责人', + storageType: 'INBOUND', + }]), + }, + } as never) + + await expect(service.syncAll()).resolves.toEqual( + expect.objectContaining({ scanned: 1, changed: 1 }), + ) + + expect(repository.applyPatch).toHaveBeenCalledTimes(6) + expect(repository.applyPatch).toHaveBeenCalledWith( + expect.objectContaining({ + archiveId: 'archive-1', + patch: expect.objectContaining({ + supplierType: '家具厂', + supplierStatus: '合作', + purchaseOwner: '千里眼', + }), + }), + ) + expect(repository.applyPatch).toHaveBeenCalledWith( + expect.objectContaining({ + patch: expect.objectContaining({ + purchasePrice: '90.00', + costPrice: '76.20', + }), + }), + ) + expect(recordProductArchiveFieldChanges).toHaveBeenCalledWith( + expect.objectContaining({ + barcode: 'BAR-001', + source: expect.objectContaining({ sourceObject: 'jst_sku' }), + changes: [expect.objectContaining({ + field: 'purchasePrice', + previousValue: '88.50', + nextValue: '90.00', + })], + }), + ) + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts index 882b8d90..41e4eec2 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts @@ -97,6 +97,26 @@ describe('supply-chain product archive wide-catalog sync', () => { ) }) + it('maps Prisma Decimal measurements returned by the wide catalog query', () => { + const mapped = mapProductCatalogWideArchiveSource({ + ...createSourceRecord(), + sample_weight_kg: new Prisma.Decimal('9.5'), + package_length_cm: new Prisma.Decimal('15'), + package_width_cm: new Prisma.Decimal('72'), + package_height_cm: new Prisma.Decimal('50'), + }) + + expect(mapped?.archive).toEqual( + expect.objectContaining({ + weight: new Prisma.Decimal('9.5'), + length: new Prisma.Decimal('15'), + width: new Prisma.Decimal('72'), + height: new Prisma.Decimal('50'), + volume: new Prisma.Decimal('54000'), + }), + ) + }) + it('upserts eligible rows and removes stale product sources', async () => { const valid = createSourceRecord() const missingBarcode = { diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts index acc74586..807ae492 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts @@ -1,29 +1,3 @@ -jest.mock('@/server/repositories/jst-supplier-profile.repository', () => ({ - jstSupplierProfileRepository: { - listByNames: jest.fn().mockResolvedValue([]), - }, -})) -jest.mock('@/server/repositories/jst-stock-monitor.repository', () => ({ - jstStockMonitorRepository: { - listByKeys: jest.fn().mockResolvedValue([]), - }, -})) -jest.mock('@/server/repositories/jst-warehouse-profile.repository', () => ({ - jstWarehouseProfileRepository: { - listByNames: jest.fn().mockResolvedValue([]), - }, -})) -jest.mock('@/server/repositories/jst-sku-price.repository', () => ({ - jstSkuPriceRepository: { - listByBarcodes: jest.fn().mockResolvedValue([]), - }, -})) -jest.mock('@/server/repositories/product-catalog-cost-price.repository', () => ({ - productCatalogCostPriceRepository: { - listByBarcodes: jest.fn().mockResolvedValue([]), - }, -})) - import { SupplyChainProductArchiveService, } from '../supply-chain-product-archive.service' @@ -86,82 +60,33 @@ describe('supply-chain product archive field mapping', () => { productTag: '', brand: '基本盘', orderType: '订单式', - storageType: '入仓', + storageType: 'INBOUND', isSeasonal: '', replaceCount: '', supplierName: '源沃家具', - purchaseOwner: '宁珂', - minimumOrderQuantity: '1', + supplierType: '家具厂', + supplierStatus: '合作', + purchaseOwner: '千里眼', + settlementMethod: '月结', + boardProvider: '简尚', + penaltyStatus: '都执行', + warehouseType: '成品发货仓-真实库存', + warehouseHead: '负责人', + minimumOrderQuantity: '12', + firstLeadTime: '7', + palletSpec: '40', + dailySales: '35.39', + sales3d: '0', + sales7d: '32.77', + sales15d: '34.72', + purchasePrice: '88.50', + costPrice: '76.20', + costPriceSecondary: '72.30', }, ], }), } - const supplierProfileRepository = { - listByNames: jest.fn().mockResolvedValue([ - { - supplierName: '源沃家具', - supplierType: '家具厂', - supplierStatusCode: '1', - purchaser: '千里眼', - settlementMethod: '月结', - boardProvider: '简尚', - penaltyStatus: '都执行', - }, - ]), - } - const stockMonitorRepository = { - listByKeys: jest.fn().mockResolvedValue([ - { - sku: 'SKU-001', - barcode: 'BAR-001', - minimum: '12', - produceCycle: '7', - trayParameter: '40', - purchaseSale: '35.39', - threeAvgSale: '0', - sevenAvgSale: '32.77', - fifteenAvgSale: '34.72', - }, - ]), - } - const warehouseProfileRepository = { - listByNames: jest.fn().mockResolvedValue([ - { - warehouseName: 'WH-1', - warehouseType: '成品发货仓-真实库存', - }, - ]), - } - const jstSkuPriceRepository = { - listByBarcodes: jest.fn().mockResolvedValue([ - { - barcode: 'BAR-001', - purchasePrice: '88.50', - costPrice: '76.20', - }, - ]), - } - const productCatalogCostPriceRepository = { - listByBarcodes: jest.fn().mockResolvedValue([ - { - barcode: 'BAR-001', - costPrice: '72.30', - }, - ]), - } - const service = new SupplyChainProductArchiveService( - repository as never, - supplierProfileRepository, - stockMonitorRepository, - { - listActive: jest.fn().mockResolvedValue([ - { warehouseName: 'WH-1', warehouseHead: '负责人', storageType: 'INBOUND' }, - ]), - } as never, - warehouseProfileRepository, - jstSkuPriceRepository, - productCatalogCostPriceRepository, - ) + const service = new SupplyChainProductArchiveService(repository as never) const page = await service.listPage({ page: 1, pageSize: 50 }) @@ -204,20 +129,5 @@ describe('supply-chain product archive field mapping', () => { costPriceSecondary: '72.30', }), ) - expect(supplierProfileRepository.listByNames).toHaveBeenCalledWith([ - '源沃家具', - ]) - expect(stockMonitorRepository.listByKeys).toHaveBeenCalledWith([ - { sku: 'SKU-001', barcode: 'BAR-001' }, - ]) - expect(warehouseProfileRepository.listByNames).toHaveBeenCalledWith([ - 'WH-1', - ]) - expect(jstSkuPriceRepository.listByBarcodes).toHaveBeenCalledWith([ - 'BAR-001', - ]) - expect(productCatalogCostPriceRepository.listByBarcodes).toHaveBeenCalledWith([ - 'BAR-001', - ]) }) }) diff --git a/furniture-3d-system/src/server/services/jst-sku-product-archive-full-sync.service.ts b/furniture-3d-system/src/server/services/jst-sku-product-archive-full-sync.service.ts index f25bd80e..f407e537 100644 --- a/furniture-3d-system/src/server/services/jst-sku-product-archive-full-sync.service.ts +++ b/furniture-3d-system/src/server/services/jst-sku-product-archive-full-sync.service.ts @@ -12,7 +12,10 @@ import { supplyChainProductArchiveCategoryConfigRepository, type SupplyChainProductArchiveCategoryConfigRepository, } from '@/server/repositories/supply-chain-product-archive-category-config.repository' -import { mapJstSkuProductArchiveSource } from './jst-sku-product-archive-sync.service' +import { + mapJstSkuProductArchiveSource, + recordJstSkuProductArchiveSync, +} from './jst-sku-product-archive-sync.service' export type JstSkuProductArchiveFullSyncProgress = { batch: number @@ -150,6 +153,7 @@ export class JstSkuProductArchiveFullSyncService { await forEachConcurrent(mapped, concurrency, async (record) => { const upserted = await this.archiveRepository.upsert(record) result[upserted.status] += 1 + await recordJstSkuProductArchiveSync(record, upserted) }) } await input.onBatch?.({ diff --git a/furniture-3d-system/src/server/services/jst-sku-product-archive-sync.service.ts b/furniture-3d-system/src/server/services/jst-sku-product-archive-sync.service.ts index 31fe0165..fe918d39 100644 --- a/furniture-3d-system/src/server/services/jst-sku-product-archive-sync.service.ts +++ b/furniture-3d-system/src/server/services/jst-sku-product-archive-sync.service.ts @@ -23,6 +23,10 @@ import { type ProductArchiveCategoryConfigRecord, type SupplyChainProductArchiveCategoryConfigRepository, } from '@/server/repositories/supply-chain-product-archive-category-config.repository' +import { + recordProductArchiveFieldChanges, + recordProductArchiveImported, +} from './supply-chain-product-archive-change-log.service' const SOURCE_SYSTEM = '聚水潭 JSReportReplica' const SOURCE_SCHEMA = 'rpt' @@ -142,14 +146,6 @@ export function mapJstSkuProductArchiveSource( } addTextField(patch, fieldColumns, 'spu', text(record.i_id).toUpperCase(), 'i_id') addTextField(patch, fieldColumns, 'sku', text(record.other_7).toUpperCase(), 'other_7') - const largeImage = text(record.pic_big) - addTextField( - patch, - fieldColumns, - 'image', - largeImage || record.pic, - largeImage ? 'pic_big' : 'pic', - ) addTextField(patch, fieldColumns, 'productName', record.name, 'name') addTextField(patch, fieldColumns, 'productTag', record.labels, 'labels') addTextField(patch, fieldColumns, 'brand', record.brand, 'brand') @@ -220,6 +216,7 @@ export function mapJstSkuProductArchiveSource( return { barcode, businessCreatedAt, + sourceUpdatedAt, patch, fieldSourceMap, source: { @@ -234,6 +231,34 @@ export function mapJstSkuProductArchiveSource( } } +export async function recordJstSkuProductArchiveSync( + record: JstSkuProductArchiveInput, + result: Awaited>, +) { + const actor = { + source: 'SYSTEM' as const, + actorName: `${record.source.sourceSystem} / ${record.source.sourceSchema} / ${record.source.sourceObject}`, + } + const source = { + sourceSystem: record.source.sourceSystem, + sourceSchema: record.source.sourceSchema, + sourceObject: record.source.sourceObject, + sourceRecordedAt: record.sourceUpdatedAt, + } + if (result.status === 'created') { + await recordProductArchiveImported({ barcode: record.barcode, actor, source }) + return + } + if (result.changes.length > 0) { + await recordProductArchiveFieldChanges({ + barcode: record.barcode, + actor, + source, + changes: result.changes, + }) + } +} + export class JstSkuProductArchiveSyncService { constructor( private readonly sourceRepository: JstSkuProductArchiveSourceRepository = @@ -299,6 +324,7 @@ export class JstSkuProductArchiveSyncService { for (const record of mapped) { const upserted = await this.archiveRepository.upsert(record) result[upserted.status] += 1 + await recordJstSkuProductArchiveSync(record, upserted) } return result } finally { diff --git a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts index c370fd94..831ffbd8 100644 --- a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts +++ b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts @@ -9,6 +9,10 @@ import { type MaterialLibraryConnection, type ProductArchiveSkuImageIndex, } from './product-archive-material-sku-index.service' +import { + supplyChainProductArchiveImageCacheRepository, + type SupplyChainProductArchiveImageCacheRepository, +} from '@/server/repositories/supply-chain-product-archive-image-cache.repository' type MaterialLibraryConfig = { enabled: boolean @@ -33,6 +37,7 @@ const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init) const SUCCESS_CACHE_MS = 60 * 60 * 1000 const MISS_CACHE_MS = 5 * 60 * 1000 +const PERSISTED_MISS_RETRY_MS = 24 * 60 * 60 * 1000 const REQUEST_TIMEOUT_MS = 120_000 const BATCH_CONCURRENCY = 4 const WHITE_BACKGROUND_VIEW_PRIORITY = ['leftFront45', 'rightFront45', 'front'] @@ -60,6 +65,14 @@ function groupBatchItems(items: ProductArchiveMaterialImageBatchRequestItem[]) { return [...groups.values()].flat() } +function normalizedIdentity(value: string | null | undefined) { + return normalizeLookup(value) +} + +function persistedMissIsFresh(checkedAt: Date, now: number) { + return checkedAt.getTime() + PERSISTED_MISS_RETRY_MS > now +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -153,11 +166,21 @@ export class ProductArchiveMaterialImageService { private readonly fetchImpl: FetchLike = defaultFetch, private readonly configProvider: () => MaterialLibraryConfig = readConfig, private readonly now: () => number = Date.now, + private readonly imageCacheRepository: SupplyChainProductArchiveImageCacheRepository = + supplyChainProductArchiveImageCacheRepository, ) { this.skuIndexService = new ProductArchiveMaterialSkuIndexService(fetchImpl, now) } - async resolve(params: { spu?: string; barcode?: string; sku?: string }) { + async resolve( + params: { + archiveId?: string + spu?: string + barcode?: string + sku?: string + }, + persistedOverride?: Awaited>, + ) { const spu = trimToNull(params.spu) const barcode = trimToNull(params.barcode) const sku = trimToNull(params.sku) @@ -167,6 +190,46 @@ export class ProductArchiveMaterialImageService { const cached = this.cache.get(cacheKey) if (cached && cached.expiresAt > this.now()) return cached.value + const persisted = persistedOverride === undefined + ? await this.imageCacheRepository.findByIdentifiers({ + archiveId: params.archiveId, + spu: spu ?? undefined, + barcode: barcode ?? undefined, + sku: sku ?? undefined, + }).catch(() => null) + : persistedOverride + const archiveIdentity = params.archiveId || persisted?.archiveId + ? null + : await this.imageCacheRepository.findArchiveIdentityByIdentifiers({ + spu: spu ?? undefined, + barcode: barcode ?? undefined, + sku: sku ?? undefined, + }).catch(() => null) + const persistedMatches = !persisted || ( + (!params.archiveId || persisted.archiveId === params.archiveId) && + (!barcode || normalizedIdentity(persisted.barcode) === normalizedIdentity(barcode)) && + (!spu || normalizedIdentity(persisted.spu) === normalizedIdentity(spu)) && + (!sku || normalizedIdentity(persisted.sku) === normalizedIdentity(sku)) + ) + if (persistedMatches && persisted?.status === 'RESOLVED' && persisted.previewUrl) { + const image = { + previewUrl: persisted.previewUrl, + source: persisted.source === 'barcode' ? 'barcode' : 'sku', + ...(persisted.viewKey ? { viewKey: persisted.viewKey } : {}), + ...(persisted.viewLabel ? { viewLabel: persisted.viewLabel } : {}), + } satisfies ProductArchiveMaterialImage + this.cache.set(cacheKey, { value: image, expiresAt: Number.POSITIVE_INFINITY }) + return image + } + if ( + persistedMatches && + persisted?.status === 'NOT_FOUND' && + persistedMissIsFresh(persisted.checkedAt, this.now()) + ) { + this.cache.set(cacheKey, { value: null, expiresAt: Number.POSITIVE_INFINITY }) + return null + } + const existing = this.inFlight.get(cacheKey) if (existing) return existing @@ -176,8 +239,34 @@ export class ProductArchiveMaterialImageService { value, expiresAt: this.now() + (value ? SUCCESS_CACHE_MS : MISS_CACHE_MS), }) + const archiveId = params.archiveId ?? persisted?.archiveId ?? archiveIdentity?.id + if (archiveId) { + const input = { + archiveId, + barcode: archiveIdentity?.barcode ?? barcode ?? '', + spu: archiveIdentity?.spu ?? spu ?? '', + sku: archiveIdentity?.sku ?? sku ?? '', + } + return (value + ? this.imageCacheRepository.saveResolved({ ...input, image: value }) + : this.imageCacheRepository.saveNotFound(input) + ).catch(() => undefined).then(() => value) + } return value }) + .catch(async (error) => { + const archiveId = params.archiveId ?? persisted?.archiveId ?? archiveIdentity?.id + if (archiveId) { + await this.imageCacheRepository.saveError({ + archiveId, + barcode: archiveIdentity?.barcode ?? barcode ?? '', + spu: archiveIdentity?.spu ?? spu ?? '', + sku: archiveIdentity?.sku ?? sku ?? '', + error: error instanceof Error ? error.message : String(error), + }).catch(() => undefined) + } + throw error + }) .finally(() => this.inFlight.delete(cacheKey)) this.inFlight.set(cacheKey, request) @@ -190,6 +279,9 @@ export class ProductArchiveMaterialImageService { ): Promise { const orderedItems = groupBatchItems(items) const results = new Map() + const persistedByArchiveId = await this.imageCacheRepository + .findByArchiveIds(orderedItems.map((item) => item.archiveId)) + .catch(() => new Map()) let nextIndex = 0 const worker = async () => { @@ -198,7 +290,7 @@ export class ProductArchiveMaterialImageService { nextIndex += 1 try { - const image = await this.resolve(item) + const image = await this.resolve(item, persistedByArchiveId.get(item.archiveId) ?? null) results.set(item.archiveId, { archiveId: item.archiveId, status: image ? 'resolved' : 'not-found', diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts new file mode 100644 index 00000000..d0c0513a --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts @@ -0,0 +1,219 @@ +import { + JST_SKU_PRICE_ARCHIVE_SOURCE_CODE, + JST_STOCK_MONITOR_ARCHIVE_SOURCE_CODE, + JST_SUPPLIER_ARCHIVE_SOURCE_CODE, + JST_WAREHOUSE_ARCHIVE_SOURCE_CODE, + PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE, + WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE, +} from '@/lib/supply-chain-product-archive-field-source' +import type { JstSkuPriceProfile } from '@/server/repositories/jst-sku-price.repository' +import type { JstStockMonitorProfile } from '@/server/repositories/jst-stock-monitor.repository' +import type { JstSupplierProfile } from '@/server/repositories/jst-supplier-profile.repository' +import type { JstWarehouseProfile } from '@/server/repositories/jst-warehouse-profile.repository' +import type { ProductCatalogCostPriceProfile } from '@/server/repositories/product-catalog-cost-price.repository' +import type { + ProductArchiveEnrichmentPatch, + ProductArchiveEnrichmentSource, + ProductArchiveEnrichmentTarget, +} from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository' +import type { ProductArchiveWarehouseConfigRecord } from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository' + +const JST_SOURCE_SYSTEM = '聚水潭 JSReportReplica' +const JST_SOURCE_SCHEMA = 'rpt' +const PRODUCT_DEVELOPMENT_SOURCE_SYSTEM = '家具产品开发系统' +const EXTERNAL_SOURCE_SCHEMA = 'external' +const CONFIG_SOURCE_SYSTEM = '供应链中心' +const PUBLIC_SOURCE_SCHEMA = 'public' + +export type ProductArchiveEnrichmentUpdate = { + patch: ProductArchiveEnrichmentPatch + source: ProductArchiveEnrichmentSource +} + +export type ProductArchiveEnrichmentLookups = { + suppliers: readonly JstSupplierProfile[] + stocks: readonly JstStockMonitorProfile[] + warehouses: readonly JstWarehouseProfile[] + prices: readonly JstSkuPriceProfile[] + catalogCosts: readonly ProductCatalogCostPriceProfile[] + warehouseConfigs: readonly ProductArchiveWarehouseConfigRecord[] +} + +function normalize(value: string | null | undefined) { + return value?.trim().toLocaleLowerCase('zh-CN') ?? '' +} + +function normalizeIdentity(value: string | null | undefined) { + return value?.trim().toLocaleUpperCase('zh-CN') ?? '' +} + +function supplierStatusLabel(code: string) { + if (code === '1') return '合作' + if (code === '2') return '不合作' + return '' +} + +function findStockProfile( + target: ProductArchiveEnrichmentTarget, + profiles: readonly JstStockMonitorProfile[], +) { + const barcode = normalizeIdentity(target.barcode) + const exact = profiles.find( + (profile) => normalizeIdentity(profile.barcode) === barcode, + ) + if (exact) return exact + + const sku = normalizeIdentity(target.sku) + if (!sku) return undefined + const candidates = profiles.filter( + (profile) => normalizeIdentity(profile.sku) === sku, + ) + return candidates.length === 1 ? candidates[0] : undefined +} + +function source( + sourceCode: string, + sourceSystem: string, + sourceSchema: string, + sourceObject: string, + sourceRecordId: string, + sourceColumns: ProductArchiveEnrichmentSource['sourceColumns'], +): ProductArchiveEnrichmentSource { + return { + sourceCode, + sourceSystem, + sourceSchema, + sourceObject, + sourceRecordId, + sourceColumns, + } +} + +export function buildProductArchiveEnrichmentUpdates( + target: ProductArchiveEnrichmentTarget, + lookups: ProductArchiveEnrichmentLookups, +): ProductArchiveEnrichmentUpdate[] { + const supplierProfile = lookups.suppliers.find( + (profile) => normalize(profile.supplierName) === normalize(target.supplierName), + ) + const stockProfile = findStockProfile(target, lookups.stocks) + const warehouseProfile = lookups.warehouses.find( + (profile) => normalize(profile.warehouseName) === normalize(target.inOutWarehouse), + ) + const warehouseConfig = lookups.warehouseConfigs.find( + (config) => normalize(config.warehouseName) === normalize(target.inOutWarehouse), + ) + const price = lookups.prices.find( + (profile) => normalizeIdentity(profile.barcode) === normalizeIdentity(target.barcode), + ) + const catalogCost = lookups.catalogCosts.find( + (profile) => normalizeIdentity(profile.barcode) === normalizeIdentity(target.barcode), + ) + + return [ + { + patch: { + supplierType: supplierProfile?.supplierType ?? '', + supplierStatus: supplierStatusLabel(supplierProfile?.supplierStatusCode ?? ''), + ...(supplierProfile?.purchaser ? { purchaseOwner: supplierProfile.purchaser } : {}), + settlementMethod: supplierProfile?.settlementMethod ?? '', + boardProvider: supplierProfile?.boardProvider ?? '', + penaltyStatus: supplierProfile?.penaltyStatus ?? '', + }, + source: source( + JST_SUPPLIER_ARCHIVE_SOURCE_CODE, + JST_SOURCE_SYSTEM, + JST_SOURCE_SCHEMA, + 'js_supplier', + supplierProfile?.supplierName || target.supplierName || target.barcode, + { + supplierType: 'scmtype', + supplierStatus: 'coopersgn', + purchaseOwner: 'buyer', + settlementMethod: 'paycyclesgn', + boardProvider: 'undefined2', + penaltyStatus: 'undefined1', + }, + ), + }, + { + patch: { + ...(stockProfile?.minimum + ? { minimumOrderQuantity: stockProfile.minimum } + : {}), + firstLeadTime: stockProfile?.produceCycle ?? '', + palletSpec: stockProfile?.trayParameter ?? '', + dailySales: stockProfile?.purchaseSale ?? '', + sales3d: stockProfile?.threeAvgSale ?? '', + sales7d: stockProfile?.sevenAvgSale ?? '', + sales15d: stockProfile?.fifteenAvgSale ?? '', + }, + source: source( + JST_STOCK_MONITOR_ARCHIVE_SOURCE_CODE, + JST_SOURCE_SYSTEM, + JST_SOURCE_SCHEMA, + 'pm_stockMonitor', + stockProfile?.barcode || target.barcode, + { + minimumOrderQuantity: 'minimum', + firstLeadTime: 'produceCycle', + palletSpec: 'trayParameter', + dailySales: 'purchaseSale', + sales3d: 'threeAvgSale', + sales7d: 'sevenAvgSale', + sales15d: 'fifteenAvgSale', + }, + ), + }, + { + patch: { warehouseType: warehouseProfile?.warehouseType ?? '' }, + source: source( + JST_WAREHOUSE_ARCHIVE_SOURCE_CODE, + JST_SOURCE_SYSTEM, + JST_SOURCE_SCHEMA, + 'jst_warehouseInfo', + warehouseProfile?.warehouseName || target.inOutWarehouse || target.barcode, + { warehouseType: 'remark2' }, + ), + }, + { + patch: { + warehouseHead: warehouseConfig?.warehouseHead ?? '', + storageType: warehouseConfig?.storageType ?? '', + }, + source: source( + WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE, + CONFIG_SOURCE_SYSTEM, + PUBLIC_SOURCE_SCHEMA, + 'SupplyChainProductArchiveWarehouseConfig', + warehouseConfig?.id || target.inOutWarehouse || target.barcode, + { warehouseHead: 'warehouseHead', storageType: 'storageType' }, + ), + }, + { + patch: { + purchasePrice: price?.purchasePrice ?? '', + costPrice: price?.costPrice ?? '', + }, + source: source( + JST_SKU_PRICE_ARCHIVE_SOURCE_CODE, + JST_SOURCE_SYSTEM, + JST_SOURCE_SCHEMA, + 'jst_sku', + price?.barcode || target.barcode, + { purchasePrice: 'purchase_price', costPrice: 'cost_price' }, + ), + }, + { + patch: { costPriceSecondary: catalogCost?.costPrice ?? '' }, + source: source( + PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE, + PRODUCT_DEVELOPMENT_SOURCE_SYSTEM, + EXTERNAL_SOURCE_SCHEMA, + 'product_catalog_wide_v1', + catalogCost?.barcode || target.barcode, + { costPriceSecondary: 'cost_price' }, + ), + }, + ] +} diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts new file mode 100644 index 00000000..eff405a3 --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts @@ -0,0 +1,161 @@ +import { + jstSkuPriceRepository, + type JstSkuPriceRepository, +} from '@/server/repositories/jst-sku-price.repository' +import { + jstStockMonitorRepository, + type JstStockMonitorRepository, +} from '@/server/repositories/jst-stock-monitor.repository' +import { + jstSupplierProfileRepository, + type JstSupplierProfileRepository, +} from '@/server/repositories/jst-supplier-profile.repository' +import { + jstWarehouseProfileRepository, + type JstWarehouseProfileRepository, +} from '@/server/repositories/jst-warehouse-profile.repository' +import { + productCatalogCostPriceRepository, + type ProductCatalogCostPriceRepository, +} from '@/server/repositories/product-catalog-cost-price.repository' +import { supplyChainProductArchiveWarehouseConfigRepository } from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository' +import { + supplyChainProductArchiveEnrichmentSyncRepository, + type SupplyChainProductArchiveEnrichmentSyncRepository, +} from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository' +import { recordProductArchiveFieldChanges } from './supply-chain-product-archive-change-log.service' +import { buildProductArchiveEnrichmentUpdates } from './supply-chain-product-archive-enrichment-sync.mapper' + +export type SupplyChainProductArchiveEnrichmentSyncResult = { + batches: number + scanned: number + changed: number + unchanged: number + failed: number + lastId: string +} + +type EnrichmentDependencies = { + repository: SupplyChainProductArchiveEnrichmentSyncRepository + supplierRepository: Pick + stockRepository: Pick + warehouseRepository: Pick + priceRepository: Pick + catalogCostRepository: Pick + configRepository: Pick< + typeof supplyChainProductArchiveWarehouseConfigRepository, + 'listActive' + > +} + +function unique(values: readonly T[]) { + return Array.from(new Set(values)) +} + +export class SupplyChainProductArchiveEnrichmentSyncService { + constructor( + private readonly dependencies: EnrichmentDependencies = { + repository: supplyChainProductArchiveEnrichmentSyncRepository, + supplierRepository: jstSupplierProfileRepository, + stockRepository: jstStockMonitorRepository, + warehouseRepository: jstWarehouseProfileRepository, + priceRepository: jstSkuPriceRepository, + catalogCostRepository: productCatalogCostPriceRepository, + configRepository: supplyChainProductArchiveWarehouseConfigRepository, + }, + ) {} + + async syncAll(input: { batchSize?: number } = {}) { + const batchSize = Math.min(200, Math.max(1, Math.trunc(input.batchSize ?? 200))) + const result: SupplyChainProductArchiveEnrichmentSyncResult = { + batches: 0, + scanned: 0, + changed: 0, + unchanged: 0, + failed: 0, + lastId: '', + } + const warehouseConfigs = await this.dependencies.configRepository.listActive() + try { + while (true) { + const targets = await this.dependencies.repository.listTargets({ + afterId: result.lastId || undefined, + limit: batchSize, + }) + if (targets.length === 0) break + + const [suppliers, stocks, warehouses, prices, catalogCosts] = await Promise.all([ + this.dependencies.supplierRepository.listByNames( + unique(targets.map((target) => target.supplierName)), + ), + this.dependencies.stockRepository.listByKeys( + targets.map((target) => ({ sku: target.sku, barcode: target.barcode })), + ), + this.dependencies.warehouseRepository.listByNames( + unique(targets.map((target) => target.inOutWarehouse)), + ), + this.dependencies.priceRepository.listByBarcodes( + targets.map((target) => target.barcode), + ), + this.dependencies.catalogCostRepository.listByBarcodes( + targets.map((target) => target.barcode), + ), + ]) + + for (const target of targets) { + const updates = buildProductArchiveEnrichmentUpdates(target, { + suppliers, + stocks, + warehouses, + prices, + catalogCosts, + warehouseConfigs, + }) + + for (const update of updates) { + const applied = await this.dependencies.repository.applyPatch({ + archiveId: target.id, + patch: update.patch, + source: update.source, + }) + if (applied.changes.length === 0) { + result.unchanged += 1 + continue + } + result.changed += applied.changes.length + if (applied.sourceInitialized) continue + await recordProductArchiveFieldChanges({ + barcode: target.barcode, + actor: { + source: 'SYSTEM', + actorName: `${update.source.sourceSystem} / ${update.source.sourceSchema} / ${update.source.sourceObject}`, + }, + source: { + sourceSystem: update.source.sourceSystem, + sourceSchema: update.source.sourceSchema, + sourceObject: update.source.sourceObject, + }, + changes: applied.changes, + }) + } + } + + result.batches += 1 + result.scanned += targets.length + result.lastId = targets[targets.length - 1]?.id ?? result.lastId + if (targets.length < batchSize) break + } + return result + } finally { + await Promise.all([ + this.dependencies.supplierRepository.close(), + this.dependencies.stockRepository.close(), + this.dependencies.warehouseRepository.close(), + this.dependencies.priceRepository.close(), + ]) + } + } +} + +export const supplyChainProductArchiveEnrichmentSyncService = + new SupplyChainProductArchiveEnrichmentSyncService() diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-sync.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-sync.service.ts index a558458b..6f6fb533 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-sync.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-sync.service.ts @@ -54,8 +54,13 @@ function text(value: unknown) { return '' } +function numericText(value: unknown) { + if (value instanceof Prisma.Decimal) return value.toString() + return text(value) +} + function decimal(value: unknown) { - const normalized = text(value) + const normalized = numericText(value) if (!normalized) return null const number = Number(normalized.replace(/,/g, '')) return Number.isFinite(number) ? new Prisma.Decimal(number) : null @@ -110,9 +115,9 @@ export function mapProductCatalogWideArchiveSource( return null } - const length = text(record.package_length_cm) - const width = text(record.package_width_cm) - const height = text(record.package_height_cm) + const length = numericText(record.package_length_cm) + const width = numericText(record.package_width_cm) + const height = numericText(record.package_height_cm) const fieldSourceMap = createProductArchiveFieldSourceMap({ fields: PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS, sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE, @@ -129,7 +134,6 @@ export function mapProductCatalogWideArchiveSource( barcode, spu: text(record.archive_spu), sku: text(record.archive_sku), - image: text(record.image_url) || text(record.archive_image) || null, businessCreatedAt, categoryL1: text(record.sales_category_name), categoryL2: categoryL2(text(record.archive_product_category_name)), diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts index a87e1ca5..3970ef33 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts @@ -1,9 +1,5 @@ import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive' import { storageTypeLabel } from '@/lib/supply-chain-product-archive-values' -import type { JstSkuPriceProfile } from '@/server/repositories/jst-sku-price.repository' -import type { JstStockMonitorProfile } from '@/server/repositories/jst-stock-monitor.repository' -import type { JstSupplierProfile } from '@/server/repositories/jst-supplier-profile.repository' -import type { ProductCatalogCostPriceProfile } from '@/server/repositories/product-catalog-cost-price.repository' import type { ProductArchiveRecord } from '@/server/repositories/supply-chain-product-archive.repository' const DAY_IN_MS = 24 * 60 * 60 * 1000 @@ -28,40 +24,13 @@ export function normalizeArchiveIdentity(value: string) { return value.trim().toLocaleUpperCase('zh-CN') } -function supplierStatusLabel(code: string | undefined) { - if (code === '1') return '合作' - if (code === '2') return '不合作' - return '' -} - export function mapProductArchiveRow({ record, now, - supplierProfile, - stockProfile, - jstSkuPrice, - productCatalogCostPrice, - warehouseTypes, - warehouseStorageTypes, - warehouseHeads, }: { record: ProductArchiveRecord now: Date - supplierProfile?: JstSupplierProfile - stockProfile?: JstStockMonitorProfile - jstSkuPrice?: JstSkuPriceProfile - productCatalogCostPrice?: ProductCatalogCostPriceProfile - warehouseTypes?: Map - warehouseStorageTypes?: Map - warehouseHeads?: Map }) { - const exactBarcodeStockProfile = - stockProfile && - normalizeArchiveIdentity(stockProfile.barcode) === - normalizeArchiveIdentity(record.barcode) - ? stockProfile - : undefined - return { id: record.id, image: record.image?.trim() ?? '', @@ -92,31 +61,27 @@ export function mapProductArchiveRow({ brand: record.brand, orderType: record.orderType, inOutWarehouse: record.inOutWarehouse, - warehouseType: - warehouseTypes?.get(normalizeWarehouseName(record.inOutWarehouse)) ?? '', - warehouseHead: - warehouseHeads?.get(normalizeWarehouseName(record.inOutWarehouse)) ?? '', - storageType: storageTypeLabel( - warehouseStorageTypes?.get(normalizeWarehouseName(record.inOutWarehouse)), - ), + warehouseType: record.warehouseType, + warehouseHead: record.warehouseHead, + storageType: storageTypeLabel(record.storageType), isSeasonal: record.isSeasonal, replaceCount: record.replaceCount, supplier: record.supplierName, - supplierType: supplierProfile?.supplierType ?? '', - supplierStatus: supplierStatusLabel(supplierProfile?.supplierStatusCode), - purchaser: supplierProfile?.purchaser || record.purchaseOwner, - settleType: supplierProfile?.settlementMethod ?? '', - boardProvider: supplierProfile?.boardProvider ?? '', - penaltyStatus: supplierProfile?.penaltyStatus ?? '', - moq: stockProfile?.minimum || record.minimumOrderQuantity, - firstLeadTime: stockProfile?.produceCycle ?? '', - palletSpec: stockProfile?.trayParameter ?? '', - dailySales: exactBarcodeStockProfile?.purchaseSale ?? '', - sales3d: exactBarcodeStockProfile?.threeAvgSale ?? '', - sales7d: exactBarcodeStockProfile?.sevenAvgSale ?? '', - sales15d: exactBarcodeStockProfile?.fifteenAvgSale ?? '', - purchasePrice: jstSkuPrice?.purchasePrice ?? '', - costPrice: jstSkuPrice?.costPrice ?? '', - costPriceSecondary: productCatalogCostPrice?.costPrice ?? '', + supplierType: record.supplierType, + supplierStatus: record.supplierStatus, + purchaser: record.purchaseOwner, + settleType: record.settlementMethod, + boardProvider: record.boardProvider, + penaltyStatus: record.penaltyStatus, + moq: record.minimumOrderQuantity, + firstLeadTime: record.firstLeadTime, + palletSpec: record.palletSpec, + dailySales: record.dailySales, + sales3d: record.sales3d, + sales7d: record.sales7d, + sales15d: record.sales15d, + purchasePrice: record.purchasePrice, + costPrice: record.costPrice, + costPriceSecondary: record.costPriceSecondary, } satisfies SupplyChainProductArchiveRow } diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts index 88a5fe37..4135cad4 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts @@ -8,69 +8,12 @@ import { supplyChainProductArchiveRepository, type SupplyChainProductArchiveRepository, } from '@/server/repositories/supply-chain-product-archive.repository' -import { - jstSupplierProfileRepository, - type JstSupplierProfile, - type JstSupplierProfileRepository, -} from '@/server/repositories/jst-supplier-profile.repository' -import { - jstStockMonitorRepository, - type JstStockMonitorProfile, - type JstStockMonitorRepository, -} from '@/server/repositories/jst-stock-monitor.repository' -import { - jstWarehouseProfileRepository, - type JstWarehouseProfileRepository, -} from '@/server/repositories/jst-warehouse-profile.repository' -import { - jstSkuPriceRepository, - type JstSkuPriceProfile, - type JstSkuPriceRepository, -} from '@/server/repositories/jst-sku-price.repository' -import { - productCatalogCostPriceRepository, - type ProductCatalogCostPriceProfile, - type ProductCatalogCostPriceRepository, -} from '@/server/repositories/product-catalog-cost-price.repository' -import { - supplyChainProductArchiveWarehouseConfigRepository, - type SupplyChainProductArchiveWarehouseConfigRepository, -} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository' -import { - mapProductArchiveRow, - normalizeArchiveIdentity, - normalizeSupplierName, - normalizeWarehouseName, -} from './supply-chain-product-archive.mapper' +import { mapProductArchiveRow } from './supply-chain-product-archive.mapper' export class SupplyChainProductArchiveService { constructor( private readonly repository: SupplyChainProductArchiveRepository = supplyChainProductArchiveRepository, - private readonly supplierProfileRepository: Pick< - JstSupplierProfileRepository, - 'listByNames' - > = jstSupplierProfileRepository, - private readonly stockMonitorRepository: Pick< - JstStockMonitorRepository, - 'listByKeys' - > = jstStockMonitorRepository, - private readonly warehouseConfigRepository: Pick< - SupplyChainProductArchiveWarehouseConfigRepository, - 'listActive' - > = supplyChainProductArchiveWarehouseConfigRepository, - private readonly warehouseProfileRepository: Pick< - JstWarehouseProfileRepository, - 'listByNames' - > = jstWarehouseProfileRepository, - private readonly skuPriceRepository: Pick< - JstSkuPriceRepository, - 'listByBarcodes' - > = jstSkuPriceRepository, - private readonly catalogCostPriceRepository: Pick< - ProductCatalogCostPriceRepository, - 'listByBarcodes' - > = productCatalogCostPriceRepository, ) {} async listPage(params: { @@ -86,113 +29,8 @@ export class SupplyChainProductArchiveService { filters: params.filters, }) const now = new Date() - const warehouseConfigs = await this.warehouseConfigRepository.listActive() - const warehouseStorageTypes = new Map( - warehouseConfigs.map((item) => [ - normalizeWarehouseName(item.warehouseName), - item.storageType, - ]), - ) - const warehouseHeads = new Map( - warehouseConfigs.map((item) => [ - normalizeWarehouseName(item.warehouseName), - item.warehouseHead.trim(), - ]), - ) - let supplierProfiles = new Map() - - try { - const profiles = await this.supplierProfileRepository.listByNames( - records.map((record) => record.supplierName), - ) - supplierProfiles = new Map( - profiles.map((profile) => [normalizeSupplierName(profile.supplierName), profile]), - ) - } catch { - // Keep the archive usable when the external supplier source is temporarily unavailable. - } - - let stockProfiles: JstStockMonitorProfile[] = [] - try { - stockProfiles = await this.stockMonitorRepository.listByKeys( - records.map((record) => ({ sku: record.sku, barcode: record.barcode })), - ) - } catch { - // Keep the archive usable when the external stock monitor source is temporarily unavailable. - } - let warehouseTypes = new Map() - try { - const profiles = await this.warehouseProfileRepository.listByNames( - records.map((record) => record.inOutWarehouse), - ) - warehouseTypes = new Map( - profiles.map((profile) => [ - normalizeWarehouseName(profile.warehouseName), - profile.warehouseType, - ]), - ) - } catch { - // Keep the archive usable when the external warehouse source is temporarily unavailable. - } - const barcodes = records.map((record) => record.barcode) - const [jstSkuPrices, productCatalogCostPrices] = await Promise.all([ - this.skuPriceRepository - .listByBarcodes(barcodes) - .catch(() => [] as JstSkuPriceProfile[]), - this.catalogCostPriceRepository - .listByBarcodes(barcodes) - .catch(() => [] as ProductCatalogCostPriceProfile[]), - ]) - const jstSkuPriceByBarcode = new Map( - jstSkuPrices.map((profile) => [ - normalizeArchiveIdentity(profile.barcode), - profile, - ]), - ) - const productCatalogCostPriceByBarcode = new Map( - productCatalogCostPrices.map((profile) => [ - normalizeArchiveIdentity(profile.barcode), - profile, - ]), - ) - const stockByBarcode = new Map( - stockProfiles - .filter((profile) => profile.barcode.trim()) - .map((profile) => [normalizeArchiveIdentity(profile.barcode), profile]), - ) - const stockBySku = new Map() - for (const profile of stockProfiles) { - const key = normalizeArchiveIdentity(profile.sku) - if (!key) continue - const candidates = stockBySku.get(key) ?? [] - candidates.push(profile) - stockBySku.set(key, candidates) - } - return { - rows: records.map((record) => - mapProductArchiveRow({ - record, - now, - supplierProfile: supplierProfiles.get( - normalizeSupplierName(record.supplierName), - ), - stockProfile: - stockByBarcode.get(normalizeArchiveIdentity(record.barcode)) ?? - (stockBySku.get(normalizeArchiveIdentity(record.sku))?.length === 1 - ? stockBySku.get(normalizeArchiveIdentity(record.sku))?.[0] - : undefined), - jstSkuPrice: jstSkuPriceByBarcode.get( - normalizeArchiveIdentity(record.barcode), - ), - productCatalogCostPrice: productCatalogCostPriceByBarcode.get( - normalizeArchiveIdentity(record.barcode), - ), - warehouseTypes, - warehouseStorageTypes, - warehouseHeads, - }), - ), + rows: records.map((record) => mapProductArchiveRow({ record, now })), pageInfo: { page, pageSize,