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
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/node_modules.pnpm-mixed
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
.yarn/*
|
||||||
@ -15,6 +16,7 @@
|
|||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
/.next-*/
|
||||||
/.next-oa-preview*/
|
/.next-oa-preview*/
|
||||||
/.next-oa-webpack/
|
/.next-oa-webpack/
|
||||||
/out/
|
/out/
|
||||||
|
|||||||
@ -53,6 +53,17 @@
|
|||||||
- 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。
|
- 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。
|
||||||
- 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。
|
- 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。
|
||||||
|
|
||||||
|
## 商品 SABC 月度核算
|
||||||
|
|
||||||
|
- 试算命令:`npm run run:product-sabc -- --year=2026 --month=8 --mode=PREVIEW`。
|
||||||
|
- 8 月人工表对账命令:`npm run compare:product-sabc-xlsx`,默认读取本机下载的人工工作簿;生产执行前应先完成产品全量档案日销售数据同步。
|
||||||
|
- 生产定时器安装脚本:`furniture-3d-system/scripts/install-product-sabc-timer.sh`。
|
||||||
|
- 生产 systemd 名称:`furniture-product-sabc-monthly.service` / `furniture-product-sabc-monthly.timer`。
|
||||||
|
- 默认调度:每月 2 日中国标准时间 `03:00`,附带最多 10 分钟随机延迟;服务计算上一个自然月的数据。
|
||||||
|
- SABC 的销售收款总金额、真实业绩、发货件数、商品毛利额和售后金额统一读取 `SupplyChainProductArchiveSalesPerformanceDaily`,与产品全量档案页面保持同一月度口径。
|
||||||
|
- 首次安装生成权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/product-sabc-timer.env`,默认 `PRODUCT_SABC_TIMER_MODE=PREVIEW`。8 月对账确认通过、迁移已应用且上月产品档案日销售数据完整后,才允许改为 `APPLY`。
|
||||||
|
- SABC 自动任务只读取现有 `DATABASE_URL`;上游日销售同步任务继续使用 `JST_REPORT_DB_*`,凭据不写入定时器配置。安装、迁移、首次 APPLY 和生产核算都需要单独授权。
|
||||||
|
|
||||||
## 操作日志保留策略
|
## 操作日志保留策略
|
||||||
|
|
||||||
- 清理脚本:`npm run db:cleanup-operation-logs` 默认只做 dry-run;只有显式传入 `--apply --confirm=cleanup-operation-logs` 才会删除数据。
|
- 清理脚本:`npm run db:cleanup-operation-logs` 默认只做 dry-run;只有显式传入 `--apply --confirm=cleanup-operation-logs` 才会删除数据。
|
||||||
|
|||||||
@ -24,6 +24,9 @@
|
|||||||
"backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts",
|
"backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts",
|
||||||
"backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply",
|
"backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply",
|
||||||
"sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts",
|
"sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts",
|
||||||
|
"run:product-sabc": "tsx scripts/run-product-sabc.ts",
|
||||||
|
"compare:product-sabc-xlsx": "tsx scripts/compare-product-sabc-xlsx.ts",
|
||||||
|
"seed:local-product-sabc-xlsx": "tsx scripts/seed-local-product-sabc-xlsx.ts",
|
||||||
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
|
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
|
||||||
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
|
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
|
||||||
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",
|
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",
|
||||||
|
|||||||
@ -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)
|
height Decimal? @db.Decimal(18, 3)
|
||||||
volume Decimal? @db.Decimal(24, 3)
|
volume Decimal? @db.Decimal(24, 3)
|
||||||
packType String @default("")
|
packType String @default("")
|
||||||
|
productAttribute String @default("")
|
||||||
productTag String @default("")
|
productTag String @default("")
|
||||||
installDocId String @default("")
|
installDocId String @default("")
|
||||||
hasDrawing String @default("")
|
hasDrawing String @default("")
|
||||||
@ -733,11 +734,14 @@ model SupplyChainProductArchive {
|
|||||||
purchasePrice String @default("")
|
purchasePrice String @default("")
|
||||||
costPrice String @default("")
|
costPrice String @default("")
|
||||||
costPriceSecondary String @default("")
|
costPriceSecondary String @default("")
|
||||||
|
sabcRating String @default("") @db.VarChar(1)
|
||||||
|
sabcUpdatedAt DateTime?
|
||||||
fieldSourceMap Json @default("{}")
|
fieldSourceMap Json @default("{}")
|
||||||
sources SupplyChainProductArchiveSource[]
|
sources SupplyChainProductArchiveSource[]
|
||||||
imageEmbedding ProductArchiveImageEmbedding?
|
imageEmbedding ProductArchiveImageEmbedding?
|
||||||
imageCache SupplyChainProductArchiveImageCache?
|
imageCache SupplyChainProductArchiveImageCache?
|
||||||
salesPerformance SupplyChainProductArchiveSalesPerformance?
|
salesPerformance SupplyChainProductArchiveSalesPerformance?
|
||||||
|
sabcResults SupplyChainProductArchiveSabcResult[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -752,6 +756,58 @@ model SupplyChainProductArchive {
|
|||||||
@@index([firstSourceCode, createdAt])
|
@@index([firstSourceCode, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 月度 SABC 核算快照。主表只保留当前标签,历史月份和计算过程在这里追溯。
|
||||||
|
model SupplyChainProductArchiveSabcResult {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
archiveId String
|
||||||
|
archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade)
|
||||||
|
barcode String @db.VarChar(100)
|
||||||
|
periodStart DateTime @db.Date
|
||||||
|
periodEnd DateTime @db.Date
|
||||||
|
eligible Boolean
|
||||||
|
sabcRating String @default("") @db.VarChar(1)
|
||||||
|
category String @default("")
|
||||||
|
performanceScale String @default("")
|
||||||
|
salesScale String @default("")
|
||||||
|
finalScale String @default("")
|
||||||
|
profitLabel String @default("")
|
||||||
|
averageDailySales Decimal? @db.Decimal(24, 8)
|
||||||
|
feeRatio Decimal? @db.Decimal(20, 8)
|
||||||
|
grossMargin Decimal? @db.Decimal(20, 8)
|
||||||
|
shipAmount Decimal @db.Decimal(24, 4)
|
||||||
|
realPerformance Decimal @db.Decimal(24, 4)
|
||||||
|
shipQty BigInt
|
||||||
|
grossProfit Decimal @db.Decimal(24, 4)
|
||||||
|
afterSaleAmount Decimal @db.Decimal(24, 4)
|
||||||
|
status String @default("CALCULATED") @db.VarChar(24)
|
||||||
|
reason String @default("") @db.Text
|
||||||
|
calculatedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([barcode, periodStart, periodEnd])
|
||||||
|
@@index([periodStart, periodEnd])
|
||||||
|
@@index([sabcRating, periodEnd])
|
||||||
|
@@index([archiveId, periodEnd])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupplyChainProductArchiveSabcRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
periodStart DateTime @db.Date
|
||||||
|
periodEnd DateTime @db.Date
|
||||||
|
mode String @db.VarChar(16)
|
||||||
|
status String @db.VarChar(24)
|
||||||
|
sourceRows Int @default(0)
|
||||||
|
eligibleRows Int @default(0)
|
||||||
|
taggedRows Int @default(0)
|
||||||
|
comparedRows Int @default(0)
|
||||||
|
mismatchRows Int @default(0)
|
||||||
|
errorMessage String? @db.Text
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
@@unique([periodStart, periodEnd, mode])
|
||||||
|
@@index([status, startedAt])
|
||||||
|
}
|
||||||
|
|
||||||
// 销售宽表按条码汇总的每日快照。
|
// 销售宽表按条码汇总的每日快照。
|
||||||
model SupplyChainProductArchiveSalesPerformance {
|
model SupplyChainProductArchiveSalesPerformance {
|
||||||
archiveId String @id
|
archiveId String @id
|
||||||
|
|||||||
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',
|
route: '/supply-chain/management/blank-pages/blank-page-2',
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
label: '空白页 3',
|
label: '商品SABC打标',
|
||||||
module: '经营管理',
|
module: '经营管理',
|
||||||
route: '/supply-chain/management/blank-pages/blank-page-3',
|
route: '/supply-chain/management/blank-pages/blank-page-3',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
|
||||||
|
import { formatProductArchiveDateTime } from '@/lib/product-archive-date-time'
|
||||||
import type { ArchiveColumnGroup } from './columns'
|
import type { ArchiveColumnGroup } from './columns'
|
||||||
import { ProductArchiveActions } from './ProductArchiveActions'
|
import { ProductArchiveActions } from './ProductArchiveActions'
|
||||||
import { ProductArchiveMaterialImageCell } from './ProductArchiveMaterialImageCell'
|
import { ProductArchiveMaterialImageCell } from './ProductArchiveMaterialImageCell'
|
||||||
@ -64,21 +65,6 @@ function areTableBodyPropsEqual(
|
|||||||
&& hasSameColumnLayout(previous.columns, next.columns)
|
&& hasSameColumnLayout(previous.columns, next.columns)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
|
||||||
if (!value) return ''
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return ''
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCell(
|
function renderCell(
|
||||||
row: SupplyChainProductArchiveRow,
|
row: SupplyChainProductArchiveRow,
|
||||||
prop: string,
|
prop: string,
|
||||||
@ -98,7 +84,7 @@ function renderCell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (prop === 'createTime' || prop === 'updateTime') {
|
if (prop === 'createTime' || prop === 'updateTime') {
|
||||||
return formatDateTime(row[prop])
|
return formatProductArchiveDateTime(row[prop])
|
||||||
}
|
}
|
||||||
|
|
||||||
return row[prop as keyof SupplyChainProductArchiveRow]
|
return row[prop as keyof SupplyChainProductArchiveRow]
|
||||||
|
|||||||
@ -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 { getManagementBlankPage } from '@/lib/supply-chain-management-blank-pages'
|
||||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||||
import { ManagementBlankPageView } from '../ManagementBlankPageView'
|
import { ManagementBlankPageView } from '../ManagementBlankPageView'
|
||||||
|
import { ProductSabcView } from '../ProductSabcView'
|
||||||
|
|
||||||
export default async function ManagementBlankPage({ params }: { params: Promise<{ pageKey: string }> }) {
|
export default async function ManagementBlankPage({ params }: { params: Promise<{ pageKey: string }> }) {
|
||||||
const definition = getManagementBlankPage((await params).pageKey)
|
const definition = getManagementBlankPage((await params).pageKey)
|
||||||
@ -15,6 +16,10 @@ export default async function ManagementBlankPage({ params }: { params: Promise<
|
|||||||
)
|
)
|
||||||
if (!access.view) redirect('/supply-chain')
|
if (!access.view) redirect('/supply-chain')
|
||||||
|
|
||||||
|
if (definition.key === 'blank-page-3') {
|
||||||
|
return <ProductSabcView definition={definition} canManage={access.edit} canDownload={access.download} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagementBlankPageView
|
<ManagementBlankPageView
|
||||||
definition={definition}
|
definition={definition}
|
||||||
|
|||||||
@ -17,6 +17,9 @@ export type ManagementBlankPageField = {
|
|||||||
const TITLE_MAX_LENGTH = 20
|
const TITLE_MAX_LENGTH = 20
|
||||||
const SETTINGS_CHANGED_EVENT = 'supply-chain-management-blank-page-settings-changed'
|
const SETTINGS_CHANGED_EVENT = 'supply-chain-management-blank-page-settings-changed'
|
||||||
const FIELDS_MAX_COUNT = 20
|
const FIELDS_MAX_COUNT = 20
|
||||||
|
const LEGACY_PAGE_TITLES: Record<string, readonly string[]> = {
|
||||||
|
'blank-page-3': ['特殊入库&特殊备货流程'],
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_FIELDS: ManagementBlankPageField[] = [
|
const DEFAULT_FIELDS: ManagementBlankPageField[] = [
|
||||||
{ id: 'name', label: '名称', builtinKey: 'name', visible: true },
|
{ id: 'name', label: '名称', builtinKey: 'name', visible: true },
|
||||||
@ -40,11 +43,17 @@ function cleanTitle(value: unknown, fallback: string) {
|
|||||||
|
|
||||||
function readTitles(): ManagementBlankPageTitles {
|
function readTitles(): ManagementBlankPageTitles {
|
||||||
return Object.fromEntries(MANAGEMENT_BLANK_PAGES.map((page) => {
|
return Object.fromEntries(MANAGEMENT_BLANK_PAGES.map((page) => {
|
||||||
|
const storageKey = managementBlankPageSettingsStorageKey(page.key)
|
||||||
try {
|
try {
|
||||||
const stored = JSON.parse(
|
const stored = JSON.parse(
|
||||||
window.localStorage.getItem(managementBlankPageSettingsStorageKey(page.key)) ?? 'null',
|
window.localStorage.getItem(storageKey) ?? 'null',
|
||||||
) as { pageTitle?: unknown } | null
|
) as { pageTitle?: unknown } | null
|
||||||
return [page.key, cleanTitle(stored?.pageTitle, page.label)]
|
const pageTitle = cleanTitle(stored?.pageTitle, page.label)
|
||||||
|
if (LEGACY_PAGE_TITLES[page.key]?.includes(pageTitle)) {
|
||||||
|
window.localStorage.removeItem(storageKey)
|
||||||
|
return [page.key, page.label]
|
||||||
|
}
|
||||||
|
return [page.key, pageTitle]
|
||||||
} catch {
|
} catch {
|
||||||
return [page.key, page.label]
|
return [page.key, page.label]
|
||||||
}
|
}
|
||||||
|
|||||||
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([
|
expect(MANAGEMENT_BLANK_PAGES.map((page) => page.label)).toEqual([
|
||||||
'空白页 1',
|
'空白页 1',
|
||||||
'空白页 2',
|
'空白页 2',
|
||||||
'空白页 3',
|
'商品SABC打标',
|
||||||
])
|
])
|
||||||
expect(getManagementBlankPage('blank-page-2')?.route)
|
expect(getManagementBlankPage('blank-page-2')?.route)
|
||||||
.toBe('/supply-chain/management/blank-pages/blank-page-2')
|
.toBe('/supply-chain/management/blank-pages/blank-page-2')
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export const MANAGEMENT_BLANK_PAGES: readonly ManagementBlankPageDefinition[] =
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'blank-page-3',
|
key: 'blank-page-3',
|
||||||
label: '空白页 3',
|
label: '商品SABC打标',
|
||||||
route: '/supply-chain/management/blank-pages/blank-page-3',
|
route: '/supply-chain/management/blank-pages/blank-page-3',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -384,6 +384,9 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
|
|||||||
action: pathname.endsWith('/image-search') ? 'view' as const : action,
|
action: pathname.endsWith('/image-search') ? 'view' as const : action,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (pathname.startsWith('/api/supply-chain/product-sabc')) {
|
||||||
|
return { targetKey: 'businessManagement' as const, action }
|
||||||
|
}
|
||||||
if (pathname.startsWith('/api/supply-chain/category-config')) {
|
if (pathname.startsWith('/api/supply-chain/category-config')) {
|
||||||
return {
|
return {
|
||||||
targetKey: 'categoryConfig' as const,
|
targetKey: 'categoryConfig' as const,
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export const JST_SKU_ARCHIVE_FIELD_COLUMNS = {
|
|||||||
height: 'h',
|
height: 'h',
|
||||||
volume: 'l*w*h',
|
volume: 'l*w*h',
|
||||||
packType: 'other_8',
|
packType: 'other_8',
|
||||||
|
productAttribute: 'item_type',
|
||||||
spec: 'name',
|
spec: 'name',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@ -77,6 +78,7 @@ export const PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS = {
|
|||||||
height: 'package_height_cm',
|
height: 'package_height_cm',
|
||||||
volume: 'package_length_cm*package_width_cm*package_height_cm',
|
volume: 'package_length_cm*package_width_cm*package_height_cm',
|
||||||
packType: 'package_set_count',
|
packType: 'package_set_count',
|
||||||
|
productAttribute: 'item_type',
|
||||||
productTag: 'product_tag',
|
productTag: 'product_tag',
|
||||||
installDocId: 'drawing_id',
|
installDocId: 'drawing_id',
|
||||||
spec: 'archive_spec',
|
spec: 'archive_spec',
|
||||||
@ -95,9 +97,9 @@ export const DRAWING_MEDIA_ARCHIVE_FIELD_COLUMNS = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
const BUSINESS_CREATED_AT_SOURCE_PRIORITY: Record<string, number> = {
|
const BUSINESS_CREATED_AT_SOURCE_PRIORITY: Record<string, number> = {
|
||||||
[PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE]: 10,
|
[PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE]: 30,
|
||||||
[JST_SKU_ARCHIVE_SOURCE_CODE]: 20,
|
[JST_SKU_ARCHIVE_SOURCE_CODE]: 20,
|
||||||
[YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE]: 30,
|
[YUNSHAN_PRODUCT_EXPORT_ARCHIVE_SOURCE_CODE]: 40,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldApplyProductArchiveFieldSource(input: {
|
export function shouldApplyProductArchiveFieldSource(input: {
|
||||||
|
|||||||
@ -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,
|
l,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
TODATETIMEOFFSET(created, '+08:00') AS created,
|
created,
|
||||||
modified,
|
modified,
|
||||||
local_updated
|
local_updated
|
||||||
FROM [${schema}].[jst_sku]
|
FROM [${schema}].[jst_sku]
|
||||||
@ -168,7 +168,7 @@ class JstSkuProductArchiveSourceRepositoryClass
|
|||||||
l,
|
l,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
TODATETIMEOFFSET(created, '+08:00') AS created,
|
created,
|
||||||
modified,
|
modified,
|
||||||
local_updated
|
local_updated
|
||||||
FROM [${schema}].[jst_sku]
|
FROM [${schema}].[jst_sku]
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export type JstSkuProductArchivePatch = {
|
|||||||
sku?: string
|
sku?: string
|
||||||
productName?: string
|
productName?: string
|
||||||
categoryL1?: string
|
categoryL1?: string
|
||||||
|
productAttribute?: string
|
||||||
productTag?: string
|
productTag?: string
|
||||||
brand?: string
|
brand?: string
|
||||||
supplierName?: string
|
supplierName?: string
|
||||||
@ -87,6 +88,7 @@ const currentArchiveSelect = {
|
|||||||
height: true,
|
height: true,
|
||||||
volume: true,
|
volume: true,
|
||||||
packType: true,
|
packType: true,
|
||||||
|
productAttribute: true,
|
||||||
productTag: true,
|
productTag: true,
|
||||||
installDocId: true,
|
installDocId: true,
|
||||||
spec: true,
|
spec: true,
|
||||||
@ -99,6 +101,7 @@ const currentArchiveSelect = {
|
|||||||
inOutWarehouse: true,
|
inOutWarehouse: true,
|
||||||
purchaseOwner: true,
|
purchaseOwner: true,
|
||||||
minimumOrderQuantity: true,
|
minimumOrderQuantity: true,
|
||||||
|
sabcRating: true,
|
||||||
fieldSourceMap: true,
|
fieldSourceMap: true,
|
||||||
sources: {
|
sources: {
|
||||||
where: { sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE },
|
where: { sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE },
|
||||||
@ -202,11 +205,13 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
|
|||||||
)
|
)
|
||||||
: {}
|
: {}
|
||||||
const incomingFieldSources = { ...input.fieldSourceMap }
|
const incomingFieldSources = { ...input.fieldSourceMap }
|
||||||
const shouldApplyBusinessCreatedAt = shouldApplyProductArchiveFieldSource({
|
const shouldApplyBusinessCreatedAt = previous === null
|
||||||
|
? true
|
||||||
|
: shouldApplyProductArchiveFieldSource({
|
||||||
field: 'businessCreatedAt',
|
field: 'businessCreatedAt',
|
||||||
currentSourceCode: currentFieldSources.businessCreatedAt?.sourceCode,
|
currentSourceCode: currentFieldSources.businessCreatedAt?.sourceCode,
|
||||||
incomingSourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
incomingSourceCode: JST_SKU_ARCHIVE_SOURCE_CODE,
|
||||||
})
|
}) && !currentFieldSources.businessCreatedAt
|
||||||
if (shouldApplyBusinessCreatedAt) {
|
if (shouldApplyBusinessCreatedAt) {
|
||||||
incomingFieldSources.businessCreatedAt = {
|
incomingFieldSources.businessCreatedAt = {
|
||||||
...incomingFieldSources.barcode,
|
...incomingFieldSources.barcode,
|
||||||
@ -238,6 +243,12 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
|
|||||||
const sourceMapChanged =
|
const sourceMapChanged =
|
||||||
JSON.stringify(parseProductArchiveFieldSourceMap(previous?.fieldSourceMap)) !==
|
JSON.stringify(parseProductArchiveFieldSourceMap(previous?.fieldSourceMap)) !==
|
||||||
JSON.stringify(nextFieldSources)
|
JSON.stringify(nextFieldSources)
|
||||||
|
const nextProductStatus = input.patch.productStatus ?? previous?.productStatus ?? ''
|
||||||
|
const nextProductAttribute = input.patch.productAttribute ?? previous?.productAttribute ?? ''
|
||||||
|
const shouldClearSabcRating = Boolean(
|
||||||
|
previous?.sabcRating &&
|
||||||
|
(nextProductStatus !== '销售' || nextProductAttribute !== '成品'),
|
||||||
|
)
|
||||||
|
|
||||||
let archiveId: string
|
let archiveId: string
|
||||||
let status: JstSkuProductArchiveUpsertResult['status']
|
let status: JstSkuProductArchiveUpsertResult['status']
|
||||||
@ -256,7 +267,7 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
|
|||||||
status = 'created'
|
status = 'created'
|
||||||
} else {
|
} else {
|
||||||
archiveId = previous.id
|
archiveId = previous.id
|
||||||
if (changedFields.length > 0 || sourceMapChanged) {
|
if (changedFields.length > 0 || sourceMapChanged || shouldClearSabcRating) {
|
||||||
await tx.supplyChainProductArchive.update({
|
await tx.supplyChainProductArchive.update({
|
||||||
where: { id: previous.id },
|
where: { id: previous.id },
|
||||||
data: {
|
data: {
|
||||||
@ -264,6 +275,9 @@ class JstSkuProductArchiveRepositoryClass implements JstSkuProductArchiveReposit
|
|||||||
...(shouldUpdateBusinessCreatedAt
|
...(shouldUpdateBusinessCreatedAt
|
||||||
? { businessCreatedAt: input.businessCreatedAt }
|
? { businessCreatedAt: input.businessCreatedAt }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(shouldClearSabcRating
|
||||||
|
? { sabcRating: '', sabcUpdatedAt: new Date() }
|
||||||
|
: {}),
|
||||||
fieldSourceMap: toProductArchiveFieldSourceJson(nextFieldSources),
|
fieldSourceMap: toProductArchiveFieldSourceJson(nextFieldSources),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -185,3 +185,38 @@ export const supplyChainProductArchiveCategoryScaleRowRepository =
|
|||||||
new SupplyChainProductArchiveCategoryScaleRowRepositoryClass()
|
new SupplyChainProductArchiveCategoryScaleRowRepositoryClass()
|
||||||
|
|
||||||
export { SupplyChainProductArchiveCategoryScaleRowRepositoryClass }
|
export { SupplyChainProductArchiveCategoryScaleRowRepositoryClass }
|
||||||
|
|
||||||
|
// SABC 核算使用的「启用类目 + 档位」扁平列表(保留旧接口,按 ACTIVE 类目过滤)。
|
||||||
|
export type ProductArchiveCategoryScaleRow = {
|
||||||
|
categoryL1: string
|
||||||
|
categoryL2: string
|
||||||
|
categoryGroup: string
|
||||||
|
scaleLabel: string
|
||||||
|
performanceStart: string | number
|
||||||
|
performanceEnd: string | number | null
|
||||||
|
dailySalesStart: string | number
|
||||||
|
dailySalesEnd: string | number | null
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActiveProductArchiveCategoryScaleRows(): Promise<ProductArchiveCategoryScaleRow[]> {
|
||||||
|
return prisma.$queryRaw<ProductArchiveCategoryScaleRow[]>`
|
||||||
|
SELECT
|
||||||
|
scale."categoryL1" AS "categoryL1",
|
||||||
|
scale."categoryL2" AS "categoryL2",
|
||||||
|
scale."categoryGroup" AS "categoryGroup",
|
||||||
|
scale."scaleLabel" AS "scaleLabel",
|
||||||
|
scale."performanceStart" AS "performanceStart",
|
||||||
|
scale."performanceEnd" AS "performanceEnd",
|
||||||
|
scale."dailySalesStart" AS "dailySalesStart",
|
||||||
|
scale."dailySalesEnd" AS "dailySalesEnd",
|
||||||
|
scale."sortOrder" AS "sortOrder"
|
||||||
|
FROM "SupplyChainProductArchiveCategoryScaleRow" AS scale
|
||||||
|
INNER JOIN "SupplyChainProductArchiveCategoryConfig" AS config
|
||||||
|
ON config."categoryL2" = scale."categoryL2"
|
||||||
|
WHERE config."status" = 'ACTIVE'
|
||||||
|
AND scale."performanceStart" IS NOT NULL
|
||||||
|
AND scale."dailySalesStart" IS NOT NULL
|
||||||
|
ORDER BY scale."categoryL2" ASC, scale."sortOrder" ASC
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|||||||
@ -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-environment node */
|
||||||
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
|
jest.mock('@/lib/prisma', () => ({ prisma: { $queryRaw: jest.fn() } }))
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { matchesPerformanceFilters, performanceText, type PeriodPerformance } from './supply-chain-product-archive-sales-period.repository'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
listProductArchiveSalesAggregates,
|
||||||
|
matchesPerformanceFilters,
|
||||||
|
performanceText,
|
||||||
|
type PeriodPerformance,
|
||||||
|
} from './supply-chain-product-archive-sales-period.repository'
|
||||||
import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE as empty } from '@/lib/supply-chain-product-archive'
|
import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE as empty } from '@/lib/supply-chain-product-archive'
|
||||||
|
const queryRaw = prisma.$queryRaw as jest.Mock
|
||||||
const value: PeriodPerformance = {
|
const value: PeriodPerformance = {
|
||||||
shipAmount: new Prisma.Decimal('8.8400'), returnAmount: new Prisma.Decimal(0), salesAmount: new Prisma.Decimal('10.5'),
|
shipAmount: new Prisma.Decimal('8.8400'), returnAmount: new Prisma.Decimal(0), salesAmount: new Prisma.Decimal('10.5'),
|
||||||
afterSaleAmount: new Prisma.Decimal('2.25'),
|
afterSaleAmount: new Prisma.Decimal('2.25'),
|
||||||
@ -40,3 +47,26 @@ it('omits only the candidate column filter', () => {
|
|||||||
expect(matchesPerformanceFilters(value, { shipAmount: ['999'], shipQty: ['1'] }, 'shipAmount')).toBe(true)
|
expect(matchesPerformanceFilters(value, { shipAmount: ['999'], shipQty: ['1'] }, 'shipAmount')).toBe(true)
|
||||||
expect(matchesPerformanceFilters(value, { shipAmount: ['999'], shipQty: ['2'] }, 'shipAmount')).toBe(false)
|
expect(matchesPerformanceFilters(value, { shipAmount: ['999'], shipQty: ['2'] }, 'shipAmount')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('aggregates the product archive daily facts for the selected month', async () => {
|
||||||
|
const aggregate = {
|
||||||
|
barcode: 'SKU-1', shipAmount: '100', returnAmount: '5', realPerformance: '95',
|
||||||
|
clearSalesCost: '40', sendFreight: '10', platformCommission: '1', xyCost: '2',
|
||||||
|
afterSaleAmount: '3', pushFeeTotal: '4', marketingFeeTotal: '5', advertisementFeeTotal: '6',
|
||||||
|
sharedWarehousingSum: '7', otherFee: '8', operatingFee: '9', shipQty: '2', returnQty: '1',
|
||||||
|
}
|
||||||
|
queryRaw.mockResolvedValueOnce([aggregate])
|
||||||
|
|
||||||
|
await expect(listProductArchiveSalesAggregates({
|
||||||
|
periodStart: new Date('2026-08-01T00:00:00.000Z'),
|
||||||
|
periodEnd: new Date('2026-08-31T00:00:00.000Z'),
|
||||||
|
})).resolves.toEqual([aggregate])
|
||||||
|
|
||||||
|
const sql = queryRaw.mock.calls[0][0] as Prisma.Sql
|
||||||
|
expect(sql.sql).toContain('FROM "SupplyChainProductArchiveSalesPerformanceDaily"')
|
||||||
|
expect(sql.sql).toContain('SUM("afterSaleAmount")')
|
||||||
|
expect(sql.values).toEqual([
|
||||||
|
new Date('2026-08-01T00:00:00.000Z'),
|
||||||
|
new Date('2026-08-31T00:00:00.000Z'),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|||||||
@ -23,6 +23,26 @@ export type PeriodPerformance = NonNullable<ProductArchiveRecord['salesPerforman
|
|||||||
afterSaleAmount: Prisma.Decimal
|
afterSaleAmount: Prisma.Decimal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PERIOD_AGGREGATE_MONEY_FIELDS = [
|
||||||
|
'salesAmount', 'shipAmount', 'returnAmount', 'realPerformance', 'clearSalesCost', 'sendFreight',
|
||||||
|
'platformCommission', 'xyCost', 'afterSaleAmount', 'pushFeeTotal', 'marketingFeeTotal',
|
||||||
|
'advertisementFeeTotal', 'sharedWarehousingSum', 'otherFee', 'operatingFee',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export async function listProductArchiveSalesAggregates(input: {
|
||||||
|
periodStart: Date
|
||||||
|
periodEnd: Date
|
||||||
|
}) {
|
||||||
|
return prisma.$queryRaw<ProductArchiveSalesPerformanceAggregate[]>(Prisma.sql`
|
||||||
|
SELECT "barcode",
|
||||||
|
${Prisma.join([...PERIOD_AGGREGATE_MONEY_FIELDS, 'shipQty', 'returnQty'].map((key) =>
|
||||||
|
Prisma.sql`SUM(${Prisma.raw('"' + key + '"')})::text AS ${Prisma.raw('"' + key + '"')}`))}
|
||||||
|
FROM "SupplyChainProductArchiveSalesPerformanceDaily"
|
||||||
|
WHERE "statDate" >= ${input.periodStart}::date AND "statDate" <= ${input.periodEnd}::date
|
||||||
|
GROUP BY "barcode"
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function readSalesCoverage(): Promise<SalesCoverage> {
|
export async function readSalesCoverage(): Promise<SalesCoverage> {
|
||||||
const [row] = await prisma.$queryRaw<SalesCoverage[]>(Prisma.sql`
|
const [row] = await prisma.$queryRaw<SalesCoverage[]>(Prisma.sql`
|
||||||
SELECT MIN("statDate")::text AS "availableFrom", MAX("statDate")::text AS "availableThrough",
|
SELECT MIN("statDate")::text AS "availableFrom", MAX("statDate")::text AS "availableThrough",
|
||||||
@ -67,17 +87,10 @@ export async function listPeriodPerformance(filters: ProductArchiveFilters = {})
|
|||||||
const period = resolveSalesPeriod(filters, coverage)
|
const period = resolveSalesPeriod(filters, coverage)
|
||||||
const performanceByBarcode = new Map<string, PeriodPerformance>()
|
const performanceByBarcode = new Map<string, PeriodPerformance>()
|
||||||
if (!period.hasCoverage) return { period, performanceByBarcode }
|
if (!period.hasCoverage) return { period, performanceByBarcode }
|
||||||
const money = ['shipAmount', 'returnAmount', 'salesAmount', 'realPerformance', 'clearSalesCost', 'sendFreight',
|
const aggregates = await listProductArchiveSalesAggregates({
|
||||||
'platformCommission', 'xyCost', 'afterSaleAmount', 'pushFeeTotal', 'marketingFeeTotal',
|
periodStart: new Date(`${period.start}T00:00:00.000Z`),
|
||||||
'advertisementFeeTotal', 'sharedWarehousingSum', 'otherFee', 'operatingFee'] as const
|
periodEnd: new Date(`${period.end}T00:00:00.000Z`),
|
||||||
const aggregates = await prisma.$queryRaw<ProductArchiveSalesPerformanceAggregate[]>(Prisma.sql`
|
})
|
||||||
SELECT "barcode",
|
|
||||||
${Prisma.join([...money, 'shipQty', 'returnQty'].map((key) =>
|
|
||||||
Prisma.sql`SUM(${Prisma.raw('"' + key + '"')})::text AS ${Prisma.raw('"' + key + '"')}`))}
|
|
||||||
FROM "SupplyChainProductArchiveSalesPerformanceDaily"
|
|
||||||
WHERE "statDate" >= ${period.start}::date AND "statDate" <= ${period.end}::date
|
|
||||||
GROUP BY "barcode"
|
|
||||||
`)
|
|
||||||
for (const aggregate of aggregates) {
|
for (const aggregate of aggregates) {
|
||||||
const value = calculateProductArchiveSalesPerformance(aggregate)
|
const value = calculateProductArchiveSalesPerformance(aggregate)
|
||||||
performanceByBarcode.set(value.barcode, {
|
performanceByBarcode.set(value.barcode, {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export type ProductCatalogWideArchiveRecord = {
|
|||||||
archive_spu: string | null
|
archive_spu: string | null
|
||||||
archive_sku: string | null
|
archive_sku: string | null
|
||||||
archive_product_name: string | null
|
archive_product_name: string | null
|
||||||
|
item_type: string | null
|
||||||
archive_image: string | null
|
archive_image: string | null
|
||||||
archive_designer: string | null
|
archive_designer: string | null
|
||||||
sales_category_name: string | null
|
sales_category_name: string | null
|
||||||
@ -107,6 +108,7 @@ const archiveComparableSelect = {
|
|||||||
height: true,
|
height: true,
|
||||||
volume: true,
|
volume: true,
|
||||||
packType: true,
|
packType: true,
|
||||||
|
productAttribute: true,
|
||||||
productTag: true,
|
productTag: true,
|
||||||
installDocId: true,
|
installDocId: true,
|
||||||
hasDrawing: true,
|
hasDrawing: true,
|
||||||
@ -121,6 +123,7 @@ const archiveComparableSelect = {
|
|||||||
supplierName: true,
|
supplierName: true,
|
||||||
purchaseOwner: true,
|
purchaseOwner: true,
|
||||||
minimumOrderQuantity: true,
|
minimumOrderQuantity: true,
|
||||||
|
sabcRating: true,
|
||||||
fieldSourceMap: true,
|
fieldSourceMap: true,
|
||||||
} satisfies Prisma.SupplyChainProductArchiveSelect
|
} satisfies Prisma.SupplyChainProductArchiveSelect
|
||||||
|
|
||||||
@ -180,6 +183,7 @@ function buildArchiveUpdate(
|
|||||||
height: archive.height,
|
height: archive.height,
|
||||||
volume: archive.volume,
|
volume: archive.volume,
|
||||||
packType: archive.packType,
|
packType: archive.packType,
|
||||||
|
productAttribute: archive.productAttribute,
|
||||||
productTag: archive.productTag,
|
productTag: archive.productTag,
|
||||||
installDocId: archive.installDocId,
|
installDocId: archive.installDocId,
|
||||||
spec: archive.spec,
|
spec: archive.spec,
|
||||||
@ -250,6 +254,7 @@ class SupplyChainProductArchiveSyncRepositoryClass
|
|||||||
archive_spu,
|
archive_spu,
|
||||||
archive_sku,
|
archive_sku,
|
||||||
archive_product_name,
|
archive_product_name,
|
||||||
|
item_type,
|
||||||
archive_image,
|
archive_image,
|
||||||
archive_designer,
|
archive_designer,
|
||||||
sales_category_name,
|
sales_category_name,
|
||||||
@ -334,6 +339,19 @@ class SupplyChainProductArchiveSyncRepositoryClass
|
|||||||
previous?.fieldSourceMap,
|
previous?.fieldSourceMap,
|
||||||
allowImageUpdate,
|
allowImageUpdate,
|
||||||
)
|
)
|
||||||
|
const nextProductStatus = typeof archiveUpdate.productStatus === 'string'
|
||||||
|
? archiveUpdate.productStatus
|
||||||
|
: previous?.productStatus ?? ''
|
||||||
|
const nextProductAttribute = typeof archiveUpdate.productAttribute === 'string'
|
||||||
|
? archiveUpdate.productAttribute
|
||||||
|
: previous?.productAttribute ?? ''
|
||||||
|
if (
|
||||||
|
previous?.sabcRating &&
|
||||||
|
(nextProductStatus !== '销售' || nextProductAttribute !== '成品')
|
||||||
|
) {
|
||||||
|
archiveUpdate.sabcRating = ''
|
||||||
|
archiveUpdate.sabcUpdatedAt = new Date()
|
||||||
|
}
|
||||||
const archive = await tx.supplyChainProductArchive.upsert({
|
const archive = await tx.supplyChainProductArchive.upsert({
|
||||||
where: { barcode: input.barcode },
|
where: { barcode: input.barcode },
|
||||||
create: input.archive,
|
create: input.archive,
|
||||||
|
|||||||
@ -147,6 +147,7 @@ export function mapJstSkuProductArchiveSource(
|
|||||||
addTextField(patch, fieldColumns, 'spu', text(record.i_id).toUpperCase(), 'i_id')
|
addTextField(patch, fieldColumns, 'spu', text(record.i_id).toUpperCase(), 'i_id')
|
||||||
addTextField(patch, fieldColumns, 'sku', text(record.other_7).toUpperCase(), 'other_7')
|
addTextField(patch, fieldColumns, 'sku', text(record.other_7).toUpperCase(), 'other_7')
|
||||||
addTextField(patch, fieldColumns, 'productName', record.name, 'name')
|
addTextField(patch, fieldColumns, 'productName', record.name, 'name')
|
||||||
|
addTextField(patch, fieldColumns, 'productAttribute', '成品', 'item_type')
|
||||||
addTextField(patch, fieldColumns, 'productTag', record.labels, 'labels')
|
addTextField(patch, fieldColumns, 'productTag', record.labels, 'labels')
|
||||||
addTextField(patch, fieldColumns, 'brand', record.brand, 'brand')
|
addTextField(patch, fieldColumns, 'brand', record.brand, 'brand')
|
||||||
addTextField(patch, fieldColumns, 'supplierName', record.supplier_name, 'supplier_name')
|
addTextField(patch, fieldColumns, 'supplierName', record.supplier_name, 'supplier_name')
|
||||||
|
|||||||
@ -156,6 +156,7 @@ export function mapProductCatalogWideArchiveSource(
|
|||||||
height: decimal(height),
|
height: decimal(height),
|
||||||
volume: calculateProductArchiveVolumeDecimal(length, width, height),
|
volume: calculateProductArchiveVolumeDecimal(length, width, height),
|
||||||
packType: resolveProductArchivePackageType(text(record.package_set_count)),
|
packType: resolveProductArchivePackageType(text(record.package_set_count)),
|
||||||
|
productAttribute: text(record.item_type),
|
||||||
productTag: text(record.product_tag),
|
productTag: text(record.product_tag),
|
||||||
installDocId: text(record.drawing_id),
|
installDocId: text(record.drawing_id),
|
||||||
spec: text(record.archive_spec),
|
spec: text(record.archive_spec),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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