feat(supply-chain): persist archive enrichment and image cache

This commit is contained in:
WuYongJie 2026-08-11 10:23:00 +08:00
parent 814a7ed4be
commit b47f745b83
28 changed files with 1763 additions and 383 deletions

View File

@ -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调用素材库接口并回写链接。

View File

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

View File

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

View File

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

View File

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

View File

@ -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" <<EOF
[Unit]
Description=Sync product archive from JST and supply-chain source tables
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=$APP_ROOT
Environment=NODE_ENV=production
EnvironmentFile=$APP_ENV
EnvironmentFile=$SYNC_ENV
ExecStart=/bin/bash -lc 'set -euo pipefail; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/sync-supply-chain-product-archive-enrichment.ts --apply --sync-jst-base --confirm=sync-supply-chain-product-archive-enrichment --expected-database-identity="\${SUPPLY_CHAIN_ARCHIVE_EXPECTED_DATABASE_IDENTITY:?Set SUPPLY_CHAIN_ARCHIVE_EXPECTED_DATABASE_IDENTITY in $SYNC_ENV}" --expected-source-database-identity="\${SUPPLY_CHAIN_ARCHIVE_EXPECTED_SOURCE_DATABASE_IDENTITY:?Set SUPPLY_CHAIN_ARCHIVE_EXPECTED_SOURCE_DATABASE_IDENTITY in $SYNC_ENV}" --batch-size="\${SUPPLY_CHAIN_ARCHIVE_ENRICHMENT_BATCH_SIZE:-200}" --jst-batch-size="\${SUPPLY_CHAIN_ARCHIVE_JST_BATCH_SIZE:-500}" --jst-concurrency="\${SUPPLY_CHAIN_ARCHIVE_JST_CONCURRENCY:-8}"'
StandardOutput=journal
StandardError=journal
EOF
cat >"$TIMER_FILE" <<EOF
[Unit]
Description=Sync product archive source tables every 15 minutes
[Timer]
OnBootSec=$SYNC_ON_BOOT_SEC
OnUnitActiveSec=$SYNC_INTERVAL
Persistent=true
RandomizedDelaySec=60
Unit=$SERVICE_NAME.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME.timer"
systemctl list-timers --all "$SERVICE_NAME.timer" --no-pager

View File

@ -0,0 +1,118 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
const CONFIRM_TOKEN = 'sync-supply-chain-product-archive-enrichment'
function readArgument(name: string) {
const prefix = `--${name}=`
return process.argv.slice(2).find((argument) => 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
})

View File

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

View File

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

View File

@ -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<string, unknown>
const values: Record<string, unknown> = {
...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 }
})
}
}

View File

@ -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<ProductArchiveEnrichmentField, string>
>
export type ProductArchiveEnrichmentSource = {
sourceCode: string
sourceSystem: string
sourceSchema: string
sourceObject: string
sourceRecordId: string
sourceColumns: Partial<Record<ProductArchiveEnrichmentField, string>>
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<string, unknown>
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<ProductArchiveEnrichmentTarget[]>
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 }

View File

@ -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<Map<string, ProductArchiveImageCacheRecord>>
findByIdentifiers(input: ProductArchiveImageCacheLookup): Promise<ProductArchiveImageCacheRecord | null>
findArchiveIdentityByIdentifiers(input: ProductArchiveImageCacheLookup): Promise<ProductArchiveIdentity | null>
saveResolved(input: {
archiveId: string
barcode: string
spu: string
sku: string
image: ProductArchiveMaterialImage
}): Promise<void>
saveNotFound(input: {
archiveId: string
barcode: string
spu: string
sku: string
}): Promise<void>
saveError(input: {
archiveId: string
barcode: string
spu: string
sku: string
error: string
}): Promise<void>
}
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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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?.({

View File

@ -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<ReturnType<JstSkuProductArchiveRepository['upsert']>>,
) {
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 {

View File

@ -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<string, unknown> {
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<ReturnType<SupplyChainProductArchiveImageCacheRepository['findByIdentifiers']>>,
) {
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<ProductArchiveMaterialImageBatchResult> {
const orderedItems = groupBatchItems(items)
const results = new Map<string, ProductArchiveMaterialImageBatchResultItem>()
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',

View File

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

View File

@ -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<JstSupplierProfileRepository, 'listByNames' | 'close'>
stockRepository: Pick<JstStockMonitorRepository, 'listByKeys' | 'close'>
warehouseRepository: Pick<JstWarehouseProfileRepository, 'listByNames' | 'close'>
priceRepository: Pick<JstSkuPriceRepository, 'listByBarcodes' | 'close'>
catalogCostRepository: Pick<ProductCatalogCostPriceRepository, 'listByBarcodes'>
configRepository: Pick<
typeof supplyChainProductArchiveWarehouseConfigRepository,
'listActive'
>
}
function unique<T>(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()

View File

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

View File

@ -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<string, string>
warehouseStorageTypes?: Map<string, string>
warehouseHeads?: Map<string, string>
}) {
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
}

View File

@ -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<string, JstSupplierProfile>()
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<string, string>()
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<string, JstStockMonitorProfile[]>()
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,