feat(supply-chain): persist archive enrichment and image cache
This commit is contained in:
parent
814a7ed4be
commit
b47f745b83
@ -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调用素材库接口并回写链接。
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 '';
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
})
|
||||
@ -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 =
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
})],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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 = {
|
||||
|
||||
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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?.({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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' },
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
@ -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()
|
||||
@ -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)),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user