Merge branch 'codex/xuxiake-sabc-sync-20260929' into codex/xuxiake-mainline
商品SABC打标全部源码:页面/筛选/经营总览、服务端核算规则、同款商品编码匹配、 每日同步(聚水潭档案/销售日表/替换规则/类目档位/产品类型回填)、API 与导出、测试用例。
This commit is contained in:
parent
b9dc0bddb1
commit
ad08dccf1c
2
furniture-3d-system/.gitignore
vendored
2
furniture-3d-system/.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/node_modules.pnpm-mixed
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
@ -15,6 +16,7 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-*/
|
||||
/.next-oa-preview*/
|
||||
/.next-oa-webpack/
|
||||
/out/
|
||||
|
||||
@ -53,6 +53,17 @@
|
||||
- 准确度回测:`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` 才会删除数据。
|
||||
|
||||
@ -24,6 +24,9 @@
|
||||
"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",
|
||||
"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:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
|
||||
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",
|
||||
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "productAttribute" TEXT NOT NULL DEFAULT '';
|
||||
@ -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");
|
||||
@ -693,6 +693,7 @@ model SupplyChainProductArchive {
|
||||
height Decimal? @db.Decimal(18, 3)
|
||||
volume Decimal? @db.Decimal(24, 3)
|
||||
packType String @default("")
|
||||
productAttribute String @default("")
|
||||
productTag String @default("")
|
||||
installDocId String @default("")
|
||||
hasDrawing String @default("")
|
||||
@ -733,11 +734,14 @@ model SupplyChainProductArchive {
|
||||
purchasePrice String @default("")
|
||||
costPrice String @default("")
|
||||
costPriceSecondary String @default("")
|
||||
sabcRating String @default("") @db.VarChar(1)
|
||||
sabcUpdatedAt DateTime?
|
||||
fieldSourceMap Json @default("{}")
|
||||
sources SupplyChainProductArchiveSource[]
|
||||
imageEmbedding ProductArchiveImageEmbedding?
|
||||
imageCache SupplyChainProductArchiveImageCache?
|
||||
salesPerformance SupplyChainProductArchiveSalesPerformance?
|
||||
sabcResults SupplyChainProductArchiveSabcResult[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@ -752,6 +756,58 @@ model SupplyChainProductArchive {
|
||||
@@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 {
|
||||
archiveId String @id
|
||||
|
||||
101
furniture-3d-system/scripts/compare-product-sabc-xlsx.ts
Normal file
101
furniture-3d-system/scripts/compare-product-sabc-xlsx.ts
Normal 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
|
||||
})
|
||||
85
furniture-3d-system/scripts/install-local-daily-sync.sh
Executable file
85
furniture-3d-system/scripts/install-local-daily-sync.sh
Executable 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
|
||||
84
furniture-3d-system/scripts/install-product-sabc-timer.sh
Executable file
84
furniture-3d-system/scripts/install-product-sabc-timer.sh
Executable 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
|
||||
218
furniture-3d-system/scripts/local-sync-archive.mjs
Executable file
218
furniture-3d-system/scripts/local-sync-archive.mjs
Executable 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)
|
||||
})
|
||||
24
furniture-3d-system/scripts/local-sync-archive.sh
Executable file
24
furniture-3d-system/scripts/local-sync-archive.sh
Executable 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" "$@"
|
||||
190
furniture-3d-system/scripts/reconcile-archive-from-prod.ts
Executable file
190
furniture-3d-system/scripts/reconcile-archive-from-prod.ts
Executable 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
|
||||
})
|
||||
51
furniture-3d-system/scripts/run-product-sabc.ts
Normal file
51
furniture-3d-system/scripts/run-product-sabc.ts
Normal 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
|
||||
})
|
||||
244
furniture-3d-system/scripts/seed-local-product-sabc-xlsx.ts
Normal file
244
furniture-3d-system/scripts/seed-local-product-sabc-xlsx.ts
Normal 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
|
||||
})
|
||||
146
furniture-3d-system/scripts/sync-product-category-config.ts
Normal file
146
furniture-3d-system/scripts/sync-product-category-config.ts
Normal 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
|
||||
})
|
||||
@ -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
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -133,7 +133,7 @@ describe('supply chain menu', () => {
|
||||
route: '/supply-chain/management/blank-pages/blank-page-2',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
label: '空白页 3',
|
||||
label: '商品SABC打标',
|
||||
module: '经营管理',
|
||||
route: '/supply-chain/management/blank-pages/blank-page-3',
|
||||
}),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { memo } from 'react'
|
||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||
import { formatProductArchiveDateTime } from '@/lib/product-archive-date-time'
|
||||
import type { ArchiveColumnGroup } from './columns'
|
||||
import { ProductArchiveActions } from './ProductArchiveActions'
|
||||
import { ProductArchiveMaterialImageCell } from './ProductArchiveMaterialImageCell'
|
||||
@ -64,21 +65,6 @@ function areTableBodyPropsEqual(
|
||||
&& 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(
|
||||
row: SupplyChainProductArchiveRow,
|
||||
prop: string,
|
||||
@ -98,7 +84,7 @@ function renderCell(
|
||||
}
|
||||
|
||||
if (prop === 'createTime' || prop === 'updateTime') {
|
||||
return formatDateTime(row[prop])
|
||||
return formatProductArchiveDateTime(row[prop])
|
||||
}
|
||||
|
||||
return row[prop as keyof SupplyChainProductArchiveRow]
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { requireSession } from '@/lib/server-session'
|
||||
import { getManagementBlankPage } from '@/lib/supply-chain-management-blank-pages'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import { ManagementBlankPageView } from '../ManagementBlankPageView'
|
||||
import { ProductSabcView } from '../ProductSabcView'
|
||||
|
||||
export default async function ManagementBlankPage({ params }: { params: Promise<{ pageKey: string }> }) {
|
||||
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 (definition.key === 'blank-page-3') {
|
||||
return <ProductSabcView definition={definition} canManage={access.edit} canDownload={access.download} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ManagementBlankPageView
|
||||
definition={definition}
|
||||
|
||||
@ -17,6 +17,9 @@ export type ManagementBlankPageField = {
|
||||
const TITLE_MAX_LENGTH = 20
|
||||
const SETTINGS_CHANGED_EVENT = 'supply-chain-management-blank-page-settings-changed'
|
||||
const FIELDS_MAX_COUNT = 20
|
||||
const LEGACY_PAGE_TITLES: Record<string, readonly string[]> = {
|
||||
'blank-page-3': ['特殊入库&特殊备货流程'],
|
||||
}
|
||||
|
||||
const DEFAULT_FIELDS: ManagementBlankPageField[] = [
|
||||
{ id: 'name', label: '名称', builtinKey: 'name', visible: true },
|
||||
@ -40,11 +43,17 @@ function cleanTitle(value: unknown, fallback: string) {
|
||||
|
||||
function readTitles(): ManagementBlankPageTitles {
|
||||
return Object.fromEntries(MANAGEMENT_BLANK_PAGES.map((page) => {
|
||||
const storageKey = managementBlankPageSettingsStorageKey(page.key)
|
||||
try {
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem(managementBlankPageSettingsStorageKey(page.key)) ?? 'null',
|
||||
window.localStorage.getItem(storageKey) ?? '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 {
|
||||
return [page.key, page.label]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -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}
|
||||
@ -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')
|
||||
})
|
||||
16
furniture-3d-system/src/lib/product-archive-date-time.ts
Normal file
16
furniture-3d-system/src/lib/product-archive-date-time.ts
Normal 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)
|
||||
}
|
||||
@ -9,7 +9,7 @@ describe('经营管理空白页注册表', () => {
|
||||
expect(MANAGEMENT_BLANK_PAGES.map((page) => page.label)).toEqual([
|
||||
'空白页 1',
|
||||
'空白页 2',
|
||||
'空白页 3',
|
||||
'商品SABC打标',
|
||||
])
|
||||
expect(getManagementBlankPage('blank-page-2')?.route)
|
||||
.toBe('/supply-chain/management/blank-pages/blank-page-2')
|
||||
|
||||
@ -17,7 +17,7 @@ export const MANAGEMENT_BLANK_PAGES: readonly ManagementBlankPageDefinition[] =
|
||||
},
|
||||
{
|
||||
key: 'blank-page-3',
|
||||
label: '空白页 3',
|
||||
label: '商品SABC打标',
|
||||
route: '/supply-chain/management/blank-pages/blank-page-3',
|
||||
},
|
||||
]
|
||||
|
||||
@ -384,6 +384,9 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
|
||||
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')) {
|
||||
return {
|
||||
targetKey: 'categoryConfig' as const,
|
||||
|
||||
@ -55,6 +55,7 @@ export const JST_SKU_ARCHIVE_FIELD_COLUMNS = {
|
||||
height: 'h',
|
||||
volume: 'l*w*h',
|
||||
packType: 'other_8',
|
||||
productAttribute: 'item_type',
|
||||
spec: 'name',
|
||||
} as const
|
||||
|
||||
@ -77,6 +78,7 @@ export const PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS = {
|
||||
height: 'package_height_cm',
|
||||
volume: 'package_length_cm*package_width_cm*package_height_cm',
|
||||
packType: 'package_set_count',
|
||||
productAttribute: 'item_type',
|
||||
productTag: 'product_tag',
|
||||
installDocId: 'drawing_id',
|
||||
spec: 'archive_spec',
|
||||
@ -95,9 +97,9 @@ export const DRAWING_MEDIA_ARCHIVE_FIELD_COLUMNS = {
|
||||
} as const
|
||||
|
||||
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,
|
||||
[YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE]: 30,
|
||||
[YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE]: 40,
|
||||
}
|
||||
|
||||
export function shouldApplyProductArchiveFieldSource(input: {
|
||||
|
||||
@ -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('异常')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -121,7 +121,7 @@ class JstSkuProductArchiveSourceRepositoryClass
|
||||
l,
|
||||
w,
|
||||
h,
|
||||
TODATETIMEOFFSET(created, '+08:00') AS created,
|
||||
created,
|
||||
modified,
|
||||
local_updated
|
||||
FROM [${schema}].[jst_sku]
|
||||
@ -168,7 +168,7 @@ class JstSkuProductArchiveSourceRepositoryClass
|
||||
l,
|
||||
w,
|
||||
h,
|
||||
TODATETIMEOFFSET(created, '+08:00') AS created,
|
||||
created,
|
||||
modified,
|
||||
local_updated
|
||||
FROM [${schema}].[jst_sku]
|
||||
|
||||
@ -21,6 +21,7 @@ export type JstSkuProductArchivePatch = {
|
||||
sku?: string
|
||||
productName?: string
|
||||
categoryL1?: string
|
||||
productAttribute?: string
|
||||
productTag?: string
|
||||
brand?: string
|
||||
supplierName?: string
|
||||
@ -87,6 +88,7 @@ const currentArchiveSelect = {
|
||||
height: true,
|
||||
volume: true,
|
||||
packType: true,
|
||||
productAttribute: true,
|
||||
productTag: true,
|
||||
installDocId: true,
|
||||
spec: true,
|
||||
@ -99,6 +101,7 @@ const currentArchiveSelect = {
|
||||
inOutWarehouse: true,
|
||||
purchaseOwner: true,
|
||||
minimumOrderQuantity: true,
|
||||
sabcRating: true,
|
||||
fieldSourceMap: true,
|
||||
sources: {
|
||||
where: { sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE },
|
||||
@ -202,11 +205,13 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
|
||||
)
|
||||
: {}
|
||||
const incomingFieldSources = { ...input.fieldSourceMap }
|
||||
const shouldApplyBusinessCreatedAt = shouldApplyProductArchiveFieldSource({
|
||||
field: 'businessCreatedAt',
|
||||
currentSourceCode: currentFieldSources.businessCreatedAt?.sourceCode,
|
||||
incomingSourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
})
|
||||
const shouldApplyBusinessCreatedAt = previous === null
|
||||
? true
|
||||
: shouldApplyProductArchiveFieldSource({
|
||||
field: 'businessCreatedAt',
|
||||
currentSourceCode: currentFieldSources.businessCreatedAt?.sourceCode,
|
||||
incomingSourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||
}) && !currentFieldSources.businessCreatedAt
|
||||
if (shouldApplyBusinessCreatedAt) {
|
||||
incomingFieldSources.businessCreatedAt = {
|
||||
...incomingFieldSources.barcode,
|
||||
@ -238,6 +243,12 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
|
||||
const sourceMapChanged =
|
||||
JSON.stringify(parseProductArchiveFieldSourceMap(previous?.fieldSourceMap)) !==
|
||||
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 status: JstSkuProductArchiveUpsertResult['status']
|
||||
@ -256,7 +267,7 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
|
||||
status = 'created'
|
||||
} else {
|
||||
archiveId = previous.id
|
||||
if (changedFields.length > 0 || sourceMapChanged) {
|
||||
if (changedFields.length > 0 || sourceMapChanged || shouldClearSabcRating) {
|
||||
await tx.supplyChainProductArchive.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
@ -264,6 +275,9 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
|
||||
...(shouldUpdateBusinessCreatedAt
|
||||
? { businessCreatedAt: input.businessCreatedAt }
|
||||
: {}),
|
||||
...(shouldClearSabcRating
|
||||
? { sabcRating: '', sabcUpdatedAt: new Date() }
|
||||
: {}),
|
||||
fieldSourceMap: toProductArchiveFieldSourceJson(nextFieldSources),
|
||||
},
|
||||
})
|
||||
|
||||
@ -185,3 +185,38 @@ export const supplyChainProductArchiveCategoryScaleRowRepository =
|
||||
new 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
|
||||
`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,15 @@
|
||||
/** @jest-environment node */
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: { $queryRaw: jest.fn() } }))
|
||||
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'
|
||||
const queryRaw = prisma.$queryRaw as jest.Mock
|
||||
const value: PeriodPerformance = {
|
||||
shipAmount: new Prisma.Decimal('8.8400'), returnAmount: new Prisma.Decimal(0), salesAmount: new Prisma.Decimal('10.5'),
|
||||
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: ['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'),
|
||||
])
|
||||
})
|
||||
|
||||
@ -23,6 +23,26 @@ export type PeriodPerformance = NonNullable<ProductArchiveRecord['salesPerforman
|
||||
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> {
|
||||
const [row] = await prisma.$queryRaw<SalesCoverage[]>(Prisma.sql`
|
||||
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 performanceByBarcode = new Map<string, PeriodPerformance>()
|
||||
if (!period.hasCoverage) return { period, performanceByBarcode }
|
||||
const money = ['shipAmount', 'returnAmount', 'salesAmount', 'realPerformance', 'clearSalesCost', 'sendFreight',
|
||||
'platformCommission', 'xyCost', 'afterSaleAmount', 'pushFeeTotal', 'marketingFeeTotal',
|
||||
'advertisementFeeTotal', 'sharedWarehousingSum', 'otherFee', 'operatingFee'] as const
|
||||
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"
|
||||
`)
|
||||
const aggregates = await listProductArchiveSalesAggregates({
|
||||
periodStart: new Date(`${period.start}T00:00:00.000Z`),
|
||||
periodEnd: new Date(`${period.end}T00:00:00.000Z`),
|
||||
})
|
||||
for (const aggregate of aggregates) {
|
||||
const value = calculateProductArchiveSalesPerformance(aggregate)
|
||||
performanceByBarcode.set(value.barcode, {
|
||||
|
||||
@ -29,6 +29,7 @@ export type ProductCatalogWideArchiveRecord = {
|
||||
archive_spu: string | null
|
||||
archive_sku: string | null
|
||||
archive_product_name: string | null
|
||||
item_type: string | null
|
||||
archive_image: string | null
|
||||
archive_designer: string | null
|
||||
sales_category_name: string | null
|
||||
@ -107,6 +108,7 @@ const archiveComparableSelect = {
|
||||
height: true,
|
||||
volume: true,
|
||||
packType: true,
|
||||
productAttribute: true,
|
||||
productTag: true,
|
||||
installDocId: true,
|
||||
hasDrawing: true,
|
||||
@ -121,6 +123,7 @@ const archiveComparableSelect = {
|
||||
supplierName: true,
|
||||
purchaseOwner: true,
|
||||
minimumOrderQuantity: true,
|
||||
sabcRating: true,
|
||||
fieldSourceMap: true,
|
||||
} satisfies Prisma.SupplyChainProductArchiveSelect
|
||||
|
||||
@ -180,6 +183,7 @@ function buildArchiveUpdate(
|
||||
height: archive.height,
|
||||
volume: archive.volume,
|
||||
packType: archive.packType,
|
||||
productAttribute: archive.productAttribute,
|
||||
productTag: archive.productTag,
|
||||
installDocId: archive.installDocId,
|
||||
spec: archive.spec,
|
||||
@ -250,6 +254,7 @@ class SupplyChainProductArchiveSyncRepositoryClass
|
||||
archive_spu,
|
||||
archive_sku,
|
||||
archive_product_name,
|
||||
item_type,
|
||||
archive_image,
|
||||
archive_designer,
|
||||
sales_category_name,
|
||||
@ -334,6 +339,19 @@ class SupplyChainProductArchiveSyncRepositoryClass
|
||||
previous?.fieldSourceMap,
|
||||
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({
|
||||
where: { barcode: input.barcode },
|
||||
create: input.archive,
|
||||
|
||||
@ -147,6 +147,7 @@ export function mapJstSkuProductArchiveSource(
|
||||
addTextField(patch, fieldColumns, 'spu', text(record.i_id).toUpperCase(), 'i_id')
|
||||
addTextField(patch, fieldColumns, 'sku', text(record.other_7).toUpperCase(), 'other_7')
|
||||
addTextField(patch, fieldColumns, 'productName', record.name, 'name')
|
||||
addTextField(patch, fieldColumns, 'productAttribute', '成品', 'item_type')
|
||||
addTextField(patch, fieldColumns, 'productTag', record.labels, 'labels')
|
||||
addTextField(patch, fieldColumns, 'brand', record.brand, 'brand')
|
||||
addTextField(patch, fieldColumns, 'supplierName', record.supplier_name, 'supplier_name')
|
||||
|
||||
@ -156,6 +156,7 @@ export function mapProductCatalogWideArchiveSource(
|
||||
height: decimal(height),
|
||||
volume: calculateProductArchiveVolumeDecimal(length, width, height),
|
||||
packType: resolveProductArchivePackageType(text(record.package_set_count)),
|
||||
productAttribute: text(record.item_type),
|
||||
productTag: text(record.product_tag),
|
||||
installDocId: text(record.drawing_id),
|
||||
spec: text(record.archive_spec),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
1329
furniture-3d-system/src/server/services/supply-chain-product-sabc.ts
Normal file
1329
furniture-3d-system/src/server/services/supply-chain-product-sabc.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user