feat(supply-chain): persist archive enrichment and image cache
This commit is contained in:
parent
2a5d6ec2ca
commit
98776082b7
@ -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,29 @@
|
||||
| `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` 的新品行;匹配不到或来源为空时留空 |
|
||||
|
||||
## 3. JST_SKU 第一批字段覆盖率
|
||||
|
||||
@ -82,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",
|
||||
"import:yunshan-created-at": "tsx scripts/import-yunshan-product-created-at.ts",
|
||||
"import:product-archive-drawing-media": "tsx scripts/import-product-archive-drawing-media.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;
|
||||
@ -481,11 +481,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
|
||||
|
||||
@ -500,6 +517,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
|
||||
})
|
||||
@ -0,0 +1,47 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { productArchiveMaterialImageService } from '@/server/services/product-archive-material-image.service'
|
||||
|
||||
const batchItemSchema = z.object({
|
||||
archiveId: z.string().trim().min(1).max(120),
|
||||
spu: z.string().trim().max(120).optional(),
|
||||
barcode: z.string().trim().max(120).optional(),
|
||||
sku: z.string().trim().max(120).optional(),
|
||||
}).refine((value) => Boolean(value.barcode || value.sku), {
|
||||
message: '条码和 SKU 至少提供一个',
|
||||
})
|
||||
|
||||
const batchSchema = z.object({
|
||||
items: z.array(batchItemSchema).min(1).max(200),
|
||||
}).strict()
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const body = await request.json().catch(() => null)
|
||||
const parsed = batchSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'当前页图片匹配参数不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await productArchiveMaterialImageService.resolveBatch(
|
||||
parsed.data.items,
|
||||
request.signal,
|
||||
)
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
},
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -2,37 +2,32 @@
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ProductArchiveMaterialImage } from '@/lib/supply-chain-product-archive-material-image'
|
||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||
import type { ApiResponse } from '@/server/core/types'
|
||||
import type { ProductArchivePageImageState } from './useProductArchivePageImages'
|
||||
|
||||
export function ProductArchiveMaterialImageCell({
|
||||
row,
|
||||
imageState,
|
||||
onVisible,
|
||||
onPreviewImage,
|
||||
}: {
|
||||
row: SupplyChainProductArchiveRow
|
||||
imageState: ProductArchivePageImageState
|
||||
onVisible: (archiveId: string) => void
|
||||
onPreviewImage: (image: string, title: string) => void
|
||||
}) {
|
||||
const anchorRef = useRef<HTMLSpanElement>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
const [resolvedImage, setResolvedImage] = useState<ProductArchiveMaterialImage | null>(null)
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
const [sourceImageUnavailable, setSourceImageUnavailable] = useState(false)
|
||||
const [failedMaterialImageUrl, setFailedMaterialImageUrl] = useState('')
|
||||
const [failedSourceImageUrl, setFailedSourceImageUrl] = useState('')
|
||||
|
||||
const lookupUrl = useMemo(() => {
|
||||
if (!row.spu || (!row.barcode && !row.sku)) return ''
|
||||
const params = new URLSearchParams()
|
||||
params.set('spu', row.spu)
|
||||
if (row.barcode) params.set('barcode', row.barcode)
|
||||
if (row.sku) params.set('sku', row.sku)
|
||||
return `/api/supply-chain/product-archive/material-image?${params.toString()}`
|
||||
}, [row.barcode, row.sku, row.spu])
|
||||
const canResolveMaterialImage = Boolean(row.spu && (row.barcode || row.sku))
|
||||
const sourceImageUrl = useMemo(() => {
|
||||
if (!row.image || !row.barcode) return ''
|
||||
const params = new URLSearchParams({ barcode: row.barcode })
|
||||
return `/api/supply-chain/product-archive/source-image?${params.toString()}`
|
||||
}, [row.barcode, row.image])
|
||||
const canResolveImage = Boolean(lookupUrl || sourceImageUrl)
|
||||
const canResolveImage = Boolean(canResolveMaterialImage || sourceImageUrl)
|
||||
|
||||
useEffect(() => {
|
||||
if (!canResolveImage || inView) return
|
||||
@ -56,35 +51,27 @@ export function ProductArchiveMaterialImageCell({
|
||||
}, [canResolveImage, inView])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lookupUrl || !inView || unavailable || resolvedImage) return
|
||||
const controller = new AbortController()
|
||||
if (inView && canResolveMaterialImage) onVisible(row.id)
|
||||
}, [canResolveMaterialImage, inView, onVisible, row.id])
|
||||
|
||||
async function resolveImage() {
|
||||
try {
|
||||
const response = await fetch(lookupUrl, { signal: controller.signal })
|
||||
const payload = (await response.json()) as ApiResponse<ProductArchiveMaterialImage | null>
|
||||
if (!response.ok || !payload.success || !payload.data) {
|
||||
setUnavailable(true)
|
||||
return
|
||||
}
|
||||
setResolvedImage(payload.data)
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setUnavailable(true)
|
||||
}
|
||||
}
|
||||
|
||||
void resolveImage()
|
||||
return () => controller.abort()
|
||||
}, [inView, lookupUrl, resolvedImage, unavailable])
|
||||
|
||||
const useSourceImage = !resolvedImage && (unavailable || !lookupUrl)
|
||||
const imageUrl = resolvedImage?.previewUrl
|
||||
|| (useSourceImage && !sourceImageUnavailable ? sourceImageUrl : '')
|
||||
const resolvedImage = imageState.status === 'resolved'
|
||||
&& imageState.image?.previewUrl !== failedMaterialImageUrl
|
||||
? imageState.image
|
||||
: null
|
||||
const materialLookupSettled = !canResolveMaterialImage
|
||||
|| imageState.status === 'not-found'
|
||||
|| imageState.status === 'error'
|
||||
|| Boolean(failedMaterialImageUrl)
|
||||
const useSourceImage = !resolvedImage && materialLookupSettled
|
||||
const imageUrl = inView
|
||||
? resolvedImage?.previewUrl
|
||||
|| (useSourceImage && sourceImageUrl !== failedSourceImageUrl ? sourceImageUrl : '')
|
||||
: ''
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<span
|
||||
ref={anchorRef}
|
||||
className={`archive-product-image-placeholder${inView && lookupUrl && !unavailable ? ' is-loading' : ''}`}
|
||||
className={`archive-product-image-placeholder${inView && canResolveMaterialImage && !materialLookupSettled ? ' is-loading' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<style jsx>{`
|
||||
@ -125,11 +112,10 @@ export function ProductArchiveMaterialImageCell({
|
||||
className="archive-product-image"
|
||||
onError={() => {
|
||||
if (resolvedImage) {
|
||||
setResolvedImage(null)
|
||||
setUnavailable(true)
|
||||
setFailedMaterialImageUrl(resolvedImage.previewUrl)
|
||||
return
|
||||
}
|
||||
setSourceImageUnavailable(true)
|
||||
setFailedSourceImageUrl(sourceImageUrl)
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -2,6 +2,12 @@ import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-ar
|
||||
import type { ArchiveColumnGroup } from './columns'
|
||||
import { ProductArchiveActions } from './ProductArchiveActions'
|
||||
import { ProductArchiveMaterialImageCell } from './ProductArchiveMaterialImageCell'
|
||||
import type { ProductArchivePageImageState } from './useProductArchivePageImages'
|
||||
|
||||
const IDLE_IMAGE_STATE: ProductArchivePageImageState = {
|
||||
status: 'idle',
|
||||
image: null,
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
if (!value) return ''
|
||||
@ -21,12 +27,16 @@ function formatDateTime(value: string) {
|
||||
function renderCell(
|
||||
row: SupplyChainProductArchiveRow,
|
||||
prop: string,
|
||||
imageStates: Record<string, ProductArchivePageImageState>,
|
||||
onImageVisible: (archiveId: string) => void,
|
||||
onPreviewImage: (image: string, title: string) => void,
|
||||
) {
|
||||
if (prop === 'image') {
|
||||
return (
|
||||
<ProductArchiveMaterialImageCell
|
||||
row={row}
|
||||
imageState={imageStates[row.id] ?? IDLE_IMAGE_STATE}
|
||||
onVisible={onImageVisible}
|
||||
onPreviewImage={onPreviewImage}
|
||||
/>
|
||||
)
|
||||
@ -43,6 +53,8 @@ export function ProductArchiveTableBody({
|
||||
rows,
|
||||
columns,
|
||||
startIndex,
|
||||
imageStates,
|
||||
onImageVisible,
|
||||
onPreviewImage,
|
||||
onViewRow,
|
||||
onOpenChangeLog,
|
||||
@ -50,6 +62,8 @@ export function ProductArchiveTableBody({
|
||||
rows: SupplyChainProductArchiveRow[]
|
||||
columns: ArchiveColumnGroup[]
|
||||
startIndex: number
|
||||
imageStates: Record<string, ProductArchivePageImageState>
|
||||
onImageVisible: (archiveId: string) => void
|
||||
onPreviewImage: (image: string, title: string) => void
|
||||
onViewRow: (row: SupplyChainProductArchiveRow) => void
|
||||
onOpenChangeLog: (barcode: string) => void
|
||||
@ -61,7 +75,13 @@ export function ProductArchiveTableBody({
|
||||
<td className="archive-td-index">{startIndex + rowIndex + 1}</td>
|
||||
{columns.map((group) =>
|
||||
group.columns.map((column) => {
|
||||
const value = renderCell(row, column.prop, onPreviewImage)
|
||||
const value = renderCell(
|
||||
row,
|
||||
column.prop,
|
||||
imageStates,
|
||||
onImageVisible,
|
||||
onPreviewImage,
|
||||
)
|
||||
const title = typeof value === 'string' || typeof value === 'number'
|
||||
? String(value)
|
||||
: undefined
|
||||
|
||||
@ -14,6 +14,7 @@ import { useArchiveColumnResize } from './useArchiveColumnResize'
|
||||
import { parseProductArchiveFilters, useProductArchiveViewPreferences } from './useProductArchiveViewPreferences'
|
||||
import { ProductArchiveDetailDialog } from './ProductArchiveDetailDialog'
|
||||
import { ProductArchiveChangeLogDialog } from './ProductArchiveChangeLogDialog'
|
||||
import { useProductArchivePageImages } from './useProductArchivePageImages'
|
||||
import type {
|
||||
ProductArchiveFilters,
|
||||
SupplyChainProductArchiveRow,
|
||||
@ -72,6 +73,15 @@ export function ProductArchiveView() {
|
||||
() => effectiveColumns.flatMap((group) => group.columns.map((column) => ({ group: group.group, column }))),
|
||||
[effectiveColumns],
|
||||
)
|
||||
const displayRows = useMemo(
|
||||
() => (loading || error ? [] : rows),
|
||||
[error, loading, rows],
|
||||
)
|
||||
const imageColumnVisible = visibleColumns.some(({ column }) => column.prop === 'image')
|
||||
const { states: imageStates, markVisible: markImageVisible } = useProductArchivePageImages(
|
||||
displayRows,
|
||||
imageColumnVisible,
|
||||
)
|
||||
const detailColumns = useMemo(
|
||||
() =>
|
||||
columnSettings.settings.map((group) => ({
|
||||
@ -183,9 +193,11 @@ export function ProductArchiveView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<ProductArchiveTableBody
|
||||
rows={loading || error ? [] : rows}
|
||||
rows={displayRows}
|
||||
columns={effectiveColumns}
|
||||
startIndex={(currentPage - 1) * pageSize}
|
||||
imageStates={imageStates}
|
||||
onImageVisible={markImageVisible}
|
||||
onPreviewImage={(image, title) => setImagePreview({ image, title })}
|
||||
onViewRow={setDetailRow}
|
||||
onOpenChangeLog={setChangeLogBarcode}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||
import { useProductArchivePageImages } from './useProductArchivePageImages'
|
||||
|
||||
function row(id: string, spu: string): SupplyChainProductArchiveRow {
|
||||
return {
|
||||
id,
|
||||
spu,
|
||||
sku: `${spu}-SKU`,
|
||||
barcode: `${spu}-BARCODE`,
|
||||
} as SupplyChainProductArchiveRow
|
||||
}
|
||||
|
||||
function batchResponse(items: Array<{ archiveId: string }>) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map((item) => ({
|
||||
archiveId: item.archiveId,
|
||||
status: 'resolved',
|
||||
image: {
|
||||
previewUrl: `https://image.example.com/${item.archiveId}.webp`,
|
||||
source: 'barcode',
|
||||
},
|
||||
})),
|
||||
},
|
||||
}),
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('product archive page image scheduling', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
jest.useRealTimers()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('waits for page stability and requests visible rows first', async () => {
|
||||
const fetchMock = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body)) as { items: Array<{ archiveId: string }> }
|
||||
return batchResponse(body.items)
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
const rows = [row('archive-1', 'SPU-1'), row('archive-2', 'SPU-2')]
|
||||
const hook = renderHook(() => useProductArchivePageImages(rows, true))
|
||||
|
||||
act(() => hook.result.current.markVisible('archive-2'))
|
||||
act(() => jest.advanceTimersByTime(599))
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled()
|
||||
const firstRequest = JSON.parse(
|
||||
String(jest.mocked(fetchMock).mock.calls[0][1]?.body),
|
||||
) as { items: Array<{ archiveId: string }> }
|
||||
expect(firstRequest.items.map((item) => item.archiveId)).toEqual(['archive-2'])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
ProductArchiveMaterialImage,
|
||||
ProductArchiveMaterialImageBatchItemStatus,
|
||||
ProductArchiveMaterialImageBatchResult,
|
||||
} from '@/lib/supply-chain-product-archive-material-image'
|
||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||
import type { ApiResponse } from '@/server/core/types'
|
||||
|
||||
const PAGE_STABLE_MS = 600
|
||||
const VISIBLE_BATCH_DELAY_MS = 80
|
||||
const BACKGROUND_BATCH_SIZE = 24
|
||||
const FALLBACK_PRIORITY_COUNT = 20
|
||||
const SUCCESS_CACHE_MS = 60 * 60 * 1000
|
||||
const MISS_CACHE_MS = 5 * 60 * 1000
|
||||
|
||||
export type ProductArchivePageImageState = {
|
||||
status: 'idle' | 'loading' | ProductArchiveMaterialImageBatchItemStatus
|
||||
image: ProductArchiveMaterialImage | null
|
||||
}
|
||||
|
||||
type CachedImage = {
|
||||
expiresAt: number
|
||||
state: ProductArchivePageImageState
|
||||
}
|
||||
|
||||
function canResolveMaterialImage(row: SupplyChainProductArchiveRow) {
|
||||
return Boolean(row.spu && (row.barcode || row.sku))
|
||||
}
|
||||
|
||||
function cacheKeyForRow(row: SupplyChainProductArchiveRow) {
|
||||
return [row.spu, row.barcode, row.sku]
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.join('\u0000')
|
||||
}
|
||||
|
||||
function chunkRows(rows: SupplyChainProductArchiveRow[]) {
|
||||
const chunks: SupplyChainProductArchiveRow[][] = []
|
||||
for (let index = 0; index < rows.length; index += BACKGROUND_BATCH_SIZE) {
|
||||
chunks.push(rows.slice(index, index + BACKGROUND_BATCH_SIZE))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
export function useProductArchivePageImages(
|
||||
rows: SupplyChainProductArchiveRow[],
|
||||
enabled: boolean,
|
||||
) {
|
||||
const [states, setStates] = useState<Record<string, ProductArchivePageImageState>>({})
|
||||
const cacheRef = useRef(new Map<string, CachedImage>())
|
||||
const rowsByIdRef = useRef(new Map<string, SupplyChainProductArchiveRow>())
|
||||
const visibleIdsRef = useRef(new Set<string>())
|
||||
const activeIdsRef = useRef(new Set<string>())
|
||||
const settledIdsRef = useRef(new Set<string>())
|
||||
const generationRef = useRef(0)
|
||||
const stableRef = useRef(false)
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
const stableTimerRef = useRef<number | null>(null)
|
||||
const visibleTimerRef = useRef<number | null>(null)
|
||||
|
||||
const requestRows = useCallback(async (
|
||||
candidateRows: SupplyChainProductArchiveRow[],
|
||||
generation: number,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (signal.aborted || generation !== generationRef.current) return
|
||||
|
||||
const cachedStates: Record<string, ProductArchivePageImageState> = {}
|
||||
const rowsToRequest: SupplyChainProductArchiveRow[] = []
|
||||
const now = Date.now()
|
||||
|
||||
for (const row of candidateRows) {
|
||||
if (!canResolveMaterialImage(row) || settledIdsRef.current.has(row.id)) continue
|
||||
if (activeIdsRef.current.has(row.id)) continue
|
||||
|
||||
const key = cacheKeyForRow(row)
|
||||
const cached = cacheRef.current.get(key)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
settledIdsRef.current.add(row.id)
|
||||
cachedStates[row.id] = cached.state
|
||||
continue
|
||||
}
|
||||
if (cached) cacheRef.current.delete(key)
|
||||
|
||||
activeIdsRef.current.add(row.id)
|
||||
rowsToRequest.push(row)
|
||||
}
|
||||
|
||||
if (Object.keys(cachedStates).length > 0) {
|
||||
setStates((current) => ({ ...current, ...cachedStates }))
|
||||
}
|
||||
if (rowsToRequest.length === 0) return
|
||||
|
||||
setStates((current) => {
|
||||
const next = { ...current }
|
||||
for (const row of rowsToRequest) {
|
||||
next[row.id] = { status: 'loading', image: null }
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/supply-chain/product-archive/material-images', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: rowsToRequest.map((row) => ({
|
||||
archiveId: row.id,
|
||||
spu: row.spu || undefined,
|
||||
barcode: row.barcode || undefined,
|
||||
sku: row.sku || undefined,
|
||||
})),
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
const payload = (await response.json()) as ApiResponse<ProductArchiveMaterialImageBatchResult>
|
||||
if (!response.ok || !payload.success || !payload.data) {
|
||||
throw new Error(payload.error?.message || '当前页图片加载失败')
|
||||
}
|
||||
if (signal.aborted || generation !== generationRef.current) return
|
||||
|
||||
const resultById = new Map(payload.data.items.map((item) => [item.archiveId, item]))
|
||||
const nextStates: Record<string, ProductArchivePageImageState> = {}
|
||||
const resolvedAt = Date.now()
|
||||
|
||||
for (const row of rowsToRequest) {
|
||||
const result = resultById.get(row.id)
|
||||
const state: ProductArchivePageImageState = result
|
||||
? { status: result.status, image: result.image }
|
||||
: { status: 'error', image: null }
|
||||
nextStates[row.id] = state
|
||||
settledIdsRef.current.add(row.id)
|
||||
|
||||
if (state.status === 'resolved' || state.status === 'not-found') {
|
||||
cacheRef.current.set(cacheKeyForRow(row), {
|
||||
state,
|
||||
expiresAt: resolvedAt + (state.status === 'resolved' ? SUCCESS_CACHE_MS : MISS_CACHE_MS),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setStates((current) => ({ ...current, ...nextStates }))
|
||||
} catch {
|
||||
if (signal.aborted || generation !== generationRef.current) return
|
||||
const errorStates: Record<string, ProductArchivePageImageState> = {}
|
||||
for (const row of rowsToRequest) {
|
||||
settledIdsRef.current.add(row.id)
|
||||
errorStates[row.id] = { status: 'error', image: null }
|
||||
}
|
||||
setStates((current) => ({ ...current, ...errorStates }))
|
||||
} finally {
|
||||
for (const row of rowsToRequest) activeIdsRef.current.delete(row.id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1
|
||||
const generation = generationRef.current
|
||||
stableRef.current = false
|
||||
visibleIdsRef.current.clear()
|
||||
activeIdsRef.current.clear()
|
||||
settledIdsRef.current.clear()
|
||||
controllerRef.current?.abort()
|
||||
if (stableTimerRef.current !== null) window.clearTimeout(stableTimerRef.current)
|
||||
if (visibleTimerRef.current !== null) window.clearTimeout(visibleTimerRef.current)
|
||||
|
||||
rowsByIdRef.current = new Map(rows.map((row) => [row.id, row]))
|
||||
const initialStates: Record<string, ProductArchivePageImageState> = {}
|
||||
const now = Date.now()
|
||||
for (const row of rows) {
|
||||
if (!canResolveMaterialImage(row)) {
|
||||
settledIdsRef.current.add(row.id)
|
||||
initialStates[row.id] = { status: 'not-found', image: null }
|
||||
continue
|
||||
}
|
||||
const cached = cacheRef.current.get(cacheKeyForRow(row))
|
||||
if (cached && cached.expiresAt > now) {
|
||||
settledIdsRef.current.add(row.id)
|
||||
initialStates[row.id] = cached.state
|
||||
} else {
|
||||
initialStates[row.id] = { status: 'idle', image: null }
|
||||
}
|
||||
}
|
||||
setStates(initialStates)
|
||||
|
||||
if (!enabled || rows.length === 0) return
|
||||
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
stableTimerRef.current = window.setTimeout(() => {
|
||||
stableRef.current = true
|
||||
const visibleRows = rows.filter((row) => visibleIdsRef.current.has(row.id))
|
||||
const priorityRows = visibleRows.length > 0
|
||||
? visibleRows
|
||||
: rows.slice(0, FALLBACK_PRIORITY_COUNT)
|
||||
|
||||
void (async () => {
|
||||
await requestRows(priorityRows, generation, controller.signal)
|
||||
for (const chunk of chunkRows(rows)) {
|
||||
if (controller.signal.aborted || generation !== generationRef.current) return
|
||||
await requestRows(chunk, generation, controller.signal)
|
||||
}
|
||||
})()
|
||||
}, PAGE_STABLE_MS)
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (stableTimerRef.current !== null) window.clearTimeout(stableTimerRef.current)
|
||||
if (visibleTimerRef.current !== null) window.clearTimeout(visibleTimerRef.current)
|
||||
}
|
||||
}, [enabled, requestRows, rows])
|
||||
|
||||
const markVisible = useCallback((archiveId: string) => {
|
||||
visibleIdsRef.current.add(archiveId)
|
||||
if (!stableRef.current) return
|
||||
|
||||
if (visibleTimerRef.current !== null) window.clearTimeout(visibleTimerRef.current)
|
||||
visibleTimerRef.current = window.setTimeout(() => {
|
||||
const controller = controllerRef.current
|
||||
if (!controller || controller.signal.aborted) return
|
||||
const visibleRows = [...visibleIdsRef.current]
|
||||
.map((id) => rowsByIdRef.current.get(id))
|
||||
.filter((row): row is SupplyChainProductArchiveRow => Boolean(row))
|
||||
void requestRows(visibleRows, generationRef.current, controller.signal)
|
||||
}, VISIBLE_BATCH_DELAY_MS)
|
||||
}, [requestRows])
|
||||
|
||||
return { states, markVisible }
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -6,3 +6,25 @@ export type ProductArchiveMaterialImage = {
|
||||
viewKey?: string
|
||||
viewLabel?: string
|
||||
}
|
||||
|
||||
export type ProductArchiveMaterialImageBatchRequestItem = {
|
||||
archiveId: string
|
||||
spu?: string
|
||||
barcode?: string
|
||||
sku?: string
|
||||
}
|
||||
|
||||
export type ProductArchiveMaterialImageBatchItemStatus =
|
||||
| 'resolved'
|
||||
| 'not-found'
|
||||
| 'error'
|
||||
|
||||
export type ProductArchiveMaterialImageBatchResultItem = {
|
||||
archiveId: string
|
||||
status: ProductArchiveMaterialImageBatchItemStatus
|
||||
image: ProductArchiveMaterialImage | null
|
||||
}
|
||||
|
||||
export type ProductArchiveMaterialImageBatchResult = {
|
||||
items: ProductArchiveMaterialImageBatchResultItem[]
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ export type SupplyChainProductArchiveRow = {
|
||||
brand: string
|
||||
orderType: string
|
||||
inOutWarehouse: string
|
||||
warehouseType: string
|
||||
warehouseHead: string
|
||||
storageType: string
|
||||
isSeasonal: string
|
||||
@ -46,6 +47,9 @@ export type SupplyChainProductArchiveRow = {
|
||||
sales3d: string
|
||||
sales7d: string
|
||||
sales15d: string
|
||||
purchasePrice: string
|
||||
costPrice: string
|
||||
costPriceSecondary: string
|
||||
}
|
||||
|
||||
export const PRODUCT_ARCHIVE_SALES_STATUSES = ['销售', '停产', '退市'] as const
|
||||
|
||||
@ -0,0 +1,113 @@
|
||||
import sql from 'mssql'
|
||||
|
||||
export type JstSkuPriceProfile = {
|
||||
barcode: string
|
||||
purchasePrice: string
|
||||
costPrice: string
|
||||
}
|
||||
|
||||
export interface JstSkuPriceRepository {
|
||||
listByBarcodes(barcodes: readonly string[]): Promise<JstSkuPriceProfile[]>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function requiredEnv(name: string) {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
function booleanEnv(name: string, fallback: boolean) {
|
||||
const value = process.env[name]?.trim().toLowerCase()
|
||||
if (!value) return fallback
|
||||
return value === 'true' || value === '1'
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string, label: string) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error(`${label} is not a valid SQL Server identifier`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizedBarcodes(barcodes: readonly string[]) {
|
||||
return Array.from(
|
||||
new Set(barcodes.map((barcode) => barcode.trim().toUpperCase()).filter(Boolean)),
|
||||
).slice(0, 200)
|
||||
}
|
||||
|
||||
class JstSkuPriceRepositoryClass implements JstSkuPriceRepository {
|
||||
private pool: sql.ConnectionPool | null = null
|
||||
|
||||
private async getPool() {
|
||||
if (this.pool?.connected) return this.pool
|
||||
|
||||
this.pool = await new sql.ConnectionPool({
|
||||
server: requiredEnv('JST_REPORT_DB_HOST'),
|
||||
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
|
||||
database: requiredEnv('JST_REPORT_DB_NAME'),
|
||||
user: requiredEnv('JST_REPORT_DB_USER'),
|
||||
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
|
||||
options: {
|
||||
encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true),
|
||||
trustServerCertificate: booleanEnv(
|
||||
'JST_REPORT_DB_TRUST_SERVER_CERTIFICATE',
|
||||
true,
|
||||
),
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 4,
|
||||
idleTimeoutMillis: 30_000,
|
||||
},
|
||||
requestTimeout: 30_000,
|
||||
connectionTimeout: 15_000,
|
||||
}).connect()
|
||||
return this.pool
|
||||
}
|
||||
|
||||
async listByBarcodes(barcodes: readonly string[]) {
|
||||
const normalized = normalizedBarcodes(barcodes)
|
||||
if (normalized.length === 0) return []
|
||||
|
||||
const schema = safeIdentifier(
|
||||
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
|
||||
'JST_REPORT_DB_SCHEMA',
|
||||
)
|
||||
const pool = await this.getPool()
|
||||
const request = pool.request()
|
||||
const parameters = normalized.map((barcode, index) => {
|
||||
const parameter = `barcode${index}`
|
||||
request.input(parameter, sql.VarChar(100), barcode)
|
||||
return `@${parameter}`
|
||||
})
|
||||
|
||||
const result = await request.query<JstSkuPriceProfile>(`
|
||||
WITH ranked_prices AS (
|
||||
SELECT
|
||||
UPPER(LTRIM(RTRIM(CAST(sku_id AS varchar(100))))) AS barcode,
|
||||
LTRIM(RTRIM(COALESCE(CAST(purchase_price AS varchar(100)), ''))) AS purchasePrice,
|
||||
LTRIM(RTRIM(COALESCE(CAST(cost_price AS varchar(100)), ''))) AS costPrice,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY UPPER(LTRIM(RTRIM(CAST(sku_id AS varchar(100)))))
|
||||
ORDER BY local_updated DESC, modified DESC, autoid DESC
|
||||
) AS row_rank
|
||||
FROM [${schema}].[jst_sku]
|
||||
WHERE item_type = N'成品'
|
||||
AND UPPER(LTRIM(RTRIM(CAST(sku_id AS varchar(100))))) IN (${parameters.join(', ')})
|
||||
)
|
||||
SELECT barcode, purchasePrice, costPrice
|
||||
FROM ranked_prices
|
||||
WHERE row_rank = 1
|
||||
`)
|
||||
return result.recordset
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.pool) return
|
||||
await this.pool.close()
|
||||
this.pool = null
|
||||
}
|
||||
}
|
||||
|
||||
export const jstSkuPriceRepository = new JstSkuPriceRepositoryClass()
|
||||
@ -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,96 @@
|
||||
import sql from 'mssql'
|
||||
|
||||
export type JstWarehouseProfile = {
|
||||
warehouseName: string
|
||||
warehouseType: string
|
||||
}
|
||||
|
||||
export interface JstWarehouseProfileRepository {
|
||||
listByNames(names: readonly string[]): Promise<JstWarehouseProfile[]>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function requiredEnv(name: string) {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
function booleanEnv(name: string, fallback: boolean) {
|
||||
const value = process.env[name]?.trim().toLowerCase()
|
||||
if (!value) return fallback
|
||||
return value === 'true' || value === '1'
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string, label: string) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error(`${label} is not a valid SQL Server identifier`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizedNames(names: readonly string[]) {
|
||||
return Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 200)
|
||||
}
|
||||
|
||||
class JstWarehouseProfileRepositoryClass implements JstWarehouseProfileRepository {
|
||||
private pool: sql.ConnectionPool | null = null
|
||||
|
||||
private async getPool() {
|
||||
if (this.pool?.connected) return this.pool
|
||||
|
||||
this.pool = await new sql.ConnectionPool({
|
||||
server: requiredEnv('JST_REPORT_DB_HOST'),
|
||||
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
|
||||
database: requiredEnv('JST_REPORT_DB_NAME'),
|
||||
user: requiredEnv('JST_REPORT_DB_USER'),
|
||||
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
|
||||
options: {
|
||||
encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true),
|
||||
trustServerCertificate: booleanEnv('JST_REPORT_DB_TRUST_SERVER_CERTIFICATE', true),
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 4,
|
||||
idleTimeoutMillis: 30_000,
|
||||
},
|
||||
requestTimeout: 30_000,
|
||||
connectionTimeout: 15_000,
|
||||
}).connect()
|
||||
return this.pool
|
||||
}
|
||||
|
||||
async listByNames(names: readonly string[]) {
|
||||
const warehouseNames = normalizedNames(names)
|
||||
if (warehouseNames.length === 0) return []
|
||||
|
||||
const schema = safeIdentifier(
|
||||
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
|
||||
'JST_REPORT_DB_SCHEMA',
|
||||
)
|
||||
const pool = await this.getPool()
|
||||
const request = pool.request()
|
||||
const parameters = warehouseNames.map((name, index) => {
|
||||
const parameter = `warehouseName${index}`
|
||||
request.input(parameter, sql.NVarChar(255), name)
|
||||
return `@${parameter}`
|
||||
})
|
||||
|
||||
const result = await request.query<JstWarehouseProfile>(`
|
||||
SELECT
|
||||
LTRIM(RTRIM(CAST(name AS nvarchar(255)))) AS warehouseName,
|
||||
LTRIM(RTRIM(COALESCE(CAST(remark2 AS nvarchar(1000)), N''))) AS warehouseType
|
||||
FROM [${schema}].[jst_warehouseInfo]
|
||||
WHERE LTRIM(RTRIM(CAST(name AS nvarchar(255)))) IN (${parameters.join(', ')})
|
||||
`)
|
||||
return result.recordset
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.pool) return
|
||||
await this.pool.close()
|
||||
this.pool = null
|
||||
}
|
||||
}
|
||||
|
||||
export const jstWarehouseProfileRepository = new JstWarehouseProfileRepositoryClass()
|
||||
@ -0,0 +1,40 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type ProductCatalogCostPriceProfile = {
|
||||
barcode: string
|
||||
costPrice: string | null
|
||||
}
|
||||
|
||||
export interface ProductCatalogCostPriceRepository {
|
||||
listByBarcodes(
|
||||
barcodes: readonly string[],
|
||||
): Promise<ProductCatalogCostPriceProfile[]>
|
||||
}
|
||||
|
||||
function normalizedBarcodes(barcodes: readonly string[]) {
|
||||
return Array.from(
|
||||
new Set(barcodes.map((barcode) => barcode.trim().toUpperCase()).filter(Boolean)),
|
||||
).slice(0, 200)
|
||||
}
|
||||
|
||||
class ProductCatalogCostPriceRepositoryClass
|
||||
implements ProductCatalogCostPriceRepository
|
||||
{
|
||||
async listByBarcodes(barcodes: readonly string[]) {
|
||||
const normalized = normalizedBarcodes(barcodes)
|
||||
if (normalized.length === 0) return []
|
||||
|
||||
return prisma.$queryRaw<ProductCatalogCostPriceProfile[]>(Prisma.sql`
|
||||
SELECT
|
||||
UPPER(BTRIM("barcode_business_key")) AS "barcode",
|
||||
"cost_price"::text AS "costPrice"
|
||||
FROM external.product_catalog_wide_v1
|
||||
WHERE UPPER(BTRIM("barcode_business_key")) IN (${Prisma.join(normalized)})
|
||||
AND "catalog_source" = 'INTAKE_READY'
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
export const productCatalogCostPriceRepository =
|
||||
new ProductCatalogCostPriceRepositoryClass()
|
||||
@ -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,7 +40,150 @@ 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
|
||||
let maxActiveRequests = 0
|
||||
const fetchImpl = jest.fn(async (input: RequestInfo | URL) => {
|
||||
activeRequests += 1
|
||||
maxActiveRequests = Math.max(maxActiveRequests, activeRequests)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
activeRequests -= 1
|
||||
|
||||
const url = new URL(String(input))
|
||||
const barcode = url.searchParams.get('barcode') || 'unknown'
|
||||
return jsonResponse(200, {
|
||||
data: {
|
||||
items: [{
|
||||
previewUrl: `https://image.example.com/${barcode}.webp`,
|
||||
isLatest: true,
|
||||
}],
|
||||
},
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
const service = new ProductArchiveMaterialImageService(fetchImpl, config, Date.now, createImageCacheRepository())
|
||||
const items = Array.from({ length: 9 }, (_, index) => ({
|
||||
archiveId: `archive-${index}`,
|
||||
spu: `SPU-${index}`,
|
||||
barcode: `BARCODE-${index}`,
|
||||
sku: `SKU-${index}`,
|
||||
}))
|
||||
|
||||
const result = await service.resolveBatch(items)
|
||||
|
||||
expect(result.items).toHaveLength(items.length)
|
||||
expect(result.items.every((item) => item.status === 'resolved')).toBe(true)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(items.length)
|
||||
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: {
|
||||
@ -46,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',
|
||||
@ -79,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',
|
||||
@ -112,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',
|
||||
@ -145,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,14 +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([]),
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
SupplyChainProductArchiveService,
|
||||
} from '../supply-chain-product-archive.service'
|
||||
@ -71,54 +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 service = new SupplyChainProductArchiveService(
|
||||
repository as never,
|
||||
supplierProfileRepository,
|
||||
stockMonitorRepository,
|
||||
{
|
||||
listActive: jest.fn().mockResolvedValue([
|
||||
{ warehouseName: 'WH-1', warehouseHead: '负责人', storageType: 'INBOUND' },
|
||||
]),
|
||||
} as never,
|
||||
)
|
||||
const service = new SupplyChainProductArchiveService(repository as never)
|
||||
|
||||
const page = await service.listPage({ page: 1, pageSize: 50 })
|
||||
|
||||
@ -139,6 +107,7 @@ describe('supply-chain product archive field mapping', () => {
|
||||
volume: '139264',
|
||||
packType: '双包',
|
||||
orderType: '订单式',
|
||||
warehouseType: '成品发货仓-真实库存',
|
||||
warehouseHead: '负责人',
|
||||
storageType: '入库',
|
||||
supplier: '源沃家具',
|
||||
@ -155,13 +124,10 @@ describe('supply-chain product archive field mapping', () => {
|
||||
sales3d: '0',
|
||||
sales7d: '32.77',
|
||||
sales15d: '34.72',
|
||||
purchasePrice: '88.50',
|
||||
costPrice: '76.20',
|
||||
costPriceSecondary: '72.30',
|
||||
}),
|
||||
)
|
||||
expect(supplierProfileRepository.listByNames).toHaveBeenCalledWith([
|
||||
'源沃家具',
|
||||
])
|
||||
expect(stockMonitorRepository.listByKeys).toHaveBeenCalledWith([
|
||||
{ sku: 'SKU-001', barcode: '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 {
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import type { ProductArchiveMaterialImage } from '@/lib/supply-chain-product-archive-material-image'
|
||||
import type {
|
||||
ProductArchiveMaterialImage,
|
||||
ProductArchiveMaterialImageBatchRequestItem,
|
||||
ProductArchiveMaterialImageBatchResult,
|
||||
ProductArchiveMaterialImageBatchResultItem,
|
||||
} from '@/lib/supply-chain-product-archive-material-image'
|
||||
import {
|
||||
ProductArchiveMaterialSkuIndexService,
|
||||
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
|
||||
@ -28,7 +37,9 @@ 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']
|
||||
|
||||
function trimToNull(value: string | null | undefined) {
|
||||
@ -40,6 +51,28 @@ function normalizeLookup(value: string | null | undefined) {
|
||||
return trimToNull(value)?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
function groupBatchItems(items: ProductArchiveMaterialImageBatchRequestItem[]) {
|
||||
const groups = new Map<string, ProductArchiveMaterialImageBatchRequestItem[]>()
|
||||
|
||||
for (const item of items) {
|
||||
const spu = normalizeLookup(item.spu)
|
||||
const key = spu || `unscoped:${normalizeLookup(item.barcode)}:${normalizeLookup(item.sku)}`
|
||||
const group = groups.get(key) ?? []
|
||||
group.push(item)
|
||||
groups.set(key, group)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@ -133,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)
|
||||
@ -147,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
|
||||
|
||||
@ -156,14 +239,85 @@ 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)
|
||||
return request
|
||||
}
|
||||
|
||||
async resolveBatch(
|
||||
items: ProductArchiveMaterialImageBatchRequestItem[],
|
||||
signal?: AbortSignal,
|
||||
): 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 () => {
|
||||
while (nextIndex < orderedItems.length && !signal?.aborted) {
|
||||
const item = orderedItems[nextIndex]
|
||||
nextIndex += 1
|
||||
|
||||
try {
|
||||
const image = await this.resolve(item, persistedByArchiveId.get(item.archiveId) ?? null)
|
||||
results.set(item.archiveId, {
|
||||
archiveId: item.archiveId,
|
||||
status: image ? 'resolved' : 'not-found',
|
||||
image,
|
||||
})
|
||||
} catch {
|
||||
results.set(item.archiveId, {
|
||||
archiveId: item.archiveId,
|
||||
status: 'error',
|
||||
image: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(BATCH_CONCURRENCY, orderedItems.length)
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
||||
|
||||
return {
|
||||
items: items.map((item) => results.get(item.archiveId) ?? ({
|
||||
archiveId: item.archiveId,
|
||||
status: 'error',
|
||||
image: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveUncached(params: {
|
||||
spu: string | null
|
||||
barcode: string | null
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||
import { storageTypeLabel } from '@/lib/supply-chain-product-archive-values'
|
||||
import type { ProductArchiveRecord } from '@/server/repositories/supply-chain-product-archive.repository'
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function calculateCreateDays(createdAt: Date, now: Date) {
|
||||
return Math.max(0, Math.floor((now.getTime() - createdAt.getTime()) / DAY_IN_MS))
|
||||
}
|
||||
|
||||
function decimalText(value: { toString(): string } | null) {
|
||||
return value?.toString() ?? ''
|
||||
}
|
||||
|
||||
export function normalizeSupplierName(value: string) {
|
||||
return value.trim().toLocaleLowerCase('zh-CN')
|
||||
}
|
||||
|
||||
export function normalizeWarehouseName(value: string) {
|
||||
return value.trim().toLocaleLowerCase('zh-CN')
|
||||
}
|
||||
|
||||
export function normalizeArchiveIdentity(value: string) {
|
||||
return value.trim().toLocaleUpperCase('zh-CN')
|
||||
}
|
||||
|
||||
export function mapProductArchiveRow({
|
||||
record,
|
||||
now,
|
||||
}: {
|
||||
record: ProductArchiveRecord
|
||||
now: Date
|
||||
}) {
|
||||
return {
|
||||
id: record.id,
|
||||
image: record.image?.trim() ?? '',
|
||||
createTime: record.businessCreatedAt.toISOString(),
|
||||
createDays: calculateCreateDays(record.businessCreatedAt, now),
|
||||
updateTime: record.updatedAt.toISOString(),
|
||||
categoryL1: record.categoryL1,
|
||||
categoryL2: record.categoryL2,
|
||||
spu: record.spu,
|
||||
sku: record.sku,
|
||||
barcode: record.barcode,
|
||||
productName: record.productName,
|
||||
designer: record.designer,
|
||||
productStatus: record.productStatus,
|
||||
material: record.material,
|
||||
color: record.color,
|
||||
weight: decimalText(record.weight),
|
||||
length: decimalText(record.length),
|
||||
width: decimalText(record.width),
|
||||
height: decimalText(record.height),
|
||||
volume: decimalText(record.volume),
|
||||
packType: record.packType,
|
||||
productTag: record.productTag,
|
||||
installDocId: record.installDocId,
|
||||
hasDrawing: record.hasDrawing,
|
||||
hasVideo: record.hasVideo,
|
||||
spec: record.spec,
|
||||
brand: record.brand,
|
||||
orderType: record.orderType,
|
||||
inOutWarehouse: record.inOutWarehouse,
|
||||
warehouseType: record.warehouseType,
|
||||
warehouseHead: record.warehouseHead,
|
||||
storageType: storageTypeLabel(record.storageType),
|
||||
isSeasonal: record.isSeasonal,
|
||||
replaceCount: record.replaceCount,
|
||||
supplier: record.supplierName,
|
||||
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
|
||||
}
|
||||
@ -3,136 +3,17 @@ import type {
|
||||
ProductArchiveFilterOptions,
|
||||
ProductArchiveFilters,
|
||||
SupplyChainProductArchivePage,
|
||||
SupplyChainProductArchiveRow,
|
||||
} from '@/lib/supply-chain-product-archive'
|
||||
import {
|
||||
supplyChainProductArchiveRepository,
|
||||
type ProductArchiveRecord,
|
||||
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 { storageTypeLabel } from '@/lib/supply-chain-product-archive-values'
|
||||
import {
|
||||
supplyChainProductArchiveWarehouseConfigRepository,
|
||||
type SupplyChainProductArchiveWarehouseConfigRepository,
|
||||
} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository'
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function calculateCreateDays(createdAt: Date, now: Date) {
|
||||
return Math.max(0, Math.floor((now.getTime() - createdAt.getTime()) / DAY_IN_MS))
|
||||
}
|
||||
|
||||
function decimalText(value: { toString(): string } | null) {
|
||||
return value?.toString() ?? ''
|
||||
}
|
||||
|
||||
function normalizeSupplierName(value: string) {
|
||||
return value.trim().toLocaleLowerCase('zh-CN')
|
||||
}
|
||||
|
||||
function normalizeStockKey(value: string) {
|
||||
return value.trim().toLocaleUpperCase('zh-CN')
|
||||
}
|
||||
|
||||
function supplierStatusLabel(code: string | undefined) {
|
||||
if (code === '1') return '合作'
|
||||
if (code === '2') return '不合作'
|
||||
return ''
|
||||
}
|
||||
|
||||
function mapProductArchiveRow(
|
||||
record: ProductArchiveRecord,
|
||||
now: Date,
|
||||
supplierProfile?: JstSupplierProfile,
|
||||
stockProfile?: JstStockMonitorProfile,
|
||||
warehouseStorageTypes?: Map<string, string>,
|
||||
warehouseHeads?: Map<string, string>,
|
||||
) {
|
||||
const exactBarcodeStockProfile =
|
||||
stockProfile &&
|
||||
normalizeStockKey(stockProfile.barcode) === normalizeStockKey(record.barcode)
|
||||
? stockProfile
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
image: record.image?.trim() ?? '',
|
||||
createTime: record.businessCreatedAt.toISOString(),
|
||||
createDays: calculateCreateDays(record.businessCreatedAt, now),
|
||||
updateTime: record.updatedAt.toISOString(),
|
||||
categoryL1: record.categoryL1,
|
||||
categoryL2: record.categoryL2,
|
||||
spu: record.spu,
|
||||
sku: record.sku,
|
||||
barcode: record.barcode,
|
||||
productName: record.productName,
|
||||
designer: record.designer,
|
||||
productStatus: record.productStatus,
|
||||
material: record.material,
|
||||
color: record.color,
|
||||
weight: decimalText(record.weight),
|
||||
length: decimalText(record.length),
|
||||
width: decimalText(record.width),
|
||||
height: decimalText(record.height),
|
||||
volume: decimalText(record.volume),
|
||||
packType: record.packType,
|
||||
productTag: record.productTag,
|
||||
installDocId: record.installDocId,
|
||||
hasDrawing: record.hasDrawing,
|
||||
hasVideo: record.hasVideo,
|
||||
spec: record.spec,
|
||||
brand: record.brand,
|
||||
orderType: record.orderType,
|
||||
inOutWarehouse: record.inOutWarehouse,
|
||||
warehouseHead: warehouseHeads?.get(record.inOutWarehouse.trim().toLocaleLowerCase('zh-CN')) ?? '',
|
||||
storageType: storageTypeLabel(
|
||||
warehouseStorageTypes?.get(record.inOutWarehouse.trim().toLocaleLowerCase('zh-CN')),
|
||||
),
|
||||
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 ?? '',
|
||||
} satisfies SupplyChainProductArchiveRow
|
||||
}
|
||||
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,
|
||||
) {}
|
||||
|
||||
async listPage(params: {
|
||||
@ -148,68 +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) => [
|
||||
item.warehouseName.trim().toLocaleLowerCase('zh-CN'),
|
||||
item.storageType,
|
||||
]),
|
||||
)
|
||||
const warehouseHeads = new Map(
|
||||
warehouseConfigs.map((item) => [
|
||||
item.warehouseName.trim().toLocaleLowerCase('zh-CN'),
|
||||
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.
|
||||
}
|
||||
const stockByBarcode = new Map(
|
||||
stockProfiles
|
||||
.filter((profile) => profile.barcode.trim())
|
||||
.map((profile) => [normalizeStockKey(profile.barcode), profile]),
|
||||
)
|
||||
const stockBySku = new Map<string, JstStockMonitorProfile[]>()
|
||||
for (const profile of stockProfiles) {
|
||||
const key = normalizeStockKey(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,
|
||||
supplierProfiles.get(normalizeSupplierName(record.supplierName)),
|
||||
stockByBarcode.get(normalizeStockKey(record.barcode)) ??
|
||||
(stockBySku.get(normalizeStockKey(record.sku))?.length === 1
|
||||
? stockBySku.get(normalizeStockKey(record.sku))?.[0]
|
||||
: undefined),
|
||||
warehouseStorageTypes,
|
||||
warehouseHeads,
|
||||
),
|
||||
),
|
||||
rows: records.map((record) => mapProductArchiveRow({ record, now })),
|
||||
pageInfo: {
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user