Merge branch 'codex/xuxiake-sabc-sync-20260929' into codex/xuxiake-mainline

商品SABC打标全部源码:页面/筛选/经营总览、服务端核算规则、同款商品编码匹配、
每日同步(聚水潭档案/销售日表/替换规则/类目档位/产品类型回填)、API 与导出、测试用例。
This commit is contained in:
居居 2026-09-29 17:59:16 +08:00 committed by WuYongJie
parent b9dc0bddb1
commit ad08dccf1c
51 changed files with 5994 additions and 45 deletions

View File

@ -2,6 +2,7 @@
# dependencies # dependencies
/node_modules /node_modules
/node_modules.pnpm-mixed
/.pnp /.pnp
.pnp.* .pnp.*
.yarn/* .yarn/*
@ -15,6 +16,7 @@
# next.js # next.js
/.next/ /.next/
/.next-*/
/.next-oa-preview*/ /.next-oa-preview*/
/.next-oa-webpack/ /.next-oa-webpack/
/out/ /out/

View File

@ -53,6 +53,17 @@
- 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。 - 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。
- 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。 - 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。
## 商品 SABC 月度核算
- 试算命令:`npm run run:product-sabc -- --year=2026 --month=8 --mode=PREVIEW`。
- 8 月人工表对账命令:`npm run compare:product-sabc-xlsx`,默认读取本机下载的人工工作簿;生产执行前应先完成产品全量档案日销售数据同步。
- 生产定时器安装脚本:`furniture-3d-system/scripts/install-product-sabc-timer.sh`。
- 生产 systemd 名称:`furniture-product-sabc-monthly.service` / `furniture-product-sabc-monthly.timer`。
- 默认调度:每月 2 日中国标准时间 `03:00`,附带最多 10 分钟随机延迟;服务计算上一个自然月的数据。
- SABC 的销售收款总金额、真实业绩、发货件数、商品毛利额和售后金额统一读取 `SupplyChainProductArchiveSalesPerformanceDaily`,与产品全量档案页面保持同一月度口径。
- 首次安装生成权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/product-sabc-timer.env`,默认 `PRODUCT_SABC_TIMER_MODE=PREVIEW`。8 月对账确认通过、迁移已应用且上月产品档案日销售数据完整后,才允许改为 `APPLY`。
- SABC 自动任务只读取现有 `DATABASE_URL`;上游日销售同步任务继续使用 `JST_REPORT_DB_*`,凭据不写入定时器配置。安装、迁移、首次 APPLY 和生产核算都需要单独授权。
## 操作日志保留策略 ## 操作日志保留策略
- 清理脚本:`npm run db:cleanup-operation-logs` 默认只做 dry-run;只有显式传入 `--apply --confirm=cleanup-operation-logs` 才会删除数据。 - 清理脚本:`npm run db:cleanup-operation-logs` 默认只做 dry-run;只有显式传入 `--apply --confirm=cleanup-operation-logs` 才会删除数据。

View File

@ -24,6 +24,9 @@
"backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts", "backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts",
"backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply", "backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply",
"sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts", "sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts",
"run:product-sabc": "tsx scripts/run-product-sabc.ts",
"compare:product-sabc-xlsx": "tsx scripts/compare-product-sabc-xlsx.ts",
"seed:local-product-sabc-xlsx": "tsx scripts/seed-local-product-sabc-xlsx.ts",
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts", "sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts", "sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts", "backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",

View File

@ -0,0 +1,67 @@
ALTER TABLE "SupplyChainProductArchive"
ADD COLUMN "sabcRating" VARCHAR(1) NOT NULL DEFAULT '',
ADD COLUMN "sabcUpdatedAt" TIMESTAMP(3);
CREATE TABLE "SupplyChainProductArchiveSabcResult" (
"id" TEXT NOT NULL,
"archiveId" TEXT NOT NULL,
"barcode" VARCHAR(100) NOT NULL,
"periodStart" DATE NOT NULL,
"periodEnd" DATE NOT NULL,
"eligible" BOOLEAN NOT NULL,
"sabcRating" VARCHAR(1) NOT NULL DEFAULT '',
"category" TEXT NOT NULL DEFAULT '',
"performanceScale" TEXT NOT NULL DEFAULT '',
"salesScale" TEXT NOT NULL DEFAULT '',
"finalScale" TEXT NOT NULL DEFAULT '',
"profitLabel" TEXT NOT NULL DEFAULT '',
"averageDailySales" DECIMAL(24,8),
"feeRatio" DECIMAL(20,8),
"grossMargin" DECIMAL(20,8),
"shipAmount" DECIMAL(24,4) NOT NULL,
"realPerformance" DECIMAL(24,4) NOT NULL,
"shipQty" BIGINT NOT NULL,
"grossProfit" DECIMAL(24,4) NOT NULL,
"afterSaleAmount" DECIMAL(24,4) NOT NULL,
"status" VARCHAR(24) NOT NULL DEFAULT 'CALCULATED',
"reason" TEXT NOT NULL DEFAULT '',
"calculatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupplyChainProductArchiveSabcResult_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SupplyChainProductArchiveSabcRun" (
"id" TEXT NOT NULL,
"periodStart" DATE NOT NULL,
"periodEnd" DATE NOT NULL,
"mode" VARCHAR(16) NOT NULL,
"status" VARCHAR(24) NOT NULL,
"sourceRows" INTEGER NOT NULL DEFAULT 0,
"eligibleRows" INTEGER NOT NULL DEFAULT 0,
"taggedRows" INTEGER NOT NULL DEFAULT 0,
"comparedRows" INTEGER NOT NULL DEFAULT 0,
"mismatchRows" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
CONSTRAINT "SupplyChainProductArchiveSabcRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SupplyChainProductArchiveSabcResult_barcode_periodStart_periodEnd_key"
ON "SupplyChainProductArchiveSabcResult"("barcode", "periodStart", "periodEnd");
CREATE INDEX "SupplyChainProductArchiveSabcResult_periodStart_periodEnd_idx"
ON "SupplyChainProductArchiveSabcResult"("periodStart", "periodEnd");
CREATE INDEX "SupplyChainProductArchiveSabcResult_sabcRating_periodEnd_idx"
ON "SupplyChainProductArchiveSabcResult"("sabcRating", "periodEnd");
CREATE INDEX "SupplyChainProductArchiveSabcResult_archiveId_periodEnd_idx"
ON "SupplyChainProductArchiveSabcResult"("archiveId", "periodEnd");
CREATE UNIQUE INDEX "SupplyChainProductArchiveSabcRun_periodStart_periodEnd_mode_key"
ON "SupplyChainProductArchiveSabcRun"("periodStart", "periodEnd", "mode");
CREATE INDEX "SupplyChainProductArchiveSabcRun_status_startedAt_idx"
ON "SupplyChainProductArchiveSabcRun"("status", "startedAt");
ALTER TABLE "SupplyChainProductArchiveSabcResult"
ADD CONSTRAINT "SupplyChainProductArchiveSabcResult_archiveId_fkey"
FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
ALTER TABLE "SupplyChainProductArchive"
ADD COLUMN "productAttribute" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,33 @@
ALTER TABLE "SupplyChainProductArchiveCategoryConfig"
ADD COLUMN IF NOT EXISTS "categoryGroup" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "scaleLabel" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "performanceStart" DECIMAL(14,2),
ADD COLUMN IF NOT EXISTS "performanceEnd" DECIMAL(14,2),
ADD COLUMN IF NOT EXISTS "dailySalesStart" DECIMAL(12,2),
ADD COLUMN IF NOT EXISTS "dailySalesEnd" DECIMAL(12,2);
CREATE INDEX IF NOT EXISTS "SupplyChainProductArchiveCategoryConfig_categoryGroup_idx"
ON "SupplyChainProductArchiveCategoryConfig"("categoryGroup");
CREATE TABLE IF NOT EXISTS "SupplyChainProductArchiveCategoryScaleRow" (
"id" TEXT NOT NULL,
"categoryL1" TEXT NOT NULL DEFAULT '',
"categoryL2" TEXT NOT NULL,
"categoryGroup" TEXT NOT NULL DEFAULT '',
"scaleLabel" TEXT NOT NULL,
"performanceStart" DECIMAL(14,2) NOT NULL,
"performanceEnd" DECIMAL(14,2),
"dailySalesStart" DECIMAL(12,2) NOT NULL,
"dailySalesEnd" DECIMAL(12,2),
"sortOrder" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplyChainProductArchiveCategoryScaleRow_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "SupplyChainProductArchiveCategoryScaleRow_categoryL2_scaleLabel_key"
ON "SupplyChainProductArchiveCategoryScaleRow"("categoryL2", "scaleLabel");
CREATE INDEX IF NOT EXISTS "SupplyChainProductArchiveCategoryScaleRow_categoryL2_idx"
ON "SupplyChainProductArchiveCategoryScaleRow"("categoryL2");
CREATE INDEX IF NOT EXISTS "SupplyChainProductArchiveCategoryScaleRow_categoryGroup_idx"
ON "SupplyChainProductArchiveCategoryScaleRow"("categoryGroup");

View File

@ -693,6 +693,7 @@ model SupplyChainProductArchive {
height Decimal? @db.Decimal(18, 3) height Decimal? @db.Decimal(18, 3)
volume Decimal? @db.Decimal(24, 3) volume Decimal? @db.Decimal(24, 3)
packType String @default("") packType String @default("")
productAttribute String @default("")
productTag String @default("") productTag String @default("")
installDocId String @default("") installDocId String @default("")
hasDrawing String @default("") hasDrawing String @default("")
@ -733,11 +734,14 @@ model SupplyChainProductArchive {
purchasePrice String @default("") purchasePrice String @default("")
costPrice String @default("") costPrice String @default("")
costPriceSecondary String @default("") costPriceSecondary String @default("")
sabcRating String @default("") @db.VarChar(1)
sabcUpdatedAt DateTime?
fieldSourceMap Json @default("{}") fieldSourceMap Json @default("{}")
sources SupplyChainProductArchiveSource[] sources SupplyChainProductArchiveSource[]
imageEmbedding ProductArchiveImageEmbedding? imageEmbedding ProductArchiveImageEmbedding?
imageCache SupplyChainProductArchiveImageCache? imageCache SupplyChainProductArchiveImageCache?
salesPerformance SupplyChainProductArchiveSalesPerformance? salesPerformance SupplyChainProductArchiveSalesPerformance?
sabcResults SupplyChainProductArchiveSabcResult[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -752,6 +756,58 @@ model SupplyChainProductArchive {
@@index([firstSourceCode, createdAt]) @@index([firstSourceCode, createdAt])
} }
// 月度 SABC 核算快照。主表只保留当前标签,历史月份和计算过程在这里追溯。
model SupplyChainProductArchiveSabcResult {
id String @id @default(cuid())
archiveId String
archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade)
barcode String @db.VarChar(100)
periodStart DateTime @db.Date
periodEnd DateTime @db.Date
eligible Boolean
sabcRating String @default("") @db.VarChar(1)
category String @default("")
performanceScale String @default("")
salesScale String @default("")
finalScale String @default("")
profitLabel String @default("")
averageDailySales Decimal? @db.Decimal(24, 8)
feeRatio Decimal? @db.Decimal(20, 8)
grossMargin Decimal? @db.Decimal(20, 8)
shipAmount Decimal @db.Decimal(24, 4)
realPerformance Decimal @db.Decimal(24, 4)
shipQty BigInt
grossProfit Decimal @db.Decimal(24, 4)
afterSaleAmount Decimal @db.Decimal(24, 4)
status String @default("CALCULATED") @db.VarChar(24)
reason String @default("") @db.Text
calculatedAt DateTime @default(now())
@@unique([barcode, periodStart, periodEnd])
@@index([periodStart, periodEnd])
@@index([sabcRating, periodEnd])
@@index([archiveId, periodEnd])
}
model SupplyChainProductArchiveSabcRun {
id String @id @default(cuid())
periodStart DateTime @db.Date
periodEnd DateTime @db.Date
mode String @db.VarChar(16)
status String @db.VarChar(24)
sourceRows Int @default(0)
eligibleRows Int @default(0)
taggedRows Int @default(0)
comparedRows Int @default(0)
mismatchRows Int @default(0)
errorMessage String? @db.Text
startedAt DateTime @default(now())
completedAt DateTime?
@@unique([periodStart, periodEnd, mode])
@@index([status, startedAt])
}
// 销售宽表按条码汇总的每日快照。 // 销售宽表按条码汇总的每日快照。
model SupplyChainProductArchiveSalesPerformance { model SupplyChainProductArchiveSalesPerformance {
archiveId String @id archiveId String @id

View File

@ -0,0 +1,101 @@
import { loadEnvConfig } from '@next/env'
import * as XLSX from 'xlsx'
loadEnvConfig(process.cwd())
const defaultWorkbook = '/Users/a1/Downloads/8月SABC、款效与新品动销监控 (1).xlsx'
const workbookPath = process.argv.find((argument) => argument.startsWith('--file='))?.slice('--file='.length) || defaultWorkbook
function text(value: unknown) {
return String(value ?? '').trim()
}
function number(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function indexOfHeader(headers: unknown[], label: string) {
const index = headers.findIndex((header) => text(header).replace(/\s+/g, '') === label.replace(/\s+/g, ''))
if (index < 0) throw new Error(`Excel sheet is missing column: ${label}`)
return index
}
function closeEnough(left: unknown, right: unknown) {
return Math.abs(number(left) - number(right)) <= 0.01
}
async function main() {
const [{ prisma }, { supplyChainProductSabcService }] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/supply-chain-product-sabc.service'),
])
try {
const workbook = XLSX.readFile(workbookPath, { cellFormula: true })
const detail = XLSX.utils.sheet_to_json<unknown[]>(workbook.Sheets['商品明细-所有'], { header: 1, raw: true, defval: '' })
const archive = XLSX.utils.sheet_to_json<unknown[]>(workbook.Sheets['商品资料辅助'], { header: 1, raw: true, defval: '' })
if (!detail.length || !archive.length) throw new Error('Excel 中缺少商品明细-所有或商品资料辅助工作表')
const detailHeaders = detail[0] ?? []
const archiveHeaders = archive[0] ?? []
const detailCode = indexOfHeader(detailHeaders, '商品编码')
const detailStatus = indexOfHeader(detailHeaders, '生产状态')
const detailRating = indexOfHeader(detailHeaders, '26财年版本SABC')
const detailShipAmount = indexOfHeader(detailHeaders, '8.1-8.31全渠道销售收款总金额')
const detailRealPerformance = indexOfHeader(detailHeaders, '8.1-8.31全渠道真实业绩')
const detailShipQty = indexOfHeader(detailHeaders, '8.1-8.31全渠道发货件数')
const detailGrossProfit = indexOfHeader(detailHeaders, '8.1-8.31全渠道商品毛利额')
const detailAfterSale = indexOfHeader(detailHeaders, '8.1-8.31售后金额')
const archiveCode = indexOfHeader(archiveHeaders, '商品编码')
const archiveProperty = indexOfHeader(archiveHeaders, '商品属性')
const finishedCodes = new Set(archive.slice(1)
.filter((row) => text(row[archiveProperty]) === '成品')
.map((row) => text(row[archiveCode]).toUpperCase())
.filter(Boolean))
const manualRows = detail.slice(1)
.filter((row) => text(row[detailStatus]) === '销售' && finishedCodes.has(text(row[detailCode]).toUpperCase()))
const result = await supplyChainProductSabcService.calculate({ year: 2026, month: 8, apply: false })
const systemRows = new Map(result.rows.map((entry) => [entry.row.productCode.toUpperCase(), entry.row]))
const mismatches: Array<Record<string, unknown>> = []
let compared = 0
for (const manual of manualRows) {
const code = text(manual[detailCode]).toUpperCase()
const system = systemRows.get(code)
if (!system) {
mismatches.push({ code, type: 'SYSTEM_ARCHIVE_MISSING', manualRating: text(manual[detailRating]) })
continue
}
compared += 1
const fields = [
['sabcRating', text(manual[detailRating]), system.sabcRating],
['shipAmount', manual[detailShipAmount], system.shipAmount],
['realPerformance', manual[detailRealPerformance], system.realPerformance],
['shipQty', manual[detailShipQty], system.shipQty],
['grossProfit', manual[detailGrossProfit], system.grossProfit],
['afterSaleAmount', manual[detailAfterSale], system.afterSaleAmount],
] as const
for (const [field, manualValue, systemValue] of fields) {
const same = field === 'sabcRating'
? text(manualValue) === text(systemValue)
: closeEnough(manualValue, systemValue)
if (!same) mismatches.push({ code, field, manual: manualValue, system: systemValue })
}
}
console.log(JSON.stringify({
file: workbookPath,
manualEligibleRows: manualRows.length,
systemArchiveRows: result.rows.length,
comparedRows: compared,
mismatchFields: mismatches.length,
mismatches: mismatches.slice(0, 200),
}, null, 2))
} finally {
await prisma.$disconnect()
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# 安装/卸载本地每日同步任务(macOS launchd)。
# 默认每天 08:30 运行 scripts/local-sync-archive.mjs:
# 聚水潭档案同步 → 销售日数据同步 → 正式库对齐(状态/创建时间)→ 上月 SABC 重算。
# 用法:
# bash scripts/install-local-daily-sync.sh # 安装(每天 08:30)
# LOCAL_SYNC_HOUR=7 LOCAL_SYNC_MINUTE=0 bash scripts/install-local-daily-sync.sh
# bash scripts/install-local-daily-sync.sh --uninstall
set -euo pipefail
BASE="$(cd "$(dirname "$0")/.." && pwd)"
LABEL="${LOCAL_SYNC_LABEL:-com.furniture3d.daily-sync}"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
LOG="$HOME/Library/Logs/furniture3d-daily-sync.log"
HOUR="${LOCAL_SYNC_HOUR:-8}"
MINUTE="${LOCAL_SYNC_MINUTE:-30}"
NODE_BIN="${NODE_BIN:-}"
if [ -z "$NODE_BIN" ]; then
if command -v node >/dev/null 2>&1; then
NODE_BIN="$(command -v node)"
elif [ -x "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" ]; then
NODE_BIN="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node"
else
echo "找不到 node,请安装 Node.js 或设置 NODE_BIN。" >&2
exit 1
fi
fi
if [ "${1:-}" = "--uninstall" ]; then
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
echo "已卸载 $LABEL"
exit 0
fi
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
cat >"$PLIST" <<PLIST_EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$NODE_BIN</string>
<string>$BASE/scripts/local-sync-archive.mjs</string>
</array>
<key>WorkingDirectory</key>
<string>$BASE</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>$(dirname "$NODE_BIN"):/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>$HOUR</integer>
<key>Minute</key>
<integer>$MINUTE</integer>
</dict>
<key>StandardOutPath</key>
<string>$LOG</string>
<key>StandardErrorPath</key>
<string>$LOG</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
PLIST_EOF
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null || launchctl load "$PLIST"
if launchctl list | grep -q "$LABEL"; then
echo "已安装 ${LABEL}:每天 $(printf '%02d' "$HOUR"):$(printf '%02d' "$MINUTE") 执行"
echo "日志:${LOG}"
echo "手动触发一次:launchctl kickstart -k gui/$(id -u)/$LABEL"
else
echo "安装失败,请检查 $PLIST" >&2
exit 1
fi

View File

@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}"
SERVICE_NAME="${PRODUCT_SABC_SERVICE_NAME:-furniture-product-sabc-monthly}"
APP_ROOT="$BASE/current/furniture-3d-system"
SHARED="$BASE/shared"
APP_ENV="$SHARED/.env"
SABC_ENV="$SHARED/product-sabc-timer.env"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer"
SCHEDULE="${PRODUCT_SABC_TIMER_SCHEDULE:-*-*-02 03:00:00 Asia/Shanghai}"
RANDOMIZED_DELAY_SEC="${PRODUCT_SABC_TIMER_RANDOMIZED_DELAY_SEC:-10min}"
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 "$SABC_ENV" ]; then
umask 077
cat >"$SABC_ENV" <<'EOF'
# SABC monthly run mode. Keep PREVIEW until the August reconciliation is accepted.
PRODUCT_SABC_TIMER_MODE=PREVIEW
EOF
echo "Created env template: $SABC_ENV"
fi
chmod 600 "$SABC_ENV"
if ! grep -Eq '^PRODUCT_SABC_TIMER_MODE=(PREVIEW|APPLY)$' "$SABC_ENV"; then
echo "PRODUCT_SABC_TIMER_MODE must be PREVIEW or APPLY; timer was not installed." >&2
exit 2
fi
if command -v systemd-analyze >/dev/null 2>&1; then
systemd-analyze calendar "$SCHEDULE" >/dev/null
fi
cat >"$SERVICE_FILE" <<EOF
[Unit]
Description=Calculate monthly product SABC labels from the previous month
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=$APP_ROOT
Environment=NODE_ENV=production
EnvironmentFile=$APP_ENV
EnvironmentFile=$SABC_ENV
ExecStart=/bin/bash -lc 'set -euo pipefail; period=\$(date -d "\$(date +%%Y-%%m-01) -1 day" +%%Y-%%m); year=\${period%%-*}; month=\${period##*-}; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/run-product-sabc.ts --year=\$year --month=\$month --mode=\${PRODUCT_SABC_TIMER_MODE:?Set PRODUCT_SABC_TIMER_MODE in $SABC_ENV} \$(if [ "\${PRODUCT_SABC_TIMER_MODE}" = APPLY ]; then printf %s "--confirm=run-product-sabc"; fi)'
TimeoutStartSec=6h
StandardOutput=journal
StandardError=journal
EOF
cat >"$TIMER_FILE" <<EOF
[Unit]
Description=Run product SABC calculation on the second day of each month
[Timer]
OnCalendar=$SCHEDULE
Persistent=true
RandomizedDelaySec=$RANDOMIZED_DELAY_SEC
Unit=$SERVICE_NAME.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME.timer"
systemctl list-timers --all "$SERVICE_NAME.timer" --no-pager

View File

@ -0,0 +1,218 @@
#!/usr/bin/env node
// 本地一键刷新(Node 版,供 launchd 直接调用):
// 1) 聚水潭产品档案全量同步
// 2) 聚水潭销售日数据增量同步(最近 7 天)
// 3) 同步简尚「替换款管理」换货规则
// 4) 以正式库产品全量档案对齐(生产状态 + 创建时间)
// 5) 回填产品全量档案「产品类型」(定厂优先,聚水潭 vc_name 兜底)
// 6) 以正式库类目档位表同步本地类目配置
// 7) 重算指定月份(默认上个月)SABC 并写库
// 用法:
// node scripts/local-sync-archive.mjs [--month=2026-08] [--dry-run] [--skip-sabc]
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const tsxCli = path.join(root, 'node_modules/tsx/dist/cli.mjs')
function loadEnvFile(file) {
if (!existsSync(file)) return {}
const env = {}
for (const line of readFileSync(file, 'utf8').split('\n')) {
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/)
if (!match) continue
let value = match[2]
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
value = value.slice(1, -1)
}
env[match[1]] = value
}
return env
}
const env = {
...loadEnvFile(path.join(root, '.env')),
...process.env,
PATH: [path.dirname(process.execPath), process.env.PATH].filter(Boolean).join(':'),
}
const argv = process.argv.slice(2)
if (argv.includes('-h') || argv.includes('--help')) {
console.log(`本地一键刷新:聚水潭档案同步 → 正式库对齐 → 上月 SABC 重算
用法:
node scripts/local-sync-archive.mjs # 同步并重算上个月
node scripts/local-sync-archive.mjs --month=2026-08 # 指定核算月份
node scripts/local-sync-archive.mjs --dry-run # 只预览差异,不写库、不重算
node scripts/local-sync-archive.mjs --skip-sabc # 只同步档案,不重算 SABC`)
process.exit(0)
}
const apply = !argv.includes('--dry-run')
const skipSabc = argv.includes('--skip-sabc')
const monthArgument = argv.find((argument) => argument.startsWith('--month='))?.slice('--month='.length) ?? ''
function previousMonth() {
const date = new Date()
date.setMonth(date.getMonth() - 1)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
const month = monthArgument || previousMonth()
function required(name) {
const value = env[name]?.trim()
if (!value) {
console.error(`缺少环境变量 ${name},请在 .env 中补齐。`)
process.exit(1)
}
return value
}
function databaseIdentity(url) {
const parsed = new URL(url)
return [
'postgresql',
decodeURIComponent(parsed.username || ''),
parsed.hostname,
parsed.port || '',
parsed.pathname.replace(/^\//, ''),
parsed.searchParams.get('schema') ?? '',
].join('|')
}
function sourceIdentity() {
return [
'sqlserver',
required('JST_REPORT_DB_USER'),
required('JST_REPORT_DB_HOST'),
env.JST_REPORT_DB_PORT?.trim() || '1433',
required('JST_REPORT_DB_NAME'),
env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
].join('|')
}
function run(args, { capture = false } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [tsxCli, ...args], {
cwd: root,
env,
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
})
let output = ''
if (capture) {
child.stdout.on('data', (chunk) => { output += chunk })
child.stderr.on('data', (chunk) => { output += chunk })
}
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve(output)
else reject(new Error(`命令失败(exit ${code}):\n${output.slice(-4000)}`))
})
})
}
function printSyncSummary(output) {
const progress = output.split('\n').filter((line) => line.startsWith('{"progress"'))
if (progress.length) console.log(` 最后进度:${progress[progress.length - 1]}`)
const index = output.lastIndexOf('\n{\n')
if (index < 0) return
try {
const result = JSON.parse(output.slice(index + 1))
delete result.preview
console.log(` 同步结果:${JSON.stringify(result)}`)
} catch {
/* ignore parse failure, raw log was already printed on error */
}
}
async function main() {
if (apply) {
for (const key of [
'JST_REPORT_DB_HOST', 'JST_REPORT_DB_NAME', 'JST_REPORT_DB_USER', 'JST_REPORT_DB_PASSWORD',
'PROD_ARCHIVE_DB_HOST', 'PROD_ARCHIVE_DB_NAME', 'PROD_ARCHIVE_DB_USER', 'PROD_ARCHIVE_DB_PASSWORD',
'DATABASE_URL',
]) required(key)
}
if (apply) {
console.log(`==> 1/7 同步聚水潭产品档案(${env.JST_REPORT_DB_HOST}/${env.JST_REPORT_DB_NAME} → 本地)`)
} else {
console.log('==> 1/7 预览聚水潭产品档案差异(dry-run,不写库)')
}
const syncArgs = ['scripts/sync-jst-sku-product-archive.ts', '--all', '--batch-size=500', '--concurrency=8']
if (apply) {
syncArgs.push(
'--apply',
'--confirm=sync-jst-sku-product-archive',
`--expected-database-identity=${databaseIdentity(required('DATABASE_URL'))}`,
`--expected-source-database-identity=${sourceIdentity()}`,
)
} else {
syncArgs.push('--dry-run')
}
printSyncSummary(await run(syncArgs, { capture: true }))
if (apply) {
console.log('==> 2/7 同步聚水潭销售日数据(最近 7 天)')
await run([
'scripts/sync-supply-chain-product-archive-sales-performance-daily.ts',
'--apply',
'--confirm=sync-supply-chain-product-archive-sales-performance-daily',
`--expected-database-identity=${databaseIdentity(required('DATABASE_URL'))}`,
`--expected-source-database-identity=${sourceIdentity()}`,
'--lookback-days=7',
])
} else {
console.log('==> 2/7 已跳过销售日数据同步(dry-run)')
}
if (apply) {
console.log('==> 3/7 同步简尚替换款管理换货规则')
await run(['scripts/sync-product-replacement-rules.ts', '--apply', '--confirm=sync-product-replacement-rules'])
} else {
console.log('==> 3/7 预览替换款规则差异(dry-run,不写库)')
await run(['scripts/sync-product-replacement-rules.ts'])
}
if (apply) {
console.log('==> 4/7 以正式库产品全量档案对齐(状态/创建时间/人员字段)')
await run(['scripts/reconcile-archive-from-prod.ts', '--apply', '--confirm=reconcile-archive-from-prod'])
} else {
console.log('==> 4/7 预览正式库对齐差异(dry-run,不写库)')
await run(['scripts/reconcile-archive-from-prod.ts'])
}
if (apply) {
console.log('==> 5/7 回填产品类型(定厂优先,聚水潭 vc_name 兜底)')
await run(['scripts/backfill-product-archive-production-type.ts', '--apply', '--batch-size=200'])
} else {
console.log('==> 5/7 预览产品类型差异(dry-run,不写库)')
await run(['scripts/backfill-product-archive-production-type.ts', '--batch-size=200'])
}
if (apply) {
console.log('==> 6/7 同步正式库类目档位配置')
await run(['scripts/sync-product-category-config.ts', '--apply', '--confirm=sync-product-category-config'])
} else {
console.log('==> 6/7 预览类目档位差异(dry-run,不写库)')
await run(['scripts/sync-product-category-config.ts'])
}
if (apply && !skipSabc) {
const year = month.split('-')[0]
const monthNumber = String(Number(month.split('-')[1]))
console.log(`==> 7/7 重算 ${year}-${monthNumber} SABC 并写库(APPLY)`)
await run(['scripts/run-product-sabc.ts', `--year=${year}`, `--month=${monthNumber}`, '--mode=APPLY', '--confirm=run-product-sabc'])
} else {
console.log('==> 7/7 已跳过 SABC 重算')
}
console.log('==> 完成。SABC 页面:http://localhost:3000/supply-chain/management/blank-pages/blank-page-3')
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})

View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# 本地一键刷新(手动入口):聚水潭档案同步 → 销售日数据同步 → 正式库对齐 → 上月 SABC 重算。
# 实际逻辑在 scripts/local-sync-archive.mjs。
# 用法:
# bash scripts/local-sync-archive.sh # 同步档案、对齐正式库并重算上个月 SABC
# bash scripts/local-sync-archive.sh --month=2026-08 # 指定核算月份
# bash scripts/local-sync-archive.sh --dry-run # 只预览差异,不写库、不重算
# bash scripts/local-sync-archive.sh --skip-sabc # 只同步档案,不重算 SABC
set -euo pipefail
BASE="$(cd "$(dirname "$0")/.." && pwd)"
NODE_BIN="${NODE_BIN:-}"
if [ -z "$NODE_BIN" ]; then
if command -v node >/dev/null 2>&1; then
NODE_BIN="$(command -v node)"
elif [ -x "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" ]; then
NODE_BIN="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node"
else
echo "找不到 node,请安装 Node.js 或设置 NODE_BIN。" >&2
exit 1
fi
fi
exec "$NODE_BIN" "$BASE/scripts/local-sync-archive.mjs" "$@"

View File

@ -0,0 +1,190 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
// 以正式环境产品全量档案为准,对齐本地档案的「生产状态」和「创建时间」。
// 默认 dry-run;写库需要 --apply --confirm=reconcile-archive-from-prod。
import pg from 'pg'
const CONFIRM_TOKEN = 'reconcile-archive-from-prod'
function required(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required.`)
return value
}
function normalizeTimestamp(value: string | null) {
return (value ?? '').replace('T', ' ').slice(0, 19)
}
function flagValue(name: string) {
const prefix = `--${name}=`
return process.argv.slice(2).find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? ''
}
async function main() {
const apply = process.argv.includes('--apply')
if (apply && flagValue('confirm') !== CONFIRM_TOKEN) {
throw new Error(`写库需要 --apply --confirm=${CONFIRM_TOKEN}`)
}
const batchSize = Math.min(Math.max(Number(flagValue('batch-size') || '5000'), 500), 20_000)
const prod = new pg.Client({
host: required('PROD_ARCHIVE_DB_HOST'),
port: Number(process.env.PROD_ARCHIVE_DB_PORT?.trim() || '25432'),
database: required('PROD_ARCHIVE_DB_NAME'),
user: required('PROD_ARCHIVE_DB_USER'),
password: required('PROD_ARCHIVE_DB_PASSWORD'),
ssl: { rejectUnauthorized: false },
statement_timeout: 120_000,
})
const local = new pg.Client({
connectionString: required('DATABASE_URL'),
statement_timeout: 120_000,
})
await prod.connect()
await local.connect()
const stats = {
apply,
scanned: 0,
missingLocal: 0,
statusChanged: 0,
createdChanged: 0,
updated: 0,
personnelChanged: 0,
samples: [] as Array<{ barcode: string; field: string; local: string; prod: string }>,
}
// 与产品全量档案保持一致的档案字段(人员/设计等,本地不再自行推导)。
const mirroredFields = ['opsDirector', 'opsDeptHead', 'opsHead', 'supplierName', 'purchaseOwner', 'designer'] as const
try {
let offset = 0
while (true) {
const prodRows = (await prod.query(
`select id, barcode, "productStatus", "businessCreatedAt"::text as created, "updatedAt"::text as updated,
"opsDirector", "opsDeptHead", "opsHead", "supplierName", "purchaseOwner", "designer"
from public."SupplyChainProductArchive"
order by barcode
offset $1 limit $2`,
[offset, batchSize],
)).rows
if (prodRows.length === 0) break
offset += prodRows.length
const barcodes = prodRows.map((row) => row.barcode)
const localRows = (await local.query(
`select barcode, "productStatus", "businessCreatedAt"::text as created,
"opsDirector", "opsDeptHead", "opsHead", "supplierName", "purchaseOwner", "designer"
from "SupplyChainProductArchive"
where barcode = any($1::text[])`,
[barcodes],
)).rows
const localMap = new Map(localRows.map((row) => [row.barcode, row]))
for (const prodRow of prodRows) {
stats.scanned += 1
const localRow = localMap.get(prodRow.barcode)
if (!localRow) {
stats.missingLocal += 1
continue
}
const statusDiff = (localRow.productStatus ?? '') !== (prodRow.productStatus ?? '')
const createdDiff =
normalizeTimestamp(localRow.created) !== normalizeTimestamp(prodRow.created)
&& prodRow.created !== null
const fieldDiffs = mirroredFields.filter((field) => (localRow[field] ?? '') !== (prodRow[field] ?? ''))
if (!statusDiff && !createdDiff && fieldDiffs.length === 0) continue
if (statusDiff) stats.statusChanged += 1
if (createdDiff) stats.createdChanged += 1
stats.personnelChanged += fieldDiffs.length
if (stats.samples.length < 12) {
if (statusDiff || createdDiff) {
stats.samples.push({
barcode: prodRow.barcode,
field: statusDiff ? 'productStatus' : 'businessCreatedAt',
local: statusDiff ? String(localRow.productStatus ?? '') : String(localRow.created ?? ''),
prod: statusDiff ? String(prodRow.productStatus ?? '') : String(prodRow.created ?? ''),
})
} else {
const field = fieldDiffs[0]
stats.samples.push({
barcode: prodRow.barcode,
field,
local: String(localRow[field] ?? ''),
prod: String(prodRow[field] ?? ''),
})
}
}
if (!apply) continue
const sourceUpdatedAt = new Date(String(prodRow.updated)).toISOString()
const sourceFor = (column: string) => JSON.stringify({
sourceCode: 'PRODUCT_DEVELOPMENT',
sourceSystem: '产品全量档案(正式库)',
sourceSchema: 'public',
sourceObject: 'SupplyChainProductArchive',
sourceColumn: column,
sourceRecordId: prodRow.id,
sourceUpdatedAt,
})
const assignments: string[] = []
const params: unknown[] = [prodRow.barcode]
const sourceSets: Array<{ path: string; paramIndex: number }> = []
const pushValue = (column: string, value: unknown) => {
params.push(value)
assignments.push(`"${column}" = $${params.length}`)
params.push(sourceFor(column))
sourceSets.push({ path: `'{${column}}'`, paramIndex: params.length })
}
if (statusDiff) {
pushValue('productStatus', prodRow.productStatus ?? '')
if ((prodRow.productStatus ?? '') !== '销售') {
assignments.push(`"sabcRating" = ''`)
assignments.push(`"sabcUpdatedAt" = now()`)
}
}
if (createdDiff) {
params.push(prodRow.created)
assignments.push(`"businessCreatedAt" = $${params.length}::timestamp`)
params.push(sourceFor('businessCreatedAt'))
sourceSets.push({ path: `'{businessCreatedAt}'`, paramIndex: params.length })
}
for (const field of fieldDiffs) {
pushValue(field, prodRow[field] ?? '')
}
if (sourceSets.length > 0) {
let expression = '"fieldSourceMap"'
for (const { path, paramIndex } of sourceSets) {
expression = `jsonb_set(${expression}, ${path}, $${paramIndex}::jsonb, true)`
}
assignments.push(`"fieldSourceMap" = ${expression}`)
}
assignments.push(`"updatedAt" = now()`)
await local.query(
`update "SupplyChainProductArchive" set ${assignments.join(', ')} where barcode = $1`,
params,
)
stats.updated += 1
}
}
} finally {
await prod.end()
await local.end()
}
console.log(JSON.stringify({ ...stats, samples: undefined, sampleChanges: stats.samples }, null, 2))
if (!apply) console.log('dry-run:未写库。加 --apply --confirm=' + CONFIRM_TOKEN + ' 才会对齐。')
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,51 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
function value(name: string) {
const prefix = `--${name}=`
return process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? ''
}
function integer(name: string, min: number, max: number) {
const parsed = Number(value(name))
if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new Error(`--${name} must be an integer between ${min} and ${max}.`)
return parsed
}
async function main() {
const year = integer('year', 2020, 2100)
const month = integer('month', 1, 12)
const mode = value('mode') || 'PREVIEW'
if (mode !== 'PREVIEW' && mode !== 'APPLY') throw new Error('--mode must be PREVIEW or APPLY.')
if (mode === 'APPLY' && value('confirm') !== 'run-product-sabc') throw new Error('写入模式必须传 --confirm=run-product-sabc。')
const [{ prisma }, { supplyChainProductSabcService }] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/supply-chain-product-sabc.service'),
])
try {
const result = await supplyChainProductSabcService.calculate({ year, month, apply: mode === 'APPLY' })
const summary = {
mode,
periodStart: result.periodStart,
periodEnd: result.periodEnd,
sourceRows: result.sourceRows,
archiveRows: result.rows.length,
eligibleRows: result.rows.filter((row) => row.row.eligible).length,
taggedRows: result.rows.filter((row) => row.row.sabcRating).length,
ratings: result.rows.reduce<Record<string, number>>((counts, row) => {
if (row.row.sabcRating) counts[row.row.sabcRating] = (counts[row.row.sabcRating] ?? 0) + 1
return counts
}, {}),
}
console.log(JSON.stringify(summary, null, 2))
} finally {
await prisma.$disconnect()
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,244 @@
import { loadEnvConfig } from '@next/env'
import { Prisma } from '@prisma/client'
import { randomUUID } from 'node:crypto'
import * as XLSX from 'xlsx'
loadEnvConfig(process.cwd())
const confirmToken = 'seed-local-product-sabc-xlsx'
const defaultWorkbook = '/Users/a1/Downloads/8月SABC、款效与新品动销监控 (1).xlsx'
function argument(name: string) {
const prefix = `--${name}=`
return process.argv.find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? ''
}
function text(value: unknown) {
return String(value ?? '').trim()
}
function decimal(value: unknown) {
const parsed = Number(value)
return new Prisma.Decimal(Number.isFinite(parsed) ? parsed : 0)
}
function integer(value: unknown) {
const parsed = Number(value)
return BigInt(Number.isFinite(parsed) ? Math.trunc(parsed) : 0)
}
function date(value: unknown) {
if (value instanceof Date && !Number.isNaN(value.getTime())) return value
if (typeof value === 'number') {
const parts = XLSX.SSF.parse_date_code(value)
if (parts) return new Date(Date.UTC(parts.y, parts.m - 1, parts.d, parts.H, parts.M, Math.floor(parts.S)))
}
const parsed = new Date(text(value))
return Number.isNaN(parsed.getTime()) ? new Date('2000-01-01T00:00:00.000Z') : parsed
}
function headers(row: unknown[]) {
return new Map(row.map((value, index) => [text(value).replace(/\s+/g, ''), index]))
}
function column(map: Map<string, number>, label: string) {
const index = map.get(label.replace(/\s+/g, ''))
if (index === undefined) throw new Error(`Excel sheet is missing column: ${label}`)
return index
}
function assertLocalDatabase() {
const databaseUrl = process.env.DATABASE_URL?.trim()
if (!databaseUrl) throw new Error('DATABASE_URL is required')
const url = new URL(databaseUrl)
if (!['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) {
throw new Error(`This seed only allows a local database, received host ${url.hostname}`)
}
}
async function main() {
if (argument('confirm') !== confirmToken) {
throw new Error(`Pass --confirm=${confirmToken} to seed the local test database.`)
}
assertLocalDatabase()
const workbookPath = argument('file') || defaultWorkbook
const workbook = XLSX.readFile(workbookPath, { cellFormula: true })
const archiveRows = XLSX.utils.sheet_to_json<unknown[]>(workbook.Sheets['商品资料辅助'], { header: 1, raw: true, defval: '' })
const detailRows = XLSX.utils.sheet_to_json<unknown[]>(workbook.Sheets['商品明细-所有'], { header: 1, raw: true, defval: '' })
if (!archiveRows.length || !detailRows.length) throw new Error('Excel 中缺少商品资料辅助或商品明细-所有工作表')
const archiveHeaders = headers(archiveRows[0] ?? [])
const detailHeaders = headers(detailRows[0] ?? [])
const archiveColumns = {
spu: column(archiveHeaders, '款式编码'),
sku: column(archiveHeaders, '货号'),
barcode: column(archiveHeaders, '商品编码'),
status: column(archiveHeaders, '生产状态'),
category: column(archiveHeaders, '分类'),
brand: column(archiveHeaders, '品牌'),
supplier: column(archiveHeaders, '供应商名称'),
packType: column(archiveHeaders, '单双包'),
designer: column(archiveHeaders, '设计师'),
name: column(archiveHeaders, '商品名称'),
color: column(archiveHeaders, '颜色'),
spec: column(archiveHeaders, '规格'),
weight: column(archiveHeaders, '重量'),
length: column(archiveHeaders, '长'),
width: column(archiveHeaders, '宽'),
height: column(archiveHeaders, '高'),
volume: column(archiveHeaders, '体积'),
createdAt: column(archiveHeaders, '创建时间'),
itemType: column(archiveHeaders, '商品属性'),
productTag: column(archiveHeaders, '商品标签'),
material: column(archiveHeaders, '材质'),
orderType: column(archiveHeaders, '采购类型'),
image: column(archiveHeaders, '图片'),
costPrice: column(archiveHeaders, '成本价'),
}
const detailColumns = {
barcode: column(detailHeaders, '商品编码'),
status: column(detailHeaders, '生产状态'),
category: column(detailHeaders, '类目分类'),
rating: column(detailHeaders, '26财年版本SABC'),
shipAmount: column(detailHeaders, '8.1-8.31全渠道销售收款总金额'),
realPerformance: column(detailHeaders, '8.1-8.31全渠道真实业绩'),
shipQty: column(detailHeaders, '8.1-8.31全渠道发货件数'),
grossProfit: column(detailHeaders, '8.1-8.31全渠道商品毛利额'),
afterSaleAmount: column(detailHeaders, '8.1-8.31售后金额'),
}
const archives = new Map<string, Prisma.SupplyChainProductArchiveCreateManyInput>()
for (const row of archiveRows.slice(1)) {
const barcode = text(row[archiveColumns.barcode]).toUpperCase()
if (!barcode || text(row[archiveColumns.itemType]) !== '成品') continue
const category = text(row[archiveColumns.category])
archives.set(barcode, {
id: randomUUID(),
barcode,
spu: text(row[archiveColumns.spu]).toUpperCase(),
sku: text(row[archiveColumns.sku]).toUpperCase(),
image: text(row[archiveColumns.image]) || null,
businessCreatedAt: date(row[archiveColumns.createdAt]),
categoryL1: category.split(' - ')[0] ?? '',
categoryL2: category,
productName: text(row[archiveColumns.name]),
designer: text(row[archiveColumns.designer]),
productStatus: text(row[archiveColumns.status]),
material: text(row[archiveColumns.material]),
color: text(row[archiveColumns.color]),
weight: decimal(row[archiveColumns.weight]),
length: decimal(row[archiveColumns.length]),
width: decimal(row[archiveColumns.width]),
height: decimal(row[archiveColumns.height]),
volume: decimal(row[archiveColumns.volume]),
packType: text(row[archiveColumns.packType]),
productAttribute: text(row[archiveColumns.itemType]),
productTag: text(row[archiveColumns.productTag]),
spec: text(row[archiveColumns.spec]),
brand: text(row[archiveColumns.brand]),
orderType: text(row[archiveColumns.orderType]),
supplierName: text(row[archiveColumns.supplier]),
costPrice: text(row[archiveColumns.costPrice]),
firstSourceCode: 'JST_SKU',
})
}
const [{ prisma }, { classifySabc, SABC_SCALE_RULES }] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/supply-chain-product-sabc'),
])
try {
const currentRows = await prisma.supplyChainProductArchive.count()
if (currentRows !== 0) throw new Error(`Local archive is not empty (${currentRows} rows); seed aborted.`)
const start = new Date('2026-08-01T00:00:00.000Z')
const end = new Date('2026-08-31T00:00:00.000Z')
const validCategories = new Set(SABC_SCALE_RULES.map((rule) => rule.category))
const manualRatings = new Map<string, string>()
const detailByBarcode = new Map<string, unknown[]>()
for (const row of detailRows.slice(1)) {
const barcode = text(row[detailColumns.barcode]).toUpperCase()
if (!barcode || !archives.has(barcode) || text(row[detailColumns.status]) !== '销售') continue
detailByBarcode.set(barcode, row)
manualRatings.set(barcode, text(row[detailColumns.rating]))
}
const batchSize = 750
await prisma.$transaction(async (tx) => {
const archiveValues = [...archives.values()]
for (let index = 0; index < archiveValues.length; index += batchSize) {
await tx.supplyChainProductArchive.createMany({ data: archiveValues.slice(index, index + batchSize) })
}
const persisted = await tx.supplyChainProductArchive.findMany({ select: { id: true, barcode: true } })
const archiveIds = new Map(persisted.map((row) => [row.barcode, row.id]))
const sources = persisted.map((row) => ({
id: randomUUID(),
archiveId: row.id,
sourceCode: 'JST_SKU',
entryMode: 'IMPORT',
sourceRecordId: `xlsx:${row.barcode}`,
sourceProductId: text(archives.get(row.barcode)?.spu),
sourceSkuItemId: row.barcode,
sourceSystem: 'August SABC workbook fixture',
sourceSchema: 'xlsx',
sourceObject: '商品资料辅助',
}))
for (let index = 0; index < sources.length; index += batchSize) {
await tx.supplyChainProductArchiveSource.createMany({ data: sources.slice(index, index + batchSize) })
}
const results: Prisma.SupplyChainProductArchiveSabcResultCreateManyInput[] = []
const ratingIds = new Map<string, string[]>()
let mismatches = 0
for (const [barcode, row] of detailByBarcode) {
const archiveId = archiveIds.get(barcode)
const category = text(row[detailColumns.category])
if (!archiveId || !validCategories.has(category)) continue
const realPerformance = decimal(row[detailColumns.realPerformance])
const shipQty = integer(row[detailColumns.shipQty])
const grossProfit = decimal(row[detailColumns.grossProfit])
const afterSaleAmount = decimal(row[detailColumns.afterSaleAmount])
const classification = classifySabc({ category, realPerformance, shipQty, grossProfit, afterSaleAmount, daysInPeriod: 31 }, SABC_SCALE_RULES)
if (classification.rating !== manualRatings.get(barcode)) mismatches += 1
results.push({
id: randomUUID(), archiveId, barcode, periodStart: start, periodEnd: end, eligible: true,
sabcRating: classification.rating, category, performanceScale: classification.performanceScale,
salesScale: classification.salesScale, finalScale: classification.finalScale,
profitLabel: classification.profitLabel, averageDailySales: classification.averageDailySales,
feeRatio: classification.feeRatio, grossMargin: classification.grossMargin,
shipAmount: decimal(row[detailColumns.shipAmount]), realPerformance, shipQty, grossProfit,
afterSaleAmount, status: 'CALCULATED', reason: '',
})
const ids = ratingIds.get(classification.rating) ?? []
ids.push(archiveId)
ratingIds.set(classification.rating, ids)
}
for (let index = 0; index < results.length; index += batchSize) {
await tx.supplyChainProductArchiveSabcResult.createMany({ data: results.slice(index, index + batchSize) })
}
for (const [rating, ids] of ratingIds) {
await tx.supplyChainProductArchive.updateMany({ where: { id: { in: ids } }, data: { sabcRating: rating, sabcUpdatedAt: new Date() } })
}
await tx.supplyChainProductArchiveSabcRun.create({ data: {
id: randomUUID(), periodStart: start, periodEnd: end, mode: 'LOCAL_XLSX', status: 'COMPLETED',
sourceRows: detailByBarcode.size, eligibleRows: detailByBarcode.size, taggedRows: results.length,
comparedRows: results.length, mismatchRows: mismatches, completedAt: new Date(),
} })
}, { timeout: 120_000 })
const [archiveCount, resultCount, mismatchRun] = await Promise.all([
prisma.supplyChainProductArchive.count(),
prisma.supplyChainProductArchiveSabcResult.count({ where: { periodStart: start, periodEnd: end } }),
prisma.supplyChainProductArchiveSabcRun.findUnique({ where: { periodStart_periodEnd_mode: { periodStart: start, periodEnd: end, mode: 'LOCAL_XLSX' } } }),
])
console.log(JSON.stringify({ workbookPath, archiveRows: archiveCount, augustResults: resultCount, ratingMismatches: mismatchRun?.mismatchRows ?? 0 }, null, 2))
} finally {
await prisma.$disconnect()
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,146 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
// 以正式库的类目档位表为准,同步本地类目配置(类目 + 档位阈值)。
// 默认 dry-run;写库需要 --apply --confirm=sync-product-category-config。
import pg from 'pg'
const CONFIRM_TOKEN = 'sync-product-category-config'
function required(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required.`)
return value
}
function flagValue(name: string) {
const prefix = `--${name}=`
return process.argv.slice(2).find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? ''
}
async function main() {
const apply = process.argv.includes('--apply')
if (apply && flagValue('confirm') !== CONFIRM_TOKEN) {
throw new Error(`写库需要 --apply --confirm=${CONFIRM_TOKEN}`)
}
const prod = new pg.Client({
host: required('PROD_ARCHIVE_DB_HOST'),
port: Number(process.env.PROD_ARCHIVE_DB_PORT?.trim() || '25432'),
database: required('PROD_ARCHIVE_DB_NAME'),
user: required('PROD_ARCHIVE_DB_USER'),
password: required('PROD_ARCHIVE_DB_PASSWORD'),
ssl: { rejectUnauthorized: false },
statement_timeout: 60_000,
})
const local = new pg.Client({
connectionString: required('DATABASE_URL'),
statement_timeout: 60_000,
})
await prod.connect()
await local.connect()
try {
const sourceRows = (await prod.query(
`select "categoryL1","categoryL2","categoryGroup","scaleLabel",
"performanceStart","performanceEnd","dailySalesStart","dailySalesEnd","sortOrder"
from public."SupplyChainProductArchiveCategoryScaleRow"
order by "categoryL2","sortOrder"`,
)).rows
const localRows = (await local.query(
`select "categoryL2","scaleLabel" from "SupplyChainProductArchiveCategoryScaleRow"`,
)).rows
const localKeys = new Set(localRows.map((row) => `${row.categoryL2}||${row.scaleLabel}`))
const localConfig = (await local.query(
`select "categoryL2" from "SupplyChainProductArchiveCategoryConfig"`,
)).rows
const configKeys = new Set(localConfig.map((row) => row.categoryL2))
const categories = new Map<string, { categoryL1: string; categoryGroup: string }>()
let newScaleRows = 0
let existingScaleRows = 0
for (const row of sourceRows) {
const key = `${row.categoryL2}||${row.scaleLabel}`
if (localKeys.has(key)) existingScaleRows += 1
else newScaleRows += 1
if (!categories.has(row.categoryL2)) {
categories.set(row.categoryL2, { categoryL1: row.categoryL1, categoryGroup: row.categoryGroup })
}
}
const newCategories = [...categories.keys()].filter((categoryL2) => !configKeys.has(categoryL2))
const summary = {
apply,
sourceScaleRows: sourceRows.length,
sourceCategories: categories.size,
localScaleRows: localRows.length,
newScaleRows,
existingScaleRows,
newCategories: newCategories.length,
newCategoryNames: newCategories.slice(0, 20),
}
if (apply) {
await local.query('BEGIN')
try {
for (const row of sourceRows) {
await local.query(
`insert into "SupplyChainProductArchiveCategoryScaleRow"
(id,"categoryL1","categoryL2","categoryGroup","scaleLabel","performanceStart","performanceEnd",
"dailySalesStart","dailySalesEnd","sortOrder","createdAt","updatedAt")
values (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,now(),now())
on conflict ("categoryL2","scaleLabel") do update set
"categoryL1"=excluded."categoryL1",
"categoryGroup"=excluded."categoryGroup",
"performanceStart"=excluded."performanceStart",
"performanceEnd"=excluded."performanceEnd",
"dailySalesStart"=excluded."dailySalesStart",
"dailySalesEnd"=excluded."dailySalesEnd",
"sortOrder"=excluded."sortOrder",
"updatedAt"=now()`,
[
row.categoryL1,
row.categoryL2,
row.categoryGroup,
row.scaleLabel,
row.performanceStart,
row.performanceEnd,
row.dailySalesStart,
row.dailySalesEnd,
row.sortOrder,
],
)
}
for (const [categoryL2, meta] of categories) {
await local.query(
`insert into "SupplyChainProductArchiveCategoryConfig"
(id,"categoryL2","categoryL1",status,"categoryGroup","scaleLabel","createdAt","updatedAt")
values (gen_random_uuid()::text,$1,$2,'ACTIVE',$3,'',now(),now())
on conflict ("categoryL2") do update set
"categoryL1"=excluded."categoryL1",
"categoryGroup"=excluded."categoryGroup",
"updatedAt"=now()`,
[categoryL2, meta.categoryL1, meta.categoryGroup],
)
}
await local.query('COMMIT')
} catch (error) {
await local.query('ROLLBACK')
throw error
}
}
console.log(JSON.stringify(summary, null, 2))
if (!apply) console.log(`dry-run:未写库。加 --apply --confirm=${CONFIRM_TOKEN} 才会同步。`)
} finally {
await prod.end()
await local.end()
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,95 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
// 同步简尚「替换款管理」换货规则(rpt.pm_replacementManage)到本地映射表。
// 口径:sku = 原编码(老),replaceSku = 替换编码(新);delete=0 且 replaceSku 非空才有效。
// 默认 dry-run;写库需要 --apply --confirm=sync-product-replacement-rules。
import sql from 'mssql'
import pg from 'pg'
const CONFIRM_TOKEN = 'sync-product-replacement-rules'
function required(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required.`)
return value
}
async function main() {
const apply = process.argv.includes('--apply')
if (apply && process.argv.find((a) => a.startsWith('--confirm='))?.slice('--confirm='.length) !== CONFIRM_TOKEN) {
throw new Error(`写库需要 --apply --confirm=${CONFIRM_TOKEN}`)
}
const pool = await new sql.ConnectionPool({
server: required('JST_REPORT_DB_HOST'),
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
database: required('JST_REPORT_DB_NAME'),
user: required('JST_REPORT_DB_USER'),
password: required('JST_REPORT_DB_PASSWORD'),
options: {
encrypt: process.env.JST_REPORT_DB_ENCRYPT?.trim() !== 'false',
trustServerCertificate: process.env.JST_REPORT_DB_TRUST_SERVER_CERTIFICATE?.trim() !== 'false',
},
requestTimeout: 60_000,
}).connect()
const rows = (await pool.request().query(`
SELECT id, sku, replaceSku, replaceType, isSwapping, differentPoints, creator, createTime
FROM rpt.pm_replacementManage
WHERE [delete] = 0
`)).recordset
await pool.close()
const local = new pg.Client({ connectionString: required('DATABASE_URL'), statement_timeout: 120_000 })
await local.connect()
try {
const existing = (await local.query('select count(*)::int as n from "SupplyChainProductArchiveReplacementRule"')).rows[0].n
const summary = {
apply,
sourceRows: rows.length,
localRowsBefore: existing,
emptyReplaceSku: rows.filter((row) => !String(row.replaceSku ?? '').trim()).length,
}
if (apply) {
await local.query('BEGIN')
try {
await local.query('TRUNCATE "SupplyChainProductArchiveReplacementRule"')
for (const row of rows) {
await local.query(
`insert into "SupplyChainProductArchiveReplacementRule"
(id,"sourceId",sku,"replaceSku","replaceType","isSwapping","differentPoints",creator,"sourceCreatedAt","syncedAt")
values (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,now())`,
[
row.id,
String(row.sku ?? '').trim(),
String(row.replaceSku ?? '').trim(),
Number(row.replaceType ?? 0),
Number(row.isSwapping ?? 0),
String(row.differentPoints ?? '').trim(),
String(row.creator ?? '').trim(),
row.createTime ? new Date(row.createTime).toISOString() : null,
],
)
}
await local.query('COMMIT')
} catch (error) {
await local.query('ROLLBACK')
throw error
}
const after = (await local.query('select count(*)::int as n from "SupplyChainProductArchiveReplacementRule"')).rows[0].n
console.log(JSON.stringify({ ...summary, localRowsAfter: after }, null, 2))
} else {
console.log(JSON.stringify(summary, null, 2))
console.log(`dry-run:未写库。加 --apply --confirm=${CONFIRM_TOKEN} 才会同步。`)
}
} finally {
await local.end()
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { formatProductArchiveDateTime } from '@/lib/product-archive-date-time'
import { SABC_PERCENT_FIELDS, SABC_TWO_DECIMAL_FIELDS, sabcSpreadsheetValue } from '@/lib/supply-chain-product-sabc-format'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { SABC_FILTER_FIELDS, supplyChainProductSabcService, type SabcPageRow } from '@/server/services/supply-chain-product-sabc.service'
const querySchema = z.object({
year: z.coerce.number().int().min(2020).max(2100),
month: z.coerce.number().int().min(1).max(12),
field: z.enum(SABC_FILTER_FIELDS).default('productCode'),
keyword: z.string().trim().max(4000).optional(),
rating: z.enum(['S', 'A', 'B', 'C', 'D']).optional(),
itemNumber: z.string().trim().max(4000).optional(),
productCode: z.string().trim().max(4000).optional(),
styleCode: z.string().trim().max(4000).optional(),
categoryClass: z.string().trim().max(4000).optional(),
opsHead: z.string().trim().max(4000).optional(),
opsDeptHead: z.string().trim().max(4000).optional(),
purchaser: z.string().trim().max(4000).optional(),
designer: z.string().trim().max(4000).optional(),
})
const baseColumns: Array<[keyof SabcPageRow, string]> = [
['styleCode', '款式编码'], ['itemNumber', '货号'], ['productCode', '商品编码'], ['productionStatus', '生产状态'],
['category', '分类'], ['brand', '品牌'],
['opsDirector', '运营总负责人'], ['opsDeptHead', '运营部门负责人'], ['opsHead', '运营负责人'],
['supplier', '供应商'], ['purchaser', '采购'], ['designer', '设计师'],
['createTime', '创建时间'], ['createDays', '创建天数'],
['categoryClass', '类目分类'], ['newOldLabel', '新老品标签'], ['newProductRange', '新品区间分布'], ['newProductPeriod', '新品期'],
['newProductStatus', '是否在新品期内'], ['movingStatus', '是否动销'], ['averageDailySales', '当月平均日销'], ['feeRatio', '当月部分费用占比'],
['performanceScale', '业绩规模'], ['salesScale', '销量规模'], ['finalScale', '最终核算规模'], ['profitLabel', '利润标签-新'],
['sabcRating', 'SABC标签'], ['shipAmount', '当月全渠道销售收款总金额'], ['realPerformance', '当月全渠道真实业绩'],
['shipQty', '当月全渠道发货件数'], ['grossProfit', '当月全渠道商品毛利额'], ['afterSaleAmount', '当月售后金额'],
['grossMargin', '结算毛利率'], ['afterSaleRatio', '售后占比'], ['assessmentStatus', '是否考核动销'],
['noAssessmentReason', '不考核原因'], ['sameProductCode', '同款商品编码(业绩&费比合并计算)'],
]
function columnsForMonth(year: number, month: number) {
const end = new Date(Date.UTC(year, month, 0)).getUTCDate()
const period = `${month}.1-${month}.${end}`
return baseColumns.map(([key, label]) => {
if (key === 'averageDailySales') return [key, `${month}月平均日销`] as [keyof SabcPageRow, string]
if (key === 'feeRatio') return [key, `${month}月部分费用占比`] as [keyof SabcPageRow, string]
const labels: Partial<Record<keyof SabcPageRow, string>> = {
shipAmount: '全渠道销售收款总金额',
realPerformance: '全渠道真实业绩',
shipQty: '全渠道发货件数',
grossProfit: '全渠道商品毛利额',
afterSaleAmount: '售后金额',
}
if (labels[key]) return [key, `${period}${labels[key]}`] as [keyof SabcPageRow, string]
return [key, label] as [keyof SabcPageRow, string]
})
}
function buildContentDisposition(fileName: string) {
return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
}
function timestamp() {
return new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'businessManagement')
if (!access.download) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId)
}
const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'SABC 导出参数不正确', 400, ctx.requestId)
}
const { itemNumber, productCode, styleCode, categoryClass, opsHead, opsDeptHead, purchaser, designer, ...rest } = parsed.data
const filters = { itemNumber, productCode, styleCode, categoryClass, opsHead, opsDeptHead, purchaser, designer }
const rows: SabcPageRow[] = await supplyChainProductSabcService.listExportRows({ ...rest, filters })
const columns = columnsForMonth(parsed.data.year, parsed.data.month)
const sheetRows = rows.map((row) => Object.fromEntries(
columns.map(([key, label]) => [label, key === 'createTime' && row[key]
? formatProductArchiveDateTime(String(row[key]))
: sabcSpreadsheetValue(key, row[key])]),
))
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.json_to_sheet(sheetRows, { header: columns.map(([, label]) => label) })
columns.forEach(([key], columnIndex) => {
if (!SABC_PERCENT_FIELDS.has(key) && !SABC_TWO_DECIMAL_FIELDS.has(key)) return
const numberFormat = SABC_PERCENT_FIELDS.has(key) ? '0.00%' : '0.00'
for (let rowIndex = 1; rowIndex <= rows.length; rowIndex += 1) {
const cell = worksheet[XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex })]
if (cell?.t === 'n') cell.z = numberFormat
}
})
XLSX.utils.book_append_sheet(workbook, worksheet, '商品SABC打标')
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
return new NextResponse(new Uint8Array(content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': buildContentDisposition(`商品SABC打标-${parsed.data.year}年${parsed.data.month}月-${timestamp()}.xlsx`),
'Cache-Control': 'private, no-store, max-age=0',
'X-Exported-Row-Count': String(rows.length),
'X-Request-Id': ctx.requestId,
},
})
}, { requireAuth: true })
}

View File

@ -0,0 +1,33 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { SABC_FILTER_OPTION_FIELDS, supplyChainProductSabcService } from '@/server/services/supply-chain-product-sabc.service'
const querySchema = z.object({
year: z.coerce.number().int().min(2020).max(2100),
month: z.coerce.number().int().min(1).max(12),
field: z.enum(SABC_FILTER_OPTION_FIELDS),
q: z.string().trim().max(100).optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'businessManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有商品 SABC 查看权限', 403, ctx.requestId)
}
const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选候选参数不正确', 400, ctx.requestId)
}
const options = await supplyChainProductSabcService.listFilterOptions({
year: parsed.data.year,
month: parsed.data.month,
field: parsed.data.field,
query: parsed.data.q,
})
return jsonSuccess({ options }, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,77 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { SABC_FILTER_FIELDS, supplyChainProductSabcService } from '@/server/services/supply-chain-product-sabc.service'
const querySchema = z.object({
year: z.coerce.number().int().min(2020).max(2100),
month: z.coerce.number().int().min(1).max(12),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
field: z.enum(SABC_FILTER_FIELDS).default('productCode'),
keyword: z.string().trim().max(4000).optional(),
rating: z.enum(['S', 'A', 'B', 'C', 'D']).optional(),
itemNumber: z.string().trim().max(4000).optional(),
productCode: z.string().trim().max(4000).optional(),
styleCode: z.string().trim().max(4000).optional(),
categoryClass: z.string().trim().max(4000).optional(),
opsHead: z.string().trim().max(4000).optional(),
opsDeptHead: z.string().trim().max(4000).optional(),
purchaser: z.string().trim().max(4000).optional(),
designer: z.string().trim().max(4000).optional(),
})
const calculateSchema = z.object({
year: z.coerce.number().int().min(2020).max(2100),
month: z.coerce.number().int().min(1).max(12),
mode: z.enum(['PREVIEW', 'APPLY']).default('PREVIEW'),
}).strict()
async function checkAccess(user: Parameters<typeof supplyChainPermissionSettingsService.getUserAccess>[0], action: 'view' | 'edit') {
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'businessManagement')
return action === 'view' ? access.view : access.edit
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
if (!(await checkAccess(ctx.user, 'view'))) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有商品 SABC 查看权限', 403, ctx.requestId)
}
const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'SABC 查询参数不正确', 400, ctx.requestId)
const { itemNumber, productCode, styleCode, categoryClass, opsHead, opsDeptHead, purchaser, designer, ...rest } = parsed.data
const filters = { itemNumber, productCode, styleCode, categoryClass, opsHead, opsDeptHead, purchaser, designer }
return jsonSuccess(await supplyChainProductSabcService.listPage({ ...rest, filters }), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = calculateSchema.safeParse(body)
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'SABC 核算参数不正确', 400, ctx.requestId)
const canEdit = await checkAccess(ctx.user, 'edit')
if (parsed.data.mode === 'APPLY' && !canEdit) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有执行 SABC 核算权限', 403, ctx.requestId)
}
if (!canEdit && parsed.data.mode === 'PREVIEW') {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有执行 SABC 试算权限', 403, ctx.requestId)
}
const result = await supplyChainProductSabcService.calculate({
year: parsed.data.year,
month: parsed.data.month,
apply: parsed.data.mode === 'APPLY',
})
return jsonSuccess({
periodStart: result.periodStart,
periodEnd: result.periodEnd,
sourceRows: result.sourceRows,
eligibleRows: result.rows.filter((row) => row.row.assessmentStatus === '考核').length,
taggedRows: result.rows.filter((row) => row.row.sabcRating).length,
rows: result.rows.slice(0, 200).map((row) => row.row),
mode: parsed.data.mode,
}, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,29 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { supplyChainProductSabcService } from '@/server/services/supply-chain-product-sabc.service'
const querySchema = z.object({
year: z.coerce.number().int().min(2020).max(2100),
month: z.coerce.number().int().min(1).max(12),
dimension: z.enum(['categoryClass', 'opsDeptHead', 'opsHead', 'purchaser']).optional(),
fresh: z.coerce.boolean().optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'businessManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有商品 SABC 查看权限', 403, ctx.requestId)
}
const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '经营总览参数不正确', 400, ctx.requestId)
}
const { dimension, fresh, ...period } = parsed.data
if (!dimension) return jsonSuccess(await supplyChainProductSabcService.summary({ ...period, fresh }), 200, ctx.requestId)
return jsonSuccess(await supplyChainProductSabcService.summaryWithDimension({ ...period, dimension, fresh }), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,27 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { supplyChainProductSabcService } from '@/server/services/supply-chain-product-sabc.service'
const querySchema = z.object({
year: z.coerce.number().int().min(2020).max(2100),
month: z.coerce.number().int().min(1).max(12),
months: z.coerce.number().int().min(2).max(12).optional(),
fresh: z.coerce.boolean().optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'businessManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有商品 SABC 查看权限', 403, ctx.requestId)
}
const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '趋势参数不正确', 400, ctx.requestId)
}
return jsonSuccess(await supplyChainProductSabcService.trend(parsed.data), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -133,7 +133,7 @@ describe('supply chain menu', () => {
route: '/supply-chain/management/blank-pages/blank-page-2', route: '/supply-chain/management/blank-pages/blank-page-2',
}), }),
expect.objectContaining({ expect.objectContaining({
label: '空白页 3', label: '商品SABC打标',
module: '经营管理', module: '经营管理',
route: '/supply-chain/management/blank-pages/blank-page-3', route: '/supply-chain/management/blank-pages/blank-page-3',
}), }),

View File

@ -1,5 +1,6 @@
import { memo } from 'react' import { memo } from 'react'
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive' import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
import { formatProductArchiveDateTime } from '@/lib/product-archive-date-time'
import type { ArchiveColumnGroup } from './columns' import type { ArchiveColumnGroup } from './columns'
import { ProductArchiveActions } from './ProductArchiveActions' import { ProductArchiveActions } from './ProductArchiveActions'
import { ProductArchiveMaterialImageCell } from './ProductArchiveMaterialImageCell' import { ProductArchiveMaterialImageCell } from './ProductArchiveMaterialImageCell'
@ -64,21 +65,6 @@ function areTableBodyPropsEqual(
&& hasSameColumnLayout(previous.columns, next.columns) && hasSameColumnLayout(previous.columns, next.columns)
} }
function formatDateTime(value: string) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
function renderCell( function renderCell(
row: SupplyChainProductArchiveRow, row: SupplyChainProductArchiveRow,
prop: string, prop: string,
@ -98,7 +84,7 @@ function renderCell(
} }
if (prop === 'createTime' || prop === 'updateTime') { if (prop === 'createTime' || prop === 'updateTime') {
return formatDateTime(row[prop]) return formatProductArchiveDateTime(row[prop])
} }
return row[prop as keyof SupplyChainProductArchiveRow] return row[prop as keyof SupplyChainProductArchiveRow]

View File

@ -0,0 +1,341 @@
'use client'
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'
import { Download, RefreshCw, RotateCcw, Search } from 'lucide-react'
import type { ManagementBlankPageDefinition } from '@/lib/supply-chain-management-blank-pages'
import { formatProductArchiveDateTime } from '@/lib/product-archive-date-time'
import { formatSabcDisplayValue } from '@/lib/supply-chain-product-sabc-format'
import { SabcOverviewSection } from './SabcOverviewSection'
import styles from './management-blank-page.module.css'
type SabcRow = Record<string, string | boolean>
type PageInfo = { page: number; pageSize: number; total: number; totalPages: number }
type FilterField = string
const baseColumns: Array<[string, string]> = [
['styleCode', '款式编码'], ['itemNumber', '货号'], ['productCode', '商品编码'], ['productionStatus', '生产状态'],
['category', '分类'], ['brand', '品牌'],
['opsDirector', '运营总负责人'], ['opsDeptHead', '运营部门负责人'], ['opsHead', '运营负责人'],
['supplier', '供应商'], ['purchaser', '采购'], ['designer', '设计师'],
['createTime', '创建时间'], ['createDays', '创建天数'],
['categoryClass', '类目分类'], ['newOldLabel', '新老品标签'], ['newProductRange', '新品区间分布'], ['newProductPeriod', '新品期'],
['newProductStatus', '是否在新品期内'], ['movingStatus', '是否动销'], ['averageDailySales', '当月平均日销'], ['feeRatio', '当月部分费用占比'],
['performanceScale', '业绩规模'], ['salesScale', '销量规模'], ['finalScale', '最终核算规模'], ['profitLabel', '利润标签-新'],
['sabcRating', 'SABC标签'], ['shipAmount', '当月全渠道销售收款总金额'], ['realPerformance', '当月全渠道真实业绩'],
['shipQty', '当月全渠道发货件数'], ['grossProfit', '当月全渠道商品毛利额'], ['afterSaleAmount', '当月售后金额'],
['grossMargin', '结算毛利率'], ['afterSaleRatio', '售后占比'], ['assessmentStatus', '是否考核动销'],
['noAssessmentReason', '不考核原因'], ['sameProductCode', '同款商品编码(业绩&费比合并计算)'],
]
const FIELD_FILTERS: Array<[string, string]> = [
['itemNumber', '货号'], ['productCode', '商品编码'], ['styleCode', '款式编码'], ['categoryClass', '类目分类'],
['opsHead', '运营负责人'], ['opsDeptHead', '运营部门负责人'], ['purchaser', '采购'], ['designer', '设计师'],
]
const EMPTY_FIELD_INPUTS = Object.fromEntries(FIELD_FILTERS.map(([key]) => [key, ''])) as Record<string, string>
function columnsForMonth(year: number, month: number) {
const end = new Date(Date.UTC(year, month, 0)).getUTCDate()
const prefix = `${month}月`
const period = `${month}.1-${month}.${end}`
return baseColumns.map(([key, label]) => {
if (key === 'averageDailySales') return [key, `${prefix}平均日销`] as [string, string]
if (key === 'feeRatio') return [key, `${prefix}部分费用占比`] as [string, string]
if (key === 'shipAmount') return [key, `${period}全渠道销售收款总金额`] as [string, string]
if (key === 'realPerformance') return [key, `${period}全渠道真实业绩`] as [string, string]
if (key === 'shipQty') return [key, `${period}全渠道发货件数`] as [string, string]
if (key === 'grossProfit') return [key, `${period}全渠道商品毛利额`] as [string, string]
if (key === 'afterSaleAmount') return [key, `${period}售后金额`] as [string, string]
return [key, label] as [string, string]
})
}
function previousMonth() {
const date = new Date()
date.setMonth(date.getMonth() - 1)
return { year: date.getFullYear(), month: date.getMonth() + 1 }
}
async function readResponse(response: Response) {
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败')
return payload.data
}
function FilterFieldCombo({ label, field, value, year, month, onChange }: {
label: string
field: string
value: string
year: number
month: number
onChange: (value: string) => void
}) {
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const lastToken = value.split(/[\n\r,,;;]/).pop()?.trim() ?? ''
const visibleOptions = options.length > 1000 ? options.slice(0, 1000) : options
useEffect(() => {
if (!open) return undefined
let cancelled = false
const timer = setTimeout(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ year: String(year), month: String(month), field, q: lastToken })
const data = await readResponse(await fetch(`/api/supply-chain/product-sabc/options?${params}`, { cache: 'no-store' }))
if (!cancelled) setOptions(Array.isArray(data?.options) ? data.options : [])
} catch {
if (!cancelled) setOptions([])
} finally {
if (!cancelled) setLoading(false)
}
}, 200)
return () => { cancelled = true; clearTimeout(timer) }
}, [open, field, lastToken, month, year])
useEffect(() => {
if (!open) return undefined
const close = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
if (!target?.closest(`[data-sabc-combo="${field}"]`)) setOpen(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [open, field])
const selectOption = (option: string) => {
const parts = value.split(/[\n\r,,;;]/).map((part) => part.trim()).filter(Boolean)
if (lastToken) parts.pop()
if (!parts.includes(option)) parts.push(option)
onChange(parts.join('\n'))
setOpen(false)
}
return (
<label
className={styles.sabcSearchField}
data-sabc-combo={field}
style={{ width: `${Math.max(132, (label.length + 2) * 24)}px` }}
>
<span className={styles.sabcCombo}>
<textarea
aria-label={label}
value={value}
rows={1}
placeholder={label}
title="支持批量:换行/逗号分隔"
onFocus={() => setOpen(true)}
onChange={(event) => onChange(event.target.value)}
/>
<button
type="button"
className={styles.sabcComboToggle}
aria-label={`选择${label}`}
title="下拉选择"
onClick={() => setOpen((current) => !current)}
>
▾
</button>
{open ? (
<div className={styles.sabcComboMenu}>
{loading ? <div className={styles.sabcComboEmpty}>加载中…</div> : options.length === 0 ? <div className={styles.sabcComboEmpty}>无可选项</div> : (
<>
{visibleOptions.map((option) => (
<button key={option} type="button" onClick={() => selectOption(option)}>{option}</button>
))}
{options.length > visibleOptions.length ? (
<div className={styles.sabcComboEmpty}>共 {options.length} 项,已显示前 {visibleOptions.length} 项,请继续输入缩小范围</div>
) : null}
</>
)}
</div>
) : null}
</span>
</label>
)
}
export function ProductSabcView({ definition, canManage, canDownload }: { definition: ManagementBlankPageDefinition; canManage: boolean; canDownload: boolean }) {
const initial = useMemo(() => previousMonth(), [])
const [year, setYear] = useState(initial.year)
const [month, setMonth] = useState(initial.month)
const [filterInputs, setFilterInputs] = useState<Record<string, string>>(() => ({ ...EMPTY_FIELD_INPUTS, rating: '' }))
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(() => ({ ...EMPTY_FIELD_INPUTS, rating: '' }))
const [rows, setRows] = useState<SabcRow[]>([])
const [pageInfo, setPageInfo] = useState<PageInfo>({ page: 1, pageSize: 50, total: 0, totalPages: 1 })
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [salesDataAvailable, setSalesDataAvailable] = useState(true)
const columns = useMemo(() => columnsForMonth(year, month), [month, year])
const load = useCallback(async (page = 1) => {
setLoading(true)
setError('')
try {
const params = new URLSearchParams({ year: String(year), month: String(month), page: String(page), pageSize: '50' })
for (const [key, raw] of Object.entries(appliedFilters)) {
if (key === 'rating') continue
const value = String(raw ?? '').trim()
if (value) params.set(key, value)
}
if (appliedFilters.rating) params.set('rating', appliedFilters.rating)
const data = await readResponse(await fetch(`/api/supply-chain/product-sabc?${params}`, { cache: 'no-store' }))
setRows(data.rows)
setPageInfo(data.pageInfo)
setSalesDataAvailable(data.salesDataAvailable !== false)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '加载失败')
} finally {
setLoading(false)
}
}, [appliedFilters, month, year])
useEffect(() => { void load() }, [load])
const calculate = async (mode: 'PREVIEW' | 'APPLY') => {
setLoading(true)
setError('')
setMessage('')
try {
const data = await readResponse(await fetch('/api/supply-chain/product-sabc', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ year, month, mode }),
}))
setMessage(`${mode === 'PREVIEW' ? '试算' : '核算'}完成:来源 ${data.sourceRows} 条,符合条件 ${data.eligibleRows} 条,生成标签 ${data.taggedRows} 条`)
await load()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '核算失败')
} finally {
setLoading(false)
}
}
const exportRows = async () => {
if (exporting) return
setExporting(true)
setError('')
try {
const params = new URLSearchParams({ year: String(year), month: String(month) })
for (const [key, raw] of Object.entries(appliedFilters)) {
if (key === 'rating') continue
const value = String(raw ?? '').trim()
if (value) params.set(key, value)
}
if (appliedFilters.rating) params.set('rating', appliedFilters.rating)
const response = await fetch(`/api/supply-chain/product-sabc/export?${params}`, { cache: 'no-store' })
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error?.message || '导出失败')
}
const blob = await response.blob()
const disposition = response.headers.get('Content-Disposition') || ''
const filenameMatch = /filename\*=UTF-8''([^;]+)/.exec(disposition)
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : `商品SABC打标-${year}年${month}月.xlsx`
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
setMessage(`导出完成:${response.headers.get('X-Exported-Row-Count') || '全部'} 条`)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '导出失败')
} finally {
setExporting(false)
}
}
const submitFilters = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setAppliedFilters({ ...filterInputs })
}
const resetFilters = () => {
const empty = { ...EMPTY_FIELD_INPUTS, rating: '' }
setFilterInputs(empty)
setAppliedFilters(empty)
}
// 点击经营总览里的标签卡/维度行时,联动下方明细筛选。
const applyOverviewFilter = useCallback((patch: Record<string, string>) => {
setFilterInputs((current) => ({ ...current, ...patch }))
setAppliedFilters((current) => ({ ...current, ...patch }))
}, [])
return (
<main className={styles.page}>
<header className={styles.header}>
<div><h1>{definition.label}</h1><p>SupplyChainProductArchive 商品主表 · 月度自动核算</p></div>
</header>
<div className={`${styles.toolbar} ${styles.sabcToolbar}`}>
<form className={styles.sabcFilterForm} onSubmit={submitFilters}>
<div className={styles.sabcFilterHead}>
<div className={styles.sabcFilterHeadLeft}>
<label className={styles.sabcMonthField}>
<span>核算月份</span>
<span className={styles.sabcMonthInputs}>
<input type="number" min={2020} max={2100} aria-label="核算年份" value={year} onChange={(event) => setYear(Number(event.target.value))} />
<b>年</b>
<input type="number" min={1} max={12} aria-label="核算月份" value={month} onChange={(event) => setMonth(Number(event.target.value))} />
<b>月</b>
</span>
</label>
{canManage ? <button type="button" className={styles.secondaryButton} onClick={() => void calculate('PREVIEW')} disabled={loading}><RefreshCw size={15} />{month}月试算</button> : null}
{canManage ? <button type="button" className={styles.primaryButton} onClick={() => void calculate('APPLY')} disabled={loading}><RefreshCw size={15} />执行核算</button> : null}
</div>
<div className={styles.sabcSearchActions}>
<button type="submit" className={styles.secondaryButton} title="应用筛选"><Search size={15} />查询</button>
<button type="button" className={styles.secondaryButton} title="清空筛选" onClick={resetFilters}><RotateCcw size={15} />重置</button>
{canDownload ? <button type="button" className={styles.secondaryButton} onClick={() => void exportRows()} disabled={loading || exporting}><Download size={15} />{exporting ? '导出中' : '导出'}</button> : null}
</div>
</div>
<div className={styles.sabcSearch}>
{FIELD_FILTERS.map(([key, label]) => (
<FilterFieldCombo
key={key}
field={key}
label={label}
value={filterInputs[key] ?? ''}
year={year}
month={month}
onChange={(value) => setFilterInputs((current) => ({ ...current, [key]: value }))}
/>
))}
<label className={styles.sabcSearchField} style={{ width: '140px' }}>
<select aria-label="筛选SABC" value={filterInputs.rating} onChange={(event) => setFilterInputs((current) => ({ ...current, rating: event.target.value }))}>
<option value="">全部标签</option><option value="S">S</option><option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
</select>
</label>
</div>
</form>
</div>
{error ? <div className={styles.noticeError}>{error}</div> : null}
{!error && !salesDataAvailable ? <div className={styles.noticeInfo}>{year}年{month}月销售事实尚未同步,销售指标和 SABC 标签暂为空;请先完成产品全量档案销售数据同步。</div> : null}
<SabcOverviewSection year={year} month={month} onFilter={applyOverviewFilter} />
<div className={styles.tableWrap}>
<table className={styles.tableWide}>
<thead><tr>{columns.map(([, label]) => <th key={label}>{label}</th>)}</tr></thead>
<tbody>
{rows.map((row) => <tr key={String(row.productCode)}>{columns.map(([key]) => <td key={key} className={key === 'sabcRating' ? `${styles.sabcCell} ${styles[`sabc${String(row[key] || '')}`] ?? ''}` : undefined}>{key === 'createTime' && row[key] ? formatProductArchiveDateTime(String(row[key])) : formatSabcDisplayValue(key, row[key])}</td>)}</tr>)}
{!loading && rows.length === 0 ? <tr><td className={styles.emptyCell} colSpan={columns.length}>暂无数据</td></tr> : null}
</tbody>
</table>
</div>
<footer className={styles.pagination}>
<div className={styles.paginationSummary}>
<span>共 {pageInfo.total} 条</span>
{message ? <span className={styles.paginationMessage}>{message}</span> : null}
</div>
<div className={styles.paginationControls}>
<button type="button" className={styles.secondaryButton} disabled={pageInfo.page <= 1 || loading} onClick={() => void load(pageInfo.page - 1)}>上一页</button>
<span>第 {pageInfo.page} / {pageInfo.totalPages} 页</span>
<button type="button" className={styles.secondaryButton} disabled={pageInfo.page >= pageInfo.totalPages || loading} onClick={() => void load(pageInfo.page + 1)}>下一页</button>
</div>
</footer>
</main>
)
}

View File

@ -0,0 +1,271 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronUp, RefreshCw } from 'lucide-react'
import styles from './sabc-overview-section.module.css'
type RatingSummary = {
tag: string
count: number
countRatio: number
realPerformance: number
performanceRatio: number
shipQty: number
movingCount: number
}
type Summary = {
year: number
month: number
rowTotal: number
tagged: number
untagged: number
totals: { realPerformance: number; shipAmount: number; shipQty: number }
ratings: RatingSummary[]
}
type DimensionKey = 'categoryClass' | 'opsDeptHead' | 'purchaser'
type DimensionSummary = {
dimension: DimensionKey
totalReal: number
groups: Array<{
key: string
count: number
realPerformance: number
performanceRatio: number
ratings: Record<string, { count: number; realPerformance: number }>
}>
}
type TrendMonth = {
year: number
month: number
rowTotal: number
tagged: number
realPerformance: number
strongRatio: number
}
const DIMENSION_OPTIONS: Array<[DimensionKey, string]> = [
['categoryClass', '类目分类'],
['opsDeptHead', '运营部门负责人'],
['purchaser', '采购'],
]
const TAG_COLORS: Record<string, { main: string; soft: string }> = {
S: { main: '#16a34a', soft: '#dcfce7' },
A: { main: '#ca8a04', soft: '#fef9c3' },
B: { main: '#2563eb', soft: '#dbeafe' },
C: { main: '#ea580c', soft: '#ffedd5' },
D: { main: '#64748b', soft: '#e2e8f0' },
}
function wan(value: number) {
return (value / 10000).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function percent(value: number) {
return `${(value * 100).toFixed(2)}%`
}
export function SabcOverviewSection({ year, month, onFilter }: {
year: number
month: number
onFilter?: (patch: Record<string, string>) => void
}) {
const [data, setData] = useState<Summary | null>(null)
const [dimension, setDimension] = useState<DimensionKey>('categoryClass')
const [dimensionData, setDimensionData] = useState<DimensionSummary | null>(null)
const [trend, setTrend] = useState<TrendMonth[] | null>(null)
const [trendLoading, setTrendLoading] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [collapsed, setCollapsed] = useState(false)
const load = useCallback(async (fresh = false) => {
setLoading(true)
setError('')
try {
const freshParam = fresh ? '&fresh=1' : ''
const response = await fetch(`/api/supply-chain/product-sabc/summary?year=${year}&month=${month}&dimension=${dimension}${freshParam}`, { cache: 'no-store' })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '加载失败')
setData(payload.data)
setDimensionData(payload.data?.dimension ?? null)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '加载失败')
} finally {
setLoading(false)
}
}, [dimension, month, year])
useEffect(() => { void load() }, [load])
const loadTrend = async () => {
setTrendLoading(true)
try {
const response = await fetch(`/api/supply-chain/product-sabc/trend?year=${year}&month=${month}&months=6&fresh=1`, { cache: 'no-store' })
const payload = await response.json().catch(() => null)
if (response.ok && payload?.success) setTrend(payload.data?.months ?? [])
} finally {
setTrendLoading(false)
}
}
const maxRatio = Math.max(
0.0001,
...(data?.ratings ?? []).flatMap((rating) => [rating.countRatio, rating.performanceRatio]),
)
const maxPerformance = Math.max(0.0001, ...(data?.ratings ?? []).map((rating) => rating.realPerformance))
const maxTrend = Math.max(0.0001, ...(trend ?? []).map((item) => item.realPerformance))
return (
<section className={styles.section}>
<div className={styles.head}>
<div className={styles.headTitle}>
<h2>经营总览</h2>
<span>按 SABC 标签对比:商品数占比 vs 业绩占比</span>
{data ? <span className={styles.headMeta}>{data.year}年{data.month}月 · 共 {data.rowTotal.toLocaleString('zh-CN')} 条 · 真实业绩 ¥{wan(data.totals.realPerformance)}万</span> : null}
</div>
<div className={styles.headActions}>
<button type="button" className={styles.iconButton} title="刷新总览(取最新数据)" onClick={() => void load(true)} disabled={loading}><RefreshCw size={14} /></button>
<button type="button" className={styles.iconButton} title={collapsed ? '展开总览' : '收起总览'} onClick={() => setCollapsed((current) => !current)}>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
</div>
{error ? <div className={styles.error}>{error}</div> : null}
{!collapsed && data ? (
<div className={styles.body}>
<div className={styles.cards}>
{data.ratings.map((rating) => {
const color = TAG_COLORS[rating.tag] ?? TAG_COLORS.D
return (
<article
key={rating.tag}
className={`${styles.card} ${onFilter ? styles.cardClickable : ''}`}
style={{ background: color.soft, borderColor: color.main }}
title={onFilter ? `点击只看 ${rating.tag} 的明细` : undefined}
onClick={onFilter ? () => onFilter({ rating: rating.tag }) : undefined}
>
<div className={styles.cardHead}>
<span className={styles.cardTag} style={{ background: color.main }}>{rating.tag}</span>
<span className={styles.cardCount}>{rating.count.toLocaleString('zh-CN')}<i>个</i></span>
</div>
<div className={styles.cardMeta}>商品数占比 <b>{percent(rating.countRatio)}</b></div>
</article>
)
})}
</div>
<div className={styles.compare}>
{data.ratings.map((rating) => {
const color = TAG_COLORS[rating.tag] ?? TAG_COLORS.D
return (
<div key={rating.tag} className={styles.compareRow}>
<span className={styles.compareTag} style={{ background: color.main }}>{rating.tag}</span>
<div className={styles.compareBars}>
<div className={styles.barLine}>
<span className={styles.barLabel}>真实业绩</span>
<div className={styles.barTrack}><div className={styles.barFillPure} style={{ width: `${(rating.realPerformance / maxPerformance) * 100}%`, background: color.main }} /></div>
<span className={styles.barValue}>{wan(rating.realPerformance)}万</span>
</div>
<div className={styles.barLine}>
<span className={styles.barLabel}>业绩占比</span>
<div className={styles.barTrack}><div className={styles.barFillStrong} style={{ width: `${(rating.performanceRatio / maxRatio) * 100}%`, background: color.main }} /></div>
<span className={styles.barValue}>{percent(rating.performanceRatio)}</span>
</div>
</div>
</div>
)
})}
</div>
<div className={styles.extra}>
<div className={styles.extraBlock}>
<div className={styles.extraHead}>
<h3>按维度对比</h3>
<select className={styles.dimensionSelect} value={dimension} onChange={(event) => setDimension(event.target.value as DimensionKey)}>
{DIMENSION_OPTIONS.map(([key, label]) => <option key={key} value={key}>{label}</option>)}
</select>
</div>
<div className={styles.dimTableWrap}>
<table className={styles.dimTable}>
<thead>
<tr>
<th>{DIMENSION_OPTIONS.find(([key]) => key === dimension)?.[1] ?? '维度'}</th>
{(['S', 'A', 'B', 'C', 'D'] as const).map((tag) => (
<th key={tag}>{tag}</th>
))}
<th>真实业绩</th>
<th>业绩占比</th>
</tr>
</thead>
<tbody>
{dimensionData?.groups.map((group) => {
const clickable = Boolean(onFilter) && !group.key.startsWith('其他(')
return (
<tr
key={group.key}
className={clickable ? styles.dimTableRowClickable : undefined}
title={clickable ? `点击只看「${group.key}」的明细` : undefined}
onClick={clickable ? () => onFilter?.({ [dimension]: group.key }) : undefined}
>
<td className={styles.dimTableName} title={group.key}>{group.key}</td>
{(['S', 'A', 'B', 'C', 'D'] as const).map((tag) => (
<td key={tag}>
{(group.ratings[tag]?.count ?? 0).toLocaleString('zh-CN')}
</td>
))}
<td className={styles.dimTableMoney}>¥{wan(group.realPerformance)}万</td>
<td className={styles.dimTableRatio}>{percent(group.performanceRatio)}</td>
</tr>
)
})}
{dimensionData ? (
<tr className={styles.dimTableTotal}>
<td>合计</td>
{(['S', 'A', 'B', 'C', 'D'] as const).map((tag) => (
<td key={tag}>
{dimensionData.groups.reduce((sum, group) => sum + (group.ratings[tag]?.count ?? 0), 0).toLocaleString('zh-CN')}
</td>
))}
<td className={styles.dimTableMoney}>¥{wan(dimensionData.totalReal)}万</td>
<td className={styles.dimTableRatio}>100.00%</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<div className={styles.extraBlock}>
<div className={styles.extraHead}>
<h3>近 6 个月趋势</h3>
{trend ? (
<button type="button" className={styles.textButton} onClick={() => setTrend(null)}>收起</button>
) : (
<button type="button" className={styles.textButton} onClick={() => void loadTrend()} disabled={trendLoading}>
{trendLoading ? '加载中…' : '加载趋势'}
</button>
)}
</div>
{trend ? trend.map((item) => (
<div key={`${item.year}-${item.month}`} className={styles.trendRow}>
<span className={styles.trendLabel}>{item.year}年{item.month}月</span>
<div className={styles.barTrack}>
<div className={styles.barFillStrong} style={{ width: `${(item.realPerformance / maxTrend) * 100}%`, background: '#0f172a' }} />
</div>
<span className={styles.trendValue}>¥{wan(item.realPerformance)}万</span>
<span className={styles.trendStrong}>S+A {percent(item.strongRatio)}</span>
</div>
)) : (
<div className={styles.trendHint}>点击「加载趋势」查看近 6 个月的真实业绩与 S+A 占比变化。</div>
)}
</div>
</div>
</div>
) : null}
</section>
)
}

View File

@ -3,6 +3,7 @@ import { requireSession } from '@/lib/server-session'
import { getManagementBlankPage } from '@/lib/supply-chain-management-blank-pages' import { getManagementBlankPage } from '@/lib/supply-chain-management-blank-pages'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { ManagementBlankPageView } from '../ManagementBlankPageView' import { ManagementBlankPageView } from '../ManagementBlankPageView'
import { ProductSabcView } from '../ProductSabcView'
export default async function ManagementBlankPage({ params }: { params: Promise<{ pageKey: string }> }) { export default async function ManagementBlankPage({ params }: { params: Promise<{ pageKey: string }> }) {
const definition = getManagementBlankPage((await params).pageKey) const definition = getManagementBlankPage((await params).pageKey)
@ -15,6 +16,10 @@ export default async function ManagementBlankPage({ params }: { params: Promise<
) )
if (!access.view) redirect('/supply-chain') if (!access.view) redirect('/supply-chain')
if (definition.key === 'blank-page-3') {
return <ProductSabcView definition={definition} canManage={access.edit} canDownload={access.download} />
}
return ( return (
<ManagementBlankPageView <ManagementBlankPageView
definition={definition} definition={definition}

View File

@ -17,6 +17,9 @@ export type ManagementBlankPageField = {
const TITLE_MAX_LENGTH = 20 const TITLE_MAX_LENGTH = 20
const SETTINGS_CHANGED_EVENT = 'supply-chain-management-blank-page-settings-changed' const SETTINGS_CHANGED_EVENT = 'supply-chain-management-blank-page-settings-changed'
const FIELDS_MAX_COUNT = 20 const FIELDS_MAX_COUNT = 20
const LEGACY_PAGE_TITLES: Record<string, readonly string[]> = {
'blank-page-3': ['特殊入库&特殊备货流程'],
}
const DEFAULT_FIELDS: ManagementBlankPageField[] = [ const DEFAULT_FIELDS: ManagementBlankPageField[] = [
{ id: 'name', label: '名称', builtinKey: 'name', visible: true }, { id: 'name', label: '名称', builtinKey: 'name', visible: true },
@ -40,11 +43,17 @@ function cleanTitle(value: unknown, fallback: string) {
function readTitles(): ManagementBlankPageTitles { function readTitles(): ManagementBlankPageTitles {
return Object.fromEntries(MANAGEMENT_BLANK_PAGES.map((page) => { return Object.fromEntries(MANAGEMENT_BLANK_PAGES.map((page) => {
const storageKey = managementBlankPageSettingsStorageKey(page.key)
try { try {
const stored = JSON.parse( const stored = JSON.parse(
window.localStorage.getItem(managementBlankPageSettingsStorageKey(page.key)) ?? 'null', window.localStorage.getItem(storageKey) ?? 'null',
) as { pageTitle?: unknown } | null ) as { pageTitle?: unknown } | null
return [page.key, cleanTitle(stored?.pageTitle, page.label)] const pageTitle = cleanTitle(stored?.pageTitle, page.label)
if (LEGACY_PAGE_TITLES[page.key]?.includes(pageTitle)) {
window.localStorage.removeItem(storageKey)
return [page.key, page.label]
}
return [page.key, pageTitle]
} catch { } catch {
return [page.key, page.label] return [page.key, page.label]
} }

View File

@ -0,0 +1,34 @@
.section{flex:none;margin-top:8px;border-top:1px solid var(--sc-border);border-bottom:1px solid var(--sc-border);background:#fff}
.head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 24px}
.headTitle{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.headTitle h2{margin:0;font-size:14px;font-weight:700;color:#0f172a}
.headTitle span{color:#64748b;font-size:12px}
.headMeta{color:#94a3b8!important}
.headActions{display:flex;align-items:center;gap:6px}
.iconButton{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center;border:1px solid #e2e8f0;border-radius:6px;background:#fff;color:#475569;cursor:pointer}
.iconButton:hover{background:#f8fafc}
.error{margin:0 24px 10px;border:1px solid #fecaca;border-radius:8px;background:#fff7f7;padding:8px 12px;color:#b91c1c;font-size:12px}
.body{display:flex;flex-direction:column;gap:10px;padding:0 24px 12px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.card{display:flex;flex-direction:column;gap:4px;border:1px solid;border-radius:10px;padding:10px 12px}
.cardHead{display:flex;align-items:center;justify-content:space-between}
.cardTag{display:inline-flex;width:24px;height:24px;align-items:center;justify-content:center;border-radius:7px;color:#fff;font-size:12px;font-weight:700}
.cardCount{font-size:20px;font-weight:700}
.cardCount i{margin-left:2px;font-size:11px;font-style:normal;font-weight:400;color:#64748b}
.cardMeta{display:flex;align-items:center;justify-content:space-between;color:#475569;font-size:11px}
.cardMeta b{font-size:12px}
.compare{display:flex;flex-direction:column;border:1px solid #eef2f7;border-radius:10px;padding:6px 10px}
.compareRow{display:flex;align-items:center;gap:12px;padding:5px 0;border-top:1px dashed #eef2f7}
.compareRow:first-child{border-top:0}
.compareTag{display:inline-flex;width:24px;height:24px;align-items:center;justify-content:center;border-radius:7px;color:#fff;font-size:12px;font-weight:700}
.compareBars{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}
.barLine{display:flex;align-items:center;gap:8px}
.barLabel{width:56px;color:#64748b;font-size:11px}
.barTrack{position:relative;flex:1;height:10px;overflow:hidden;border-radius:5px;background:#f1f5f9}
.barFillPure,.barFillStrong{height:100%;border-radius:5px;transition:width .3s ease}
.barFillPure{opacity:.4}
.barFillStrong{opacity:1}
.barValue{width:80px;text-align:right;color:#334155;font-size:11px;font-variant-numeric:tabular-nums}
.compareMoney{width:140px;text-align:right;color:#0f172a;font-size:12px;font-weight:600;font-variant-numeric:tabular-nums}
@media(max-width:900px){.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.compareMoney{width:104px}}
.extra{display:grid;grid-template-columns:1fr;gap:10px}.extraBlock{border:1px solid #eef2f7;border-radius:10px;padding:8px 10px;min-width:0}.extraHead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.extraHead h3{margin:0;font-size:12px;font-weight:700;color:#0f172a}.dimensionSelect{height:26px;border:1px solid #e2e8f0;border-radius:6px;background:#fff;padding:0 6px;color:#334155;font-size:12px}.textButton{border:0;background:transparent;color:#2563eb;font-size:12px;cursor:pointer;padding:2px 4px}.textButton:disabled{color:#94a3b8;cursor:default}.dimRow{display:flex;align-items:center;gap:8px;padding:3px 0;font-size:11px;color:#475569}.dimKey{width:112px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.stackBar{display:flex;flex:1;height:10px;overflow:hidden;border-radius:5px;background:#f1f5f9}.stackSegment{height:100%}.dimCount{width:58px;text-align:right;color:#64748b}.dimMoney{width:118px;text-align:right;font-weight:600;color:#0f172a;font-variant-numeric:tabular-nums}.dimRatio{width:52px;text-align:right;color:#334155;font-variant-numeric:tabular-nums}.trendRow{display:flex;align-items:center;gap:8px;padding:3px 0;font-size:11px;color:#475569}.trendLabel{width:70px;color:#64748b}.trendValue{width:118px;text-align:right;font-weight:600;color:#0f172a;font-variant-numeric:tabular-nums}.trendStrong{width:86px;text-align:right;color:#16a34a;font-weight:600}.trendHint{padding:6px 0;color:#94a3b8;font-size:11px}@media(max-width:1100px){.extra{grid-template-columns:1fr}}.cardClickable{cursor:pointer;transition:transform .12s ease,box-shadow .12s ease}.cardClickable:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(15,23,42,.08)}.dimRowClickable{cursor:pointer;border-radius:6px}.dimRowClickable:hover{background:#f8fafc}.dimTableWrap{max-height:340px;overflow:auto}.dimTable{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}.dimTable th{position:sticky;top:0;z-index:1;height:30px;border-bottom:1px solid #e2e8f0;background:#f8fafc;padding:0 8px;color:#475569;font-weight:600;text-align:right;white-space:nowrap}.dimTable th:first-child{text-align:left}.dimTable td{height:30px;border-bottom:1px solid #f1f5f9;padding:0 8px;color:#334155;text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.dimTableName{max-width:240px;overflow:hidden;text-overflow:ellipsis;text-align:left!important}.dimTableCount{font-weight:600}.dimTableMoney{font-weight:600;color:#0f172a}.dimTableRowClickable{cursor:pointer}.dimTableRowClickable:hover td{background:#f8fafc}.dimTableTotal td{background:#f8fafc;font-weight:700;color:#0f172a}

View File

@ -0,0 +1,5 @@
import { formatProductArchiveDateTime } from './product-archive-date-time'
it('keeps archive timestamps on the source calendar date', () => {
expect(formatProductArchiveDateTime('2026-08-31T16:30:00.000Z')).toBe('2026/08/31 16:30')
})

View File

@ -0,0 +1,16 @@
export function formatProductArchiveDateTime(value: string | Date) {
if (!value) return ''
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
hourCycle: 'h23',
timeZone: 'UTC',
}).format(date)
}

View File

@ -9,7 +9,7 @@ describe('经营管理空白页注册表', () => {
expect(MANAGEMENT_BLANK_PAGES.map((page) => page.label)).toEqual([ expect(MANAGEMENT_BLANK_PAGES.map((page) => page.label)).toEqual([
'空白页 1', '空白页 1',
'空白页 2', '空白页 2',
'空白页 3', '商品SABC打标',
]) ])
expect(getManagementBlankPage('blank-page-2')?.route) expect(getManagementBlankPage('blank-page-2')?.route)
.toBe('/supply-chain/management/blank-pages/blank-page-2') .toBe('/supply-chain/management/blank-pages/blank-page-2')

View File

@ -17,7 +17,7 @@ export const MANAGEMENT_BLANK_PAGES: readonly ManagementBlankPageDefinition[] =
}, },
{ {
key: 'blank-page-3', key: 'blank-page-3',
label: '空白页 3', label: '商品SABC打标',
route: '/supply-chain/management/blank-pages/blank-page-3', route: '/supply-chain/management/blank-pages/blank-page-3',
}, },
] ]

View File

@ -384,6 +384,9 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
action: pathname.endsWith('/image-search') ? 'view' as const : action, action: pathname.endsWith('/image-search') ? 'view' as const : action,
} }
} }
if (pathname.startsWith('/api/supply-chain/product-sabc')) {
return { targetKey: 'businessManagement' as const, action }
}
if (pathname.startsWith('/api/supply-chain/category-config')) { if (pathname.startsWith('/api/supply-chain/category-config')) {
return { return {
targetKey: 'categoryConfig' as const, targetKey: 'categoryConfig' as const,

View File

@ -55,6 +55,7 @@ export const JST_SKU_ARCHIVE_FIELD_COLUMNS = {
height: 'h', height: 'h',
volume: 'l*w*h', volume: 'l*w*h',
packType: 'other_8', packType: 'other_8',
productAttribute: 'item_type',
spec: 'name', spec: 'name',
} as const } as const
@ -77,6 +78,7 @@ export const PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS = {
height: 'package_height_cm', height: 'package_height_cm',
volume: 'package_length_cm*package_width_cm*package_height_cm', volume: 'package_length_cm*package_width_cm*package_height_cm',
packType: 'package_set_count', packType: 'package_set_count',
productAttribute: 'item_type',
productTag: 'product_tag', productTag: 'product_tag',
installDocId: 'drawing_id', installDocId: 'drawing_id',
spec: 'archive_spec', spec: 'archive_spec',
@ -95,9 +97,9 @@ export const DRAWING_MEDIA_ARCHIVE_FIELD_COLUMNS = {
} as const } as const
const BUSINESS_CREATED_AT_SOURCE_PRIORITY: Record<string, number> = { const BUSINESS_CREATED_AT_SOURCE_PRIORITY: Record<string, number> = {
[PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE]: 10, [PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE]: 30,
[JST_SKU_ARCHIVE_SOURCE_CODE]: 20, [JST_SKU_ARCHIVE_SOURCE_CODE]: 20,
[YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE]: 30, [YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE]: 40,
} }
export function shouldApplyProductArchiveFieldSource(input: { export function shouldApplyProductArchiveFieldSource(input: {

View File

@ -0,0 +1,30 @@
import {
formatSabcDisplayValue,
sabcSpreadsheetValue,
} from './supply-chain-product-sabc-format'
describe('SABC value formatting', () => {
it('formats ratio fields as percentages', () => {
expect(formatSabcDisplayValue('grossMargin', '0.123456')).toBe('12.35%')
expect(formatSabcDisplayValue('afterSaleRatio', '0')).toBe('0.00%')
expect(formatSabcDisplayValue('feeRatio', '')).toBe('')
})
it('keeps requested numeric fields at two decimal places', () => {
expect(formatSabcDisplayValue('averageDailySales', '1.235')).toBe('1.24')
expect(formatSabcDisplayValue('realPerformance', '100')).toBe('100.00')
expect(formatSabcDisplayValue('grossProfit', '-8.126')).toBe('-8.13')
expect(formatSabcDisplayValue('afterSaleAmount', '')).toBe('')
})
it('exports formatted fields as numeric values', () => {
expect(sabcSpreadsheetValue('grossMargin', '0.123456')).toBe(0.123456)
expect(sabcSpreadsheetValue('realPerformance', '100.1234')).toBe(100.1234)
expect(sabcSpreadsheetValue('productCode', '00123')).toBe('00123')
})
it('keeps non-numeric markers such as 异常 in the export', () => {
expect(sabcSpreadsheetValue('feeRatio', '异常')).toBe('异常')
expect(formatSabcDisplayValue('feeRatio', '异常')).toBe('异常')
})
})

View File

@ -0,0 +1,32 @@
export const SABC_TWO_DECIMAL_FIELDS = new Set([
'averageDailySales',
'realPerformance',
'grossProfit',
'afterSaleAmount',
])
export const SABC_PERCENT_FIELDS = new Set([
'feeRatio',
'grossMargin',
'afterSaleRatio',
])
function numericValue(value: unknown) {
if (value === null || value === undefined || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
}
export function formatSabcDisplayValue(key: string, value: unknown) {
const number = numericValue(value)
if (number === null) return String(value ?? '')
if (SABC_PERCENT_FIELDS.has(key)) return `${(number * 100).toFixed(2)}%`
if (SABC_TWO_DECIMAL_FIELDS.has(key)) return number.toFixed(2)
return String(value)
}
export function sabcSpreadsheetValue(key: string, value: unknown) {
if (!SABC_PERCENT_FIELDS.has(key) && !SABC_TWO_DECIMAL_FIELDS.has(key)) return value ?? ''
const number = numericValue(value)
return number === null ? (value ?? '') : number
}

View File

@ -93,4 +93,55 @@ describe('product-development archive image source boundary', () => {
}), }),
) )
}) })
it('clears a stale SABC rating when the archive becomes retired', async () => {
const tx = {
supplyChainProductArchive: {
findUnique: jest.fn().mockResolvedValue({
firstSourceCode: 'PRODUCT_DEVELOPMENT',
productStatus: '销售',
productAttribute: '成品',
sabcRating: 'A',
fieldSourceMap: {},
}),
upsert: jest.fn().mockResolvedValue({ id: 'archive-1' }),
},
supplyChainProductArchiveSource: {
upsert: jest.fn().mockResolvedValue({}),
},
}
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
await supplyChainProductArchiveSyncRepository.upsertProductDevelopmentArchive({
barcode: 'BARCODE-1',
archive: {
barcode: 'BARCODE-1',
productStatus: '退市',
productAttribute: '成品',
firstSourceCode: 'PRODUCT_DEVELOPMENT',
fieldSourceMap: {},
},
source: {
sourceCode: 'PRODUCT_DEVELOPMENT',
entryMode: 'AUTO',
sourceRecordId: 'ROW-1',
sourceProductId: 'PRODUCT-1',
sourceSkuItemId: 'SKU-ITEM-1',
sourceSystem: 'product-development',
sourceSchema: 'external',
sourceObject: 'product_catalog_wide_v1',
sourceCreatedAt: new Date('2026-08-12T00:00:00.000Z'),
},
})
expect(tx.supplyChainProductArchive.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
productStatus: '退市',
sabcRating: '',
sabcUpdatedAt: expect.any(Date),
}),
}),
)
})
}) })

View File

@ -121,7 +121,7 @@ class JstSkuProductArchiveSourceRepositoryClass
l, l,
w, w,
h, h,
TODATETIMEOFFSET(created, '+08:00') AS created, created,
modified, modified,
local_updated local_updated
FROM [${schema}].[jst_sku] FROM [${schema}].[jst_sku]
@ -168,7 +168,7 @@ class JstSkuProductArchiveSourceRepositoryClass
l, l,
w, w,
h, h,
TODATETIMEOFFSET(created, '+08:00') AS created, created,
modified, modified,
local_updated local_updated
FROM [${schema}].[jst_sku] FROM [${schema}].[jst_sku]

View File

@ -21,6 +21,7 @@ export type JstSkuProductArchivePatch = {
sku?: string sku?: string
productName?: string productName?: string
categoryL1?: string categoryL1?: string
productAttribute?: string
productTag?: string productTag?: string
brand?: string brand?: string
supplierName?: string supplierName?: string
@ -87,6 +88,7 @@ const currentArchiveSelect = {
height: true, height: true,
volume: true, volume: true,
packType: true, packType: true,
productAttribute: true,
productTag: true, productTag: true,
installDocId: true, installDocId: true,
spec: true, spec: true,
@ -99,6 +101,7 @@ const currentArchiveSelect = {
inOutWarehouse: true, inOutWarehouse: true,
purchaseOwner: true, purchaseOwner: true,
minimumOrderQuantity: true, minimumOrderQuantity: true,
sabcRating: true,
fieldSourceMap: true, fieldSourceMap: true,
sources: { sources: {
where: { sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE }, where: { sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE },
@ -202,11 +205,13 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
) )
: {} : {}
const incomingFieldSources = { ...input.fieldSourceMap } const incomingFieldSources = { ...input.fieldSourceMap }
const shouldApplyBusinessCreatedAt = shouldApplyProductArchiveFieldSource({ const shouldApplyBusinessCreatedAt = previous === null
field: 'businessCreatedAt', ? true
currentSourceCode: currentFieldSources.businessCreatedAt?.sourceCode, : shouldApplyProductArchiveFieldSource({
incomingSourceCode: JST_SKU_ARCHIVE_SOURCE_CODE, field: 'businessCreatedAt',
}) currentSourceCode: currentFieldSources.businessCreatedAt?.sourceCode,
incomingSourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
}) && !currentFieldSources.businessCreatedAt
if (shouldApplyBusinessCreatedAt) { if (shouldApplyBusinessCreatedAt) {
incomingFieldSources.businessCreatedAt = { incomingFieldSources.businessCreatedAt = {
...incomingFieldSources.barcode, ...incomingFieldSources.barcode,
@ -238,6 +243,12 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
const sourceMapChanged = const sourceMapChanged =
JSON.stringify(parseProductArchiveFieldSourceMap(previous?.fieldSourceMap)) !== JSON.stringify(parseProductArchiveFieldSourceMap(previous?.fieldSourceMap)) !==
JSON.stringify(nextFieldSources) JSON.stringify(nextFieldSources)
const nextProductStatus = input.patch.productStatus ?? previous?.productStatus ?? ''
const nextProductAttribute = input.patch.productAttribute ?? previous?.productAttribute ?? ''
const shouldClearSabcRating = Boolean(
previous?.sabcRating &&
(nextProductStatus !== '销售' || nextProductAttribute !== '成品'),
)
let archiveId: string let archiveId: string
let status: JstSkuProductArchiveUpsertResult['status'] let status: JstSkuProductArchiveUpsertResult['status']
@ -256,7 +267,7 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
status = 'created' status = 'created'
} else { } else {
archiveId = previous.id archiveId = previous.id
if (changedFields.length > 0 || sourceMapChanged) { if (changedFields.length > 0 || sourceMapChanged || shouldClearSabcRating) {
await tx.supplyChainProductArchive.update({ await tx.supplyChainProductArchive.update({
where: { id: previous.id }, where: { id: previous.id },
data: { data: {
@ -264,6 +275,9 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
...(shouldUpdateBusinessCreatedAt ...(shouldUpdateBusinessCreatedAt
? { businessCreatedAt: input.businessCreatedAt } ? { businessCreatedAt: input.businessCreatedAt }
: {}), : {}),
...(shouldClearSabcRating
? { sabcRating: '', sabcUpdatedAt: new Date() }
: {}),
fieldSourceMap: toProductArchiveFieldSourceJson(nextFieldSources), fieldSourceMap: toProductArchiveFieldSourceJson(nextFieldSources),
}, },
}) })

View File

@ -185,3 +185,38 @@ export const supplyChainProductArchiveCategoryScaleRowRepository =
new SupplyChainProductArchiveCategoryScaleRowRepositoryClass() new SupplyChainProductArchiveCategoryScaleRowRepositoryClass()
export { SupplyChainProductArchiveCategoryScaleRowRepositoryClass } export { SupplyChainProductArchiveCategoryScaleRowRepositoryClass }
// SABC 核算使用的「启用类目 + 档位」扁平列表(保留旧接口,按 ACTIVE 类目过滤)。
export type ProductArchiveCategoryScaleRow = {
categoryL1: string
categoryL2: string
categoryGroup: string
scaleLabel: string
performanceStart: string | number
performanceEnd: string | number | null
dailySalesStart: string | number
dailySalesEnd: string | number | null
sortOrder: number
}
export async function listActiveProductArchiveCategoryScaleRows(): Promise<ProductArchiveCategoryScaleRow[]> {
return prisma.$queryRaw<ProductArchiveCategoryScaleRow[]>`
SELECT
scale."categoryL1" AS "categoryL1",
scale."categoryL2" AS "categoryL2",
scale."categoryGroup" AS "categoryGroup",
scale."scaleLabel" AS "scaleLabel",
scale."performanceStart" AS "performanceStart",
scale."performanceEnd" AS "performanceEnd",
scale."dailySalesStart" AS "dailySalesStart",
scale."dailySalesEnd" AS "dailySalesEnd",
scale."sortOrder" AS "sortOrder"
FROM "SupplyChainProductArchiveCategoryScaleRow" AS scale
INNER JOIN "SupplyChainProductArchiveCategoryConfig" AS config
ON config."categoryL2" = scale."categoryL2"
WHERE config."status" = 'ACTIVE'
AND scale."performanceStart" IS NOT NULL
AND scale."dailySalesStart" IS NOT NULL
ORDER BY scale."categoryL2" ASC, scale."sortOrder" ASC
`
}

View File

@ -0,0 +1,30 @@
import { prisma } from '@/lib/prisma'
export type ProductArchiveReplacementRule = {
sku: string
replaceSku: string
sourceCreatedAt: string | null
sourceId: number
}
// 简尚「替换款管理」换货规则:sku = 原编码(老),replaceSku = 替换编码(新)。
export async function listProductArchiveReplacementRules(): Promise<ProductArchiveReplacementRule[]> {
try {
return await prisma.$queryRaw<ProductArchiveReplacementRule[]>`
SELECT sku, "replaceSku", "sourceCreatedAt"::text AS "sourceCreatedAt", "sourceId"
FROM "SupplyChainProductArchiveReplacementRule"
WHERE "replaceSku" <> ''
ORDER BY "sourceCreatedAt" ASC NULLS LAST, "sourceId" ASC
`
} catch (error) {
const failure = error && typeof error === 'object'
? error as { code?: string; meta?: { driverAdapterError?: { cause?: { originalCode?: string } } }; message?: string }
: null
if (failure?.code === '42P01'
|| failure?.meta?.driverAdapterError?.cause?.originalCode === '42P01'
|| failure?.message?.includes('SupplyChainProductArchiveReplacementRule')) {
return []
}
throw error
}
}

View File

@ -1,8 +1,15 @@
/** @jest-environment node */ /** @jest-environment node */
jest.mock('@/lib/prisma', () => ({ prisma: {} })) jest.mock('@/lib/prisma', () => ({ prisma: { $queryRaw: jest.fn() } }))
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { matchesPerformanceFilters, performanceText, type PeriodPerformance } from './supply-chain-product-archive-sales-period.repository' import { prisma } from '@/lib/prisma'
import {
listProductArchiveSalesAggregates,
matchesPerformanceFilters,
performanceText,
type PeriodPerformance,
} from './supply-chain-product-archive-sales-period.repository'
import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE as empty } from '@/lib/supply-chain-product-archive' import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE as empty } from '@/lib/supply-chain-product-archive'
const queryRaw = prisma.$queryRaw as jest.Mock
const value: PeriodPerformance = { const value: PeriodPerformance = {
shipAmount: new Prisma.Decimal('8.8400'), returnAmount: new Prisma.Decimal(0), salesAmount: new Prisma.Decimal('10.5'), shipAmount: new Prisma.Decimal('8.8400'), returnAmount: new Prisma.Decimal(0), salesAmount: new Prisma.Decimal('10.5'),
afterSaleAmount: new Prisma.Decimal('2.25'), afterSaleAmount: new Prisma.Decimal('2.25'),
@ -40,3 +47,26 @@ it('omits only the candidate column filter', () => {
expect(matchesPerformanceFilters(value, { shipAmount: ['999'], shipQty: ['1'] }, 'shipAmount')).toBe(true) expect(matchesPerformanceFilters(value, { shipAmount: ['999'], shipQty: ['1'] }, 'shipAmount')).toBe(true)
expect(matchesPerformanceFilters(value, { shipAmount: ['999'], shipQty: ['2'] }, 'shipAmount')).toBe(false) expect(matchesPerformanceFilters(value, { shipAmount: ['999'], shipQty: ['2'] }, 'shipAmount')).toBe(false)
}) })
it('aggregates the product archive daily facts for the selected month', async () => {
const aggregate = {
barcode: 'SKU-1', shipAmount: '100', returnAmount: '5', realPerformance: '95',
clearSalesCost: '40', sendFreight: '10', platformCommission: '1', xyCost: '2',
afterSaleAmount: '3', pushFeeTotal: '4', marketingFeeTotal: '5', advertisementFeeTotal: '6',
sharedWarehousingSum: '7', otherFee: '8', operatingFee: '9', shipQty: '2', returnQty: '1',
}
queryRaw.mockResolvedValueOnce([aggregate])
await expect(listProductArchiveSalesAggregates({
periodStart: new Date('2026-08-01T00:00:00.000Z'),
periodEnd: new Date('2026-08-31T00:00:00.000Z'),
})).resolves.toEqual([aggregate])
const sql = queryRaw.mock.calls[0][0] as Prisma.Sql
expect(sql.sql).toContain('FROM "SupplyChainProductArchiveSalesPerformanceDaily"')
expect(sql.sql).toContain('SUM("afterSaleAmount")')
expect(sql.values).toEqual([
new Date('2026-08-01T00:00:00.000Z'),
new Date('2026-08-31T00:00:00.000Z'),
])
})

View File

@ -23,6 +23,26 @@ export type PeriodPerformance = NonNullable<ProductArchiveRecord['salesPerforman
afterSaleAmount: Prisma.Decimal afterSaleAmount: Prisma.Decimal
} }
const PERIOD_AGGREGATE_MONEY_FIELDS = [
'salesAmount', 'shipAmount', 'returnAmount', 'realPerformance', 'clearSalesCost', 'sendFreight',
'platformCommission', 'xyCost', 'afterSaleAmount', 'pushFeeTotal', 'marketingFeeTotal',
'advertisementFeeTotal', 'sharedWarehousingSum', 'otherFee', 'operatingFee',
] as const
export async function listProductArchiveSalesAggregates(input: {
periodStart: Date
periodEnd: Date
}) {
return prisma.$queryRaw<ProductArchiveSalesPerformanceAggregate[]>(Prisma.sql`
SELECT "barcode",
${Prisma.join([...PERIOD_AGGREGATE_MONEY_FIELDS, 'shipQty', 'returnQty'].map((key) =>
Prisma.sql`SUM(${Prisma.raw('"' + key + '"')})::text AS ${Prisma.raw('"' + key + '"')}`))}
FROM "SupplyChainProductArchiveSalesPerformanceDaily"
WHERE "statDate" >= ${input.periodStart}::date AND "statDate" <= ${input.periodEnd}::date
GROUP BY "barcode"
`)
}
export async function readSalesCoverage(): Promise<SalesCoverage> { export async function readSalesCoverage(): Promise<SalesCoverage> {
const [row] = await prisma.$queryRaw<SalesCoverage[]>(Prisma.sql` const [row] = await prisma.$queryRaw<SalesCoverage[]>(Prisma.sql`
SELECT MIN("statDate")::text AS "availableFrom", MAX("statDate")::text AS "availableThrough", SELECT MIN("statDate")::text AS "availableFrom", MAX("statDate")::text AS "availableThrough",
@ -67,17 +87,10 @@ export async function listPeriodPerformance(filters: ProductArchiveFilters = {})
const period = resolveSalesPeriod(filters, coverage) const period = resolveSalesPeriod(filters, coverage)
const performanceByBarcode = new Map<string, PeriodPerformance>() const performanceByBarcode = new Map<string, PeriodPerformance>()
if (!period.hasCoverage) return { period, performanceByBarcode } if (!period.hasCoverage) return { period, performanceByBarcode }
const money = ['shipAmount', 'returnAmount', 'salesAmount', 'realPerformance', 'clearSalesCost', 'sendFreight', const aggregates = await listProductArchiveSalesAggregates({
'platformCommission', 'xyCost', 'afterSaleAmount', 'pushFeeTotal', 'marketingFeeTotal', periodStart: new Date(`${period.start}T00:00:00.000Z`),
'advertisementFeeTotal', 'sharedWarehousingSum', 'otherFee', 'operatingFee'] as const periodEnd: new Date(`${period.end}T00:00:00.000Z`),
const aggregates = await prisma.$queryRaw<ProductArchiveSalesPerformanceAggregate[]>(Prisma.sql` })
SELECT "barcode",
${Prisma.join([...money, 'shipQty', 'returnQty'].map((key) =>
Prisma.sql`SUM(${Prisma.raw('"' + key + '"')})::text AS ${Prisma.raw('"' + key + '"')}`))}
FROM "SupplyChainProductArchiveSalesPerformanceDaily"
WHERE "statDate" >= ${period.start}::date AND "statDate" <= ${period.end}::date
GROUP BY "barcode"
`)
for (const aggregate of aggregates) { for (const aggregate of aggregates) {
const value = calculateProductArchiveSalesPerformance(aggregate) const value = calculateProductArchiveSalesPerformance(aggregate)
performanceByBarcode.set(value.barcode, { performanceByBarcode.set(value.barcode, {

View File

@ -29,6 +29,7 @@ export type ProductCatalogWideArchiveRecord = {
archive_spu: string | null archive_spu: string | null
archive_sku: string | null archive_sku: string | null
archive_product_name: string | null archive_product_name: string | null
item_type: string | null
archive_image: string | null archive_image: string | null
archive_designer: string | null archive_designer: string | null
sales_category_name: string | null sales_category_name: string | null
@ -107,6 +108,7 @@ const archiveComparableSelect = {
height: true, height: true,
volume: true, volume: true,
packType: true, packType: true,
productAttribute: true,
productTag: true, productTag: true,
installDocId: true, installDocId: true,
hasDrawing: true, hasDrawing: true,
@ -121,6 +123,7 @@ const archiveComparableSelect = {
supplierName: true, supplierName: true,
purchaseOwner: true, purchaseOwner: true,
minimumOrderQuantity: true, minimumOrderQuantity: true,
sabcRating: true,
fieldSourceMap: true, fieldSourceMap: true,
} satisfies Prisma.SupplyChainProductArchiveSelect } satisfies Prisma.SupplyChainProductArchiveSelect
@ -180,6 +183,7 @@ function buildArchiveUpdate(
height: archive.height, height: archive.height,
volume: archive.volume, volume: archive.volume,
packType: archive.packType, packType: archive.packType,
productAttribute: archive.productAttribute,
productTag: archive.productTag, productTag: archive.productTag,
installDocId: archive.installDocId, installDocId: archive.installDocId,
spec: archive.spec, spec: archive.spec,
@ -250,6 +254,7 @@ class SupplyChainProductArchiveSyncRepositoryClass
archive_spu, archive_spu,
archive_sku, archive_sku,
archive_product_name, archive_product_name,
item_type,
archive_image, archive_image,
archive_designer, archive_designer,
sales_category_name, sales_category_name,
@ -334,6 +339,19 @@ class SupplyChainProductArchiveSyncRepositoryClass
previous?.fieldSourceMap, previous?.fieldSourceMap,
allowImageUpdate, allowImageUpdate,
) )
const nextProductStatus = typeof archiveUpdate.productStatus === 'string'
? archiveUpdate.productStatus
: previous?.productStatus ?? ''
const nextProductAttribute = typeof archiveUpdate.productAttribute === 'string'
? archiveUpdate.productAttribute
: previous?.productAttribute ?? ''
if (
previous?.sabcRating &&
(nextProductStatus !== '销售' || nextProductAttribute !== '成品')
) {
archiveUpdate.sabcRating = ''
archiveUpdate.sabcUpdatedAt = new Date()
}
const archive = await tx.supplyChainProductArchive.upsert({ const archive = await tx.supplyChainProductArchive.upsert({
where: { barcode: input.barcode }, where: { barcode: input.barcode },
create: input.archive, create: input.archive,

View File

@ -147,6 +147,7 @@ export function mapJstSkuProductArchiveSource(
addTextField(patch, fieldColumns, 'spu', text(record.i_id).toUpperCase(), 'i_id') addTextField(patch, fieldColumns, 'spu', text(record.i_id).toUpperCase(), 'i_id')
addTextField(patch, fieldColumns, 'sku', text(record.other_7).toUpperCase(), 'other_7') addTextField(patch, fieldColumns, 'sku', text(record.other_7).toUpperCase(), 'other_7')
addTextField(patch, fieldColumns, 'productName', record.name, 'name') addTextField(patch, fieldColumns, 'productName', record.name, 'name')
addTextField(patch, fieldColumns, 'productAttribute', '成品', 'item_type')
addTextField(patch, fieldColumns, 'productTag', record.labels, 'labels') addTextField(patch, fieldColumns, 'productTag', record.labels, 'labels')
addTextField(patch, fieldColumns, 'brand', record.brand, 'brand') addTextField(patch, fieldColumns, 'brand', record.brand, 'brand')
addTextField(patch, fieldColumns, 'supplierName', record.supplier_name, 'supplier_name') addTextField(patch, fieldColumns, 'supplierName', record.supplier_name, 'supplier_name')

View File

@ -156,6 +156,7 @@ export function mapProductCatalogWideArchiveSource(
height: decimal(height), height: decimal(height),
volume: calculateProductArchiveVolumeDecimal(length, width, height), volume: calculateProductArchiveVolumeDecimal(length, width, height),
packType: resolveProductArchivePackageType(text(record.package_set_count)), packType: resolveProductArchivePackageType(text(record.package_set_count)),
productAttribute: text(record.item_type),
productTag: text(record.product_tag), productTag: text(record.product_tag),
installDocId: text(record.drawing_id), installDocId: text(record.drawing_id),
spec: text(record.archive_spec), spec: text(record.archive_spec),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,938 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
supplyChainProductArchive: {
findMany: jest.fn(),
count: jest.fn(),
},
},
}))
jest.mock('@/server/repositories/supply-chain-product-archive-sales-period.repository', () => ({
listProductArchiveSalesAggregates: jest.fn(),
}))
jest.mock('@/server/repositories/supply-chain-product-archive-replacement.repository', () => ({
listProductArchiveReplacementRules: jest.fn(),
}))
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { listProductArchiveSalesAggregates } from '@/server/repositories/supply-chain-product-archive-sales-period.repository'
import { listProductArchiveReplacementRules } from '@/server/repositories/supply-chain-product-archive-replacement.repository'
import {
calculateSabcCreateDays,
classifySabc,
createPeriodDates,
isSabcEligibleProduct,
SABC_NEW_PRODUCT_120_DAY_CATEGORIES,
SABC_SCALE_RULES,
} from './supply-chain-product-sabc'
import {
createSabcArchiveScope,
newProductPeriodDays,
SupplyChainProductSabcService,
} from './supply-chain-product-sabc.service'
const archiveFindMany = prisma.supplyChainProductArchive.findMany as jest.Mock
const archiveCount = prisma.supplyChainProductArchive.count as jest.Mock
const salesAggregates = listProductArchiveSalesAggregates as jest.Mock
const replacementRules = listProductArchiveReplacementRules as jest.Mock
beforeEach(() => {
jest.resetAllMocks()
replacementRules.mockResolvedValue([])
})
describe('supply-chain-product-sabc rules', () => {
it('matches the August spreadsheet computer-chair example', () => {
const result = classifySabc({
category: '电脑椅',
realPerformance: new Prisma.Decimal('31248.45'),
shipQty: BigInt(196),
grossProfit: new Prisma.Decimal('15482.71'),
afterSaleAmount: new Prisma.Decimal('435.93'),
daysInPeriod: 31,
}, SABC_SCALE_RULES)
expect(result.averageDailySales.toFixed(8)).toBe('6.32258065')
expect(result.feeRatio?.toFixed(8)).toBe('0.51847916')
expect(result.performanceScale).toBe('动销款')
expect(result.salesScale).toBe('动销款')
expect(result.finalScale).toBe('动销款')
expect(result.profitLabel).toBe('一级')
expect(result.rating).toBe('A')
})
it('accepts category thresholds supplied by the category configuration table', () => {
const result = classifySabc({
category: '配置分类',
realPerformance: new Prisma.Decimal('100'),
shipQty: BigInt(10),
grossProfit: new Prisma.Decimal('50'),
afterSaleAmount: new Prisma.Decimal(0),
daysInPeriod: 10,
}, [{
category: '配置分类',
performanceMin: new Prisma.Decimal('100'),
performanceMax: null,
dailySalesMin: new Prisma.Decimal('1'),
dailySalesMax: null,
scale: '爆款',
}])
expect(result.performanceScale).toBe('爆款')
expect(result.salesScale).toBe('爆款')
})
it('uses the fifth profit level and D for zero performance', () => {
const result = classifySabc({
category: '电脑椅',
realPerformance: new Prisma.Decimal(0),
shipQty: BigInt(0),
grossProfit: new Prisma.Decimal(0),
afterSaleAmount: new Prisma.Decimal(0),
daysInPeriod: 31,
}, SABC_SCALE_RULES)
expect(result.profitLabel).toBe('五级')
expect(result.finalScale).toBe('滞销款')
expect(result.rating).toBe('D')
expect(result.feeRatio).toBeNull()
})
it('keeps the confirmed 120-day category spelling', () => {
expect(SABC_NEW_PRODUCT_120_DAY_CATEGORIES.has('电脑椅')).toBe(true)
expect(SABC_NEW_PRODUCT_120_DAY_CATEGORIES.has('脑椅')).toBe(false)
})
it('uses 120 days only for the specified new-product categories', () => {
for (const category of ['鞋柜', '电脑椅', '餐桌', '其他沙发类', '办公家具', '折叠床/午休床', '衣柜']) {
expect(newProductPeriodDays(category)).toBe(120)
}
expect(newProductPeriodDays('书柜')).toBe(90)
expect(newProductPeriodDays('')).toBe(90)
})
it('uses the archive sales status as the SABC eligibility rule', () => {
expect(isSabcEligibleProduct({ productStatus: '销售' })).toBe(true)
expect(isSabcEligibleProduct({ productStatus: '退市' })).toBe(false)
expect(isSabcEligibleProduct({ productStatus: '停产' })).toBe(false)
})
it('calculates create days by calendar date through the selected month end', () => {
const august = createPeriodDates(2026, 8)
const september = createPeriodDates(2026, 9)
const createdAt = new Date('2025-12-23T16:51:00.000Z')
expect(calculateSabcCreateDays(createdAt, august.end)).toBe(251)
expect(calculateSabcCreateDays(createdAt, september.end)).toBe(281)
})
it('does not lose a day because of the creation time of day', () => {
const { end } = createPeriodDates(2026, 8)
expect(calculateSabcCreateDays(new Date('2026-08-01T23:59:59.000Z'), end)).toBe(30)
expect(calculateSabcCreateDays(new Date('2026-08-31T23:59:59.000Z'), end)).toBe(0)
})
it('excludes products created after the selected historical month', () => {
expect(createSabcArchiveScope(2026, 8, new Date('2026-09-23T08:00:00.000Z')))
.toEqual(expect.objectContaining({
businessCreatedAt: { lt: new Date('2026-08-31T16:00:00.000Z') },
}))
})
it('uses the Shanghai calendar boundary for historical month inclusion', () => {
const { businessCreatedAt } = createSabcArchiveScope(2026, 8, new Date('2026-09-23T08:00:00.000Z'))
expect(businessCreatedAt).toEqual({ lt: new Date('2026-08-31T16:00:00.000Z') })
expect(new Date('2026-08-31T15:59:59.999Z') < businessCreatedAt.lt).toBe(true)
expect(new Date('2026-08-31T16:00:00.000Z') < businessCreatedAt.lt).toBe(false)
})
it('does not apply a creation cutoff to the current month', () => {
expect(createSabcArchiveScope(2026, 9, new Date('2026-09-23T08:00:00.000Z')))
.not.toHaveProperty('businessCreatedAt')
})
it('lists only sales products on the SABC page', async () => {
;(prisma.$queryRaw as jest.Mock).mockResolvedValueOnce([])
salesAggregates.mockResolvedValueOnce([])
archiveFindMany.mockResolvedValueOnce([])
archiveCount.mockResolvedValueOnce(0)
await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(archiveFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
productStatus: '销售',
productAttribute: '成品',
businessCreatedAt: { lt: new Date('2026-08-31T16:00:00.000Z') },
NOT: [
{ brand: '其他品牌-虚拟编码' },
{ categoryL1: '虚拟分类' },
{ categoryL2: '虚拟分类' },
{ categoryL2: '其他 - 福利品' },
{ categoryL2: '其他 - 配件' },
{ categoryL2: '其他 - 虚拟产品' },
{ categoryL2: '其他 - 赠品' },
],
}),
}))
expect(archiveCount).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ productStatus: '销售', productAttribute: '成品' }),
}))
})
it('does not silently use other thresholds when the scale table is missing', async () => {
;(prisma.$queryRaw as jest.Mock).mockRejectedValueOnce({
code: 'P2010',
meta: { driverAdapterError: { cause: { originalCode: '42P01', table: 'SupplyChainProductArchiveCategoryScaleRow' } } },
message: 'relation "SupplyChainProductArchiveCategoryScaleRow" does not exist',
})
salesAggregates.mockResolvedValueOnce([])
archiveFindMany.mockResolvedValueOnce([])
archiveCount.mockResolvedValueOnce(0)
await expect(new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})).rejects.toThrow('类目规模配置表尚未创建,请执行数据库迁移后重试')
})
it('keeps source sales metrics visible when the category has no SABC threshold configuration', async () => {
;(prisma.$queryRaw as jest.Mock)
.mockResolvedValueOnce([{
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 未配置类目',
categoryGroup: '电脑椅',
scaleLabel: '爆款',
performanceStart: '1000',
performanceEnd: null,
dailySalesStart: '10',
dailySalesEnd: null,
sortOrder: 1,
}])
archiveFindMany.mockResolvedValueOnce([{
id: 'archive-1',
barcode: 'DF-UNCONFIGURED-001',
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
productName: '休闲椅',
productStatus: '销售',
productAttribute: '成品',
productionType: 'ODM',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
}])
archiveCount.mockResolvedValueOnce(1)
salesAggregates.mockResolvedValueOnce([{
barcode: 'DF-UNCONFIGURED-001',
shipAmount: '200',
salesAmount: '200',
returnAmount: '0',
realPerformance: '123',
shipQty: '31',
returnQty: '0',
clearSalesCost: '20',
sendFreight: '5',
platformCommission: '0',
xyCost: '0',
afterSaleAmount: '3',
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
}])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(result.rows).toHaveLength(1)
expect(result.rows[0]).toEqual(expect.objectContaining({
categoryClass: '',
shipAmount: '200.0000',
realPerformance: '123.0000',
shipQty: '31',
grossProfit: '98.0000',
afterSaleAmount: '3.0000',
averageDailySales: '1.00000000',
grossMargin: '0.79674797',
afterSaleRatio: '0.02439024',
sabcRating: '',
noAssessmentReason: 'ODM',
}))
})
it('still marks zero-sales products as D when their category has no threshold configuration', async () => {
;(prisma.$queryRaw as jest.Mock).mockResolvedValueOnce([])
archiveFindMany.mockResolvedValueOnce([{
id: 'archive-unconfigured-zero',
barcode: 'PCG002201N1235V01',
spu: 'SPU-ZERO',
sku: 'SKU-ZERO',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '柜类',
categoryL2: '住宅家具 - 飘窗柜',
productName: '飘窗柜',
productStatus: '销售',
productAttribute: '成品',
productionType: 'OEM',
brand: '其他品牌-山东专用',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
}])
archiveCount.mockResolvedValueOnce(1)
salesAggregates.mockResolvedValueOnce([])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(result.rows[0]).toEqual(expect.objectContaining({
categoryClass: '',
averageDailySales: '0.00000000',
feeRatio: '异常',
performanceScale: '异常',
salesScale: '异常',
finalScale: '滞销款',
profitLabel: '五级',
sabcRating: 'D',
movingStatus: '不动销',
assessmentStatus: '考核',
noAssessmentReason: '',
}))
})
it('marks products without any monthly sales as 异常/滞销款/五级/D', async () => {
;(prisma.$queryRaw as jest.Mock)
.mockResolvedValueOnce([{
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
categoryGroup: '电脑椅',
scaleLabel: '爆款',
performanceStart: '1000',
performanceEnd: null,
dailySalesStart: '10',
dailySalesEnd: null,
sortOrder: 1,
}])
archiveFindMany.mockResolvedValueOnce([{
id: 'archive-zero-sales',
barcode: 'ZWJ0111-15963G',
spu: 'ZWJ0111',
sku: 'ZWJ02321',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
productName: '电脑椅',
productStatus: '销售',
productAttribute: '成品',
productionType: 'OEM',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
}])
archiveCount.mockResolvedValueOnce(1)
salesAggregates.mockResolvedValueOnce([])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(result.rows).toHaveLength(1)
expect(result.rows[0]).toEqual(expect.objectContaining({
averageDailySales: '0.00000000',
feeRatio: '异常',
performanceScale: '异常',
salesScale: '异常',
finalScale: '滞销款',
profitLabel: '五级',
sabcRating: 'D',
movingStatus: '不动销',
assessmentStatus: '考核',
realPerformance: '0.0000',
shipQty: '0',
}))
})
it('marks moving status as 不动销 when shipped quantity is zero even with sales revenue', async () => {
;(prisma.$queryRaw as jest.Mock)
.mockResolvedValueOnce([{
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
categoryGroup: '电脑椅',
scaleLabel: '动销款',
performanceStart: '100',
performanceEnd: null,
dailySalesStart: '1',
dailySalesEnd: null,
sortOrder: 1,
}])
archiveFindMany.mockResolvedValueOnce([{
id: 'archive-zero-qty',
barcode: 'DF-ZERO-QTY-001',
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
productName: '电脑椅',
productStatus: '销售',
productAttribute: '成品',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
}])
archiveCount.mockResolvedValueOnce(1)
salesAggregates.mockResolvedValueOnce([{
barcode: 'DF-ZERO-QTY-001',
shipAmount: '500',
salesAmount: '500',
returnAmount: '0',
realPerformance: '500',
shipQty: '0',
returnQty: '0',
clearSalesCost: '200',
sendFreight: '0',
platformCommission: '0',
xyCost: '0',
afterSaleAmount: '0',
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
}])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(result.rows[0]).toEqual(expect.objectContaining({
shipQty: '0',
finalScale: '动销款',
movingStatus: '不动销',
}))
})
it('merges the replaced code performance into the new code and leaves the old row untagged', async () => {
;(prisma.$queryRaw as jest.Mock)
.mockResolvedValueOnce([{
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
categoryGroup: '电脑椅',
scaleLabel: '爆款',
performanceStart: '100',
performanceEnd: null,
dailySalesStart: '1',
dailySalesEnd: null,
sortOrder: 1,
}])
replacementRules.mockResolvedValueOnce([
{ sku: 'OLD-001', replaceSku: 'NEW-001' },
])
const archiveRow = (barcode: string) => ({
id: `archive-${barcode}`,
barcode,
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
productName: '电脑椅',
productStatus: '销售',
productAttribute: '成品',
productionType: 'ODM',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
opsDirector: '运营总-甲',
opsDeptHead: '运营部-乙',
opsHead: '运营-丙',
supplierName: '供应商-A',
purchaseOwner: '采购-A',
designer: '设计-A',
})
archiveFindMany.mockResolvedValueOnce([archiveRow('OLD-001'), archiveRow('NEW-001')])
archiveCount.mockResolvedValueOnce(2)
const aggregateRow = (barcode: string, shipAmount: string, realPerformance: string, shipQty: string, clearSalesCost: string, afterSaleAmount: string) => ({
barcode,
shipAmount,
salesAmount: shipAmount,
returnAmount: '0',
realPerformance,
shipQty,
returnQty: '0',
clearSalesCost,
sendFreight: '0',
platformCommission: '0',
xyCost: '0',
afterSaleAmount,
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
})
salesAggregates.mockResolvedValueOnce([
aggregateRow('OLD-001', '200', '100', '10', '20', '5'),
aggregateRow('NEW-001', '300', '200', '20', '40', '7'),
])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
const oldRow = result.rows.find((row) => row.productCode === 'OLD-001')
const newRow = result.rows.find((row) => row.productCode === 'NEW-001')
expect(newRow).toEqual(expect.objectContaining({
sameProductCode: 'OLD-001',
shipAmount: '500.0000',
realPerformance: '300.0000',
shipQty: '30',
grossProfit: '240.0000',
afterSaleAmount: '12.0000',
finalScale: '爆款',
sabcRating: 'S',
opsDirector: '运营总-甲',
opsDeptHead: '运营部-乙',
opsHead: '运营-丙',
supplier: '供应商-A',
purchaser: '采购-A',
designer: '设计-A',
}))
expect(oldRow).toEqual(expect.objectContaining({
sameProductCode: 'NEW-001',
realPerformance: '300.0000',
shipQty: '30',
finalScale: '爆款',
sabcRating: 'S',
}))
})
it('matches the direct partner on both sides of a replacement chain', async () => {
;(prisma.$queryRaw as jest.Mock)
.mockResolvedValueOnce([{
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 角柜',
categoryGroup: '角柜',
scaleLabel: '爆款',
performanceStart: '100',
performanceEnd: null,
dailySalesStart: '1',
dailySalesEnd: null,
sortOrder: 1,
}])
replacementRules.mockResolvedValueOnce([
{ sku: 'CHAIN-A', replaceSku: 'CHAIN-B', sourceCreatedAt: '2026-02-08 15:57:27.93', sourceId: 1 },
{ sku: 'CHAIN-B', replaceSku: 'CHAIN-C', sourceCreatedAt: '2026-08-15 14:12:11.413', sourceId: 2 },
])
const archiveRow = (barcode: string) => ({
id: `archive-${barcode}`,
barcode,
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 角柜',
productName: '角柜',
productStatus: '销售',
productAttribute: '成品',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
})
archiveFindMany.mockResolvedValueOnce([archiveRow('CHAIN-A'), archiveRow('CHAIN-B'), archiveRow('CHAIN-C')])
archiveCount.mockResolvedValueOnce(3)
const aggregateRow = (barcode: string, shipAmount: string, realPerformance: string, shipQty: string, clearSalesCost: string, afterSaleAmount: string) => ({
barcode,
shipAmount,
salesAmount: shipAmount,
returnAmount: '0',
realPerformance,
shipQty,
returnQty: '0',
clearSalesCost,
sendFreight: '0',
platformCommission: '0',
xyCost: '0',
afterSaleAmount,
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
})
salesAggregates.mockResolvedValueOnce([
aggregateRow('CHAIN-A', '200', '100', '10', '20', '5'),
aggregateRow('CHAIN-B', '300', '200', '20', '40', '7'),
aggregateRow('CHAIN-C', '400', '300', '30', '60', '9'),
])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
const rowOf = (code: string) => result.rows.find((row) => row.productCode === code)
// 正向匹配优先:A→B、B→C;C 没有正向规则,用反向规则匹配回 B。字段只展示直接同款编码。
expect(rowOf('CHAIN-A')).toEqual(expect.objectContaining({
sameProductCode: 'CHAIN-B',
shipAmount: '500.0000',
realPerformance: '300.0000',
shipQty: '30',
}))
expect(rowOf('CHAIN-B')).toEqual(expect.objectContaining({
sameProductCode: 'CHAIN-C',
shipAmount: '700.0000',
realPerformance: '500.0000',
shipQty: '50',
}))
expect(rowOf('CHAIN-C')).toEqual(expect.objectContaining({
sameProductCode: 'CHAIN-B',
shipAmount: '700.0000',
realPerformance: '500.0000',
shipQty: '50',
}))
})
it('uses the newest rule when several old codes point to the same replacement code', async () => {
;(prisma.$queryRaw as jest.Mock)
.mockResolvedValueOnce([{
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 角柜',
categoryGroup: '角柜',
scaleLabel: '爆款',
performanceStart: '100',
performanceEnd: null,
dailySalesStart: '1',
dailySalesEnd: null,
sortOrder: 1,
}])
replacementRules.mockResolvedValueOnce([
{ sku: 'OLD-1', replaceSku: 'NEW', sourceCreatedAt: '2026-01-01 09:00:00.000', sourceId: 1 },
{ sku: 'OLD-2', replaceSku: 'NEW', sourceCreatedAt: '2026-08-15 14:12:11.413', sourceId: 2 },
])
const archiveRow = (barcode: string) => ({
id: `archive-${barcode}`,
barcode,
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 角柜',
productName: '角柜',
productStatus: '销售',
productAttribute: '成品',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
})
archiveFindMany.mockResolvedValueOnce([archiveRow('OLD-1'), archiveRow('OLD-2'), archiveRow('NEW')])
archiveCount.mockResolvedValueOnce(3)
const aggregateRow = (barcode: string, shipAmount: string, realPerformance: string, shipQty: string, clearSalesCost: string, afterSaleAmount: string) => ({
barcode,
shipAmount,
salesAmount: shipAmount,
returnAmount: '0',
realPerformance,
shipQty,
returnQty: '0',
clearSalesCost,
sendFreight: '0',
platformCommission: '0',
xyCost: '0',
afterSaleAmount,
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
})
salesAggregates.mockResolvedValueOnce([
aggregateRow('OLD-1', '100', '50', '5', '10', '1'),
aggregateRow('OLD-2', '200', '100', '10', '20', '2'),
aggregateRow('NEW', '300', '200', '20', '40', '3'),
])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
const rowOf = (code: string) => result.rows.find((row) => row.productCode === code)
expect(rowOf('OLD-1')).toEqual(expect.objectContaining({
sameProductCode: 'NEW',
realPerformance: '250.0000',
}))
expect(rowOf('OLD-2')).toEqual(expect.objectContaining({
sameProductCode: 'NEW',
realPerformance: '300.0000',
}))
// 反向匹配取创建时间最新的一条规则
expect(rowOf('NEW')).toEqual(expect.objectContaining({
sameProductCode: 'OLD-2',
realPerformance: '300.0000',
}))
})
it('does not double count when the replacement rule points to the same barcode', async () => {
;(prisma.$queryRaw as jest.Mock)
.mockResolvedValueOnce([{
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
categoryGroup: '电脑椅',
scaleLabel: '爆款',
performanceStart: '100',
performanceEnd: null,
dailySalesStart: '1',
dailySalesEnd: null,
sortOrder: 1,
}])
replacementRules.mockResolvedValueOnce([
{ sku: 'SELF-001', replaceSku: 'SELF-001' },
])
archiveFindMany.mockResolvedValueOnce([{
id: 'archive-SELF-001',
barcode: 'SELF-001',
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
productName: '电脑椅',
productStatus: '销售',
productAttribute: '成品',
productionType: 'ODM',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
}])
archiveCount.mockResolvedValueOnce(1)
salesAggregates.mockResolvedValueOnce([{
barcode: 'SELF-001',
shipAmount: '300',
salesAmount: '300',
returnAmount: '0',
realPerformance: '200',
shipQty: '20',
returnQty: '0',
clearSalesCost: '40',
sendFreight: '0',
platformCommission: '0',
xyCost: '0',
afterSaleAmount: '7',
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
}])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(result.rows[0]).toEqual(expect.objectContaining({
sameProductCode: '无',
realPerformance: '200.0000',
shipQty: '20',
shipAmount: '300.0000',
sabcRating: 'S',
}))
})
it('supports batch keyword search separated by newline/comma', async () => {
;(prisma.$queryRaw as jest.Mock).mockResolvedValueOnce([])
const archiveRow = (barcode: string) => ({
id: `archive-${barcode}`,
barcode,
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
productName: '电脑椅',
productStatus: '销售',
productAttribute: '成品',
productionType: 'ODM',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
})
archiveFindMany.mockResolvedValueOnce([
archiveRow('CODE-A'),
archiveRow('CODE-B'),
archiveRow('CODE-C'),
archiveRow('CODE-D'),
])
salesAggregates.mockResolvedValueOnce([])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
field: 'productCode',
keyword: 'CODE-A\nCODE-B,CODE-C',
})
expect(result.rows.map((row) => row.productCode)).toEqual(['CODE-A', 'CODE-B', 'CODE-C'])
})
it('combines per-field filters with AND semantics', async () => {
;(prisma.$queryRaw as jest.Mock).mockResolvedValueOnce([])
const archiveRow = (barcode: string, styleCode: string) => ({
id: `archive-${barcode}`,
barcode,
spu: styleCode,
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 电脑椅',
productName: '电脑椅',
productStatus: '销售',
productAttribute: '成品',
productionType: 'ODM',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
})
archiveFindMany.mockResolvedValueOnce([
archiveRow('CODE-A', 'SPU-1'),
archiveRow('CODE-B', 'SPU-1'),
archiveRow('CODE-C', 'SPU-2'),
])
salesAggregates.mockResolvedValueOnce([])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
filters: { productCode: 'CODE-A,CODE-C', styleCode: 'SPU-1' },
})
expect(result.rows.map((row) => row.productCode)).toEqual(['CODE-A'])
})
it.each(['休闲椅', ''])('uses configured thresholds for DF-G1XXY001301 when category group is %s', async (categoryGroup) => {
;(prisma.$queryRaw as jest.Mock)
.mockResolvedValueOnce([
{
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 休闲椅',
categoryGroup,
scaleLabel: '爆款',
performanceStart: '100',
performanceEnd: null,
dailySalesStart: '1',
dailySalesEnd: null,
sortOrder: 1,
},
])
archiveFindMany.mockResolvedValueOnce([{
id: 'archive-configured',
barcode: 'DF-G1XXY001301',
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 休闲椅',
productName: '休闲椅',
productStatus: '销售',
productAttribute: '成品',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
}])
archiveCount.mockResolvedValueOnce(1)
salesAggregates.mockResolvedValueOnce([{
barcode: 'DF-G1XXY001301',
shipAmount: '200',
salesAmount: '200',
returnAmount: '0',
realPerformance: '123',
shipQty: '31',
returnQty: '0',
clearSalesCost: '20',
sendFreight: '5',
platformCommission: '0',
xyCost: '0',
afterSaleAmount: '3',
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
}])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(result.rows[0]).toEqual(expect.objectContaining({
categoryClass: categoryGroup,
performanceScale: '爆款',
sabcRating: 'S',
}))
})
})

File diff suppressed because it is too large Load Diff