feat(supply-chain): SABC 每月3日生成上月并冻结,执行核算仅管理员

This commit is contained in:
居居 2026-09-30 09:51:41 +08:00
parent 39f603c091
commit 6eebb56ba0
11 changed files with 482 additions and 171 deletions

View File

@ -55,13 +55,15 @@
## 商品 SABC 月度核算
- 试算命令:`npm run run:product-sabc -- --year=2026 --month=8 --mode=PREVIEW`。
- 核算命令:`npm run run:product-sabc -- --year=2026 --month=8 --mode=APPLY --confirm=run-product-sabc`;试算(只算不写库)用 `--mode=PREVIEW`,末尾加 `--skip-if-exists` 可跳过已生成月份。
- 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 分钟随机延迟;服务计算上一个自然月的数据。
- 默认调度:每月 3 日中国标准时间 `03:00`,附带最多 10 分钟随机延迟;服务生成上一个自然月的数据,已生成过则跳过(不覆盖管理员的重算结果)。
- 生成即冻结:每次生成会把整行明细写入 `SupplyChainProductArchiveSabcResult.snapshot`,页面、筛选下拉、导出和经营总览都只读取该快照。生成之后档案的销售状态、人员字段等变化都不会再改变已生成月份的数据。
- 重新生成(页面「执行核算」)仅限管理员(`ADMIN` / `SUPER_ADMIN`);其他账号只能查看冻结结果。本地一键刷新 `node scripts/local-sync-archive.mjs` 在每月 3 日前会跳过 SABC 步骤,需要立刻重算时用 `--force-sabc`。
- SABC 的销售收款总金额、真实业绩、发货件数、商品毛利额和售后金额统一读取 `SupplyChainProductArchiveSalesPerformanceDaily`,与产品全量档案页面保持同一月度口径。
- 首次安装生成权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/product-sabc-timer.env`,默认 `PRODUCT_SABC_TIMER_MODE=PREVIEW`。8 月对账确认通过、迁移已应用且上月产品档案日销售数据完整后,才允许改为 `APPLY`。
- 首次安装生成权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/product-sabc-timer.env`,默认 `PRODUCT_SABC_TIMER_MODE=APPLY`;如需先观察可临时改为 `PREVIEW`(只试算不写库、不冻结)。
- SABC 自动任务只读取现有 `DATABASE_URL`;上游日销售同步任务继续使用 `JST_REPORT_DB_*`,凭据不写入定时器配置。安装、迁移、首次 APPLY 和生产核算都需要单独授权。
## 操作日志保留策略

View File

@ -0,0 +1,5 @@
-- 商品 SABC 月度核算冻结:保存生成时的整行明细快照。
-- 生成后(每月 3 日自动、或管理员手动执行核算)页面只读取该快照,
-- 后续档案销售状态、人员字段等变化不会再改变已生成月份的数据。
ALTER TABLE "SupplyChainProductArchiveSabcResult"
ADD COLUMN IF NOT EXISTS "snapshot" JSONB;

View File

@ -781,6 +781,8 @@ model SupplyChainProductArchiveSabcResult {
afterSaleAmount Decimal @db.Decimal(24, 4)
status String @default("CALCULATED") @db.VarChar(24)
reason String @default("") @db.Text
// 生成时的整行明细快照:核算结果一经生成即冻结,不再随档案/销售状态变化。
snapshot Json?
calculatedAt DateTime @default(now())
@@unique([barcode, periodStart, periodEnd])

View File

@ -9,7 +9,7 @@ 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}"
SCHEDULE="${PRODUCT_SABC_TIMER_SCHEDULE:-*-*-03 03:00:00 Asia/Shanghai}"
RANDOMIZED_DELAY_SEC="${PRODUCT_SABC_TIMER_RANDOMIZED_DELAY_SEC:-10min}"
if [ "$(id -u)" -ne 0 ]; then
@ -31,8 +31,10 @@ 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
# 商品 SABC 月度核算运行模式。
# APPLY = 每月 3 日生成上月数据并冻结(已生成过则跳过,不覆盖管理员的重算结果)。
# PREVIEW = 只试算不写库。
PRODUCT_SABC_TIMER_MODE=APPLY
EOF
echo "Created env template: $SABC_ENV"
fi
@ -59,7 +61,7 @@ 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)'
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 --skip-if-exists"; fi)'
TimeoutStartSec=6h
StandardOutput=journal
StandardError=journal
@ -67,7 +69,7 @@ EOF
cat >"$TIMER_FILE" <<EOF
[Unit]
Description=Run product SABC calculation on the second day of each month
Description=Generate the previous month product SABC labels on the third day of each month
[Timer]
OnCalendar=$SCHEDULE

View File

@ -6,7 +6,7 @@
// 4) 以正式库产品全量档案对齐(生产状态 + 创建时间)
// 5) 回填产品全量档案「产品类型」(定厂优先,聚水潭 vc_name 兜底)
// 6) 以正式库类目档位表同步本地类目配置
// 7) 重算指定月份(默认上个月)SABC 并写库
// 7) 每月 3 日起生成上月 SABC(已生成过则跳过,结果冻结不再变动)
// 用法:
// node scripts/local-sync-archive.mjs [--month=2026-08] [--dry-run] [--skip-sabc]
import { spawn } from 'node:child_process'
@ -45,14 +45,19 @@ if (argv.includes('-h') || argv.includes('--help')) {
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`)
node scripts/local-sync-archive.mjs --skip-sabc # 只同步档案,不重算 SABC
node scripts/local-sync-archive.mjs --force-sabc # 忽略每月3号限制,强制重算(覆盖已冻结数据)`)
process.exit(0)
}
const apply = !argv.includes('--dry-run')
const skipSabc = argv.includes('--skip-sabc')
const forceSabc = argv.includes('--force-sabc')
const monthArgument = argv.find((argument) => argument.startsWith('--month='))?.slice('--month='.length) ?? ''
// 人工表口径:每月 3 日生成上月数据,生成后即冻结,不再随销售状态变化。
const SABC_GENERATION_DAY = 3
function previousMonth() {
const date = new Date()
date.setMonth(date.getMonth() - 1)
@ -203,8 +208,16 @@ async function main() {
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'])
const today = new Date().getDate()
if (!forceSabc && today < SABC_GENERATION_DAY) {
console.log(`==> 7/7 每月${SABC_GENERATION_DAY}日生成上月 SABC,今天 ${today} 日,跳过(如需立即重算用 --force-sabc)`)
} else if (forceSabc) {
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 生成 ${year}-${monthNumber} SABC(APPLY;已生成过则冻结不动)`)
await run(['scripts/run-product-sabc.ts', `--year=${year}`, `--month=${monthNumber}`, '--mode=APPLY', '--confirm=run-product-sabc', '--skip-if-exists'])
}
} else {
console.log('==> 7/7 已跳过 SABC 重算')
}

View File

@ -19,12 +19,32 @@ async function main() {
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。')
// 月度核算结果生成后即冻结:--skip-if-exists 用于定时任务,避免覆盖已生成(或被管理员重算)的月份。
const skipIfExists = process.argv.includes('--skip-if-exists')
const [{ prisma }, { supplyChainProductSabcService }] = await Promise.all([
const [{ prisma }, { supplyChainProductSabcService }, { createPeriodDates }] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/supply-chain-product-sabc.service'),
import('../src/server/services/supply-chain-product-sabc'),
])
try {
if (skipIfExists && mode === 'APPLY') {
const { start, end } = createPeriodDates(year, month)
const existing = await prisma.supplyChainProductArchiveSabcResult.count({
where: { periodStart: start, periodEnd: end },
})
if (existing > 0) {
console.log(JSON.stringify({
mode,
skipped: true,
reason: 'period-already-generated',
periodStart: start.toISOString().slice(0, 10),
periodEnd: end.toISOString().slice(0, 10),
existingRows: existing,
}, null, 2))
return
}
}
const result = await supplyChainProductSabcService.calculate({ year, month, apply: mode === 'APPLY' })
const summary = {
mode,

View File

@ -1,5 +1,6 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { isAdmin } from '@/lib/role-scope'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
@ -26,7 +27,7 @@ const querySchema = z.object({
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'),
mode: z.enum(['PREVIEW', 'APPLY']).default('APPLY'),
}).strict()
async function checkAccess(user: Parameters<typeof supplyChainPermissionSettingsService.getUserAccess>[0], action: 'view' | 'edit') {
@ -52,12 +53,12 @@ export async function POST(request: NextRequest) {
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 (!(await checkAccess(ctx.user, 'view'))) {
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)
// 核算结果一经生成即冻结(每月 3 日自动生成上月),重新核算只允许管理员操作。
if (!isAdmin(ctx.user)) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '执行核算仅限管理员操作,请联系管理员', 403, ctx.requestId)
}
const result = await supplyChainProductSabcService.calculate({
year: parsed.data.year,

View File

@ -55,6 +55,16 @@ function previousMonth() {
return { year: date.getFullYear(), month: date.getMonth() + 1 }
}
function formatShanghaiDateTime(value: string | null | undefined) {
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, hourCycle: 'h23', timeZone: 'Asia/Shanghai',
}).format(date)
}
async function readResponse(response: Response) {
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败')
@ -155,7 +165,7 @@ function FilterFieldCombo({ label, field, value, year, month, onChange }: {
)
}
export function ProductSabcView({ definition, canManage, canDownload }: { definition: ManagementBlankPageDefinition; canManage: boolean; canDownload: boolean }) {
export function ProductSabcView({ definition, canCalculate, canDownload }: { definition: ManagementBlankPageDefinition; canCalculate: boolean; canDownload: boolean }) {
const initial = useMemo(() => previousMonth(), [])
const [year, setYear] = useState(initial.year)
const [month, setMonth] = useState(initial.month)
@ -168,6 +178,8 @@ export function ProductSabcView({ definition, canManage, canDownload }: { defini
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [salesDataAvailable, setSalesDataAvailable] = useState(true)
const [frozen, setFrozen] = useState(true)
const [generatedAt, setGeneratedAt] = useState<string | null>(null)
const columns = useMemo(() => columnsForMonth(year, month), [month, year])
const load = useCallback(async (page = 1) => {
@ -185,6 +197,8 @@ export function ProductSabcView({ definition, canManage, canDownload }: { defini
setRows(data.rows)
setPageInfo(data.pageInfo)
setSalesDataAvailable(data.salesDataAvailable !== false)
setFrozen(data.frozen !== false)
setGeneratedAt(typeof data.generatedAt === 'string' ? data.generatedAt : null)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '加载失败')
} finally {
@ -194,16 +208,16 @@ export function ProductSabcView({ definition, canManage, canDownload }: { defini
useEffect(() => { void load() }, [load])
const calculate = async (mode: 'PREVIEW' | 'APPLY') => {
const calculate = async () => {
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 }),
body: JSON.stringify({ year, month, mode: 'APPLY' }),
}))
setMessage(`${mode === 'PREVIEW' ? '试算' : '核算'}完成:来源 ${data.sourceRows} 条,符合条件 ${data.eligibleRows} 条,生成标签 ${data.taggedRows} 条`)
setMessage(`核算完成:来源 ${data.sourceRows} 条,符合条件 ${data.eligibleRows} 条,生成标签 ${data.taggedRows} 条`)
await load()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '核算失败')
@ -284,8 +298,7 @@ export function ProductSabcView({ definition, canManage, canDownload }: { defini
<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}
{canCalculate ? <button type="button" className={styles.primaryButton} title="仅管理员可执行;重新生成后本期数据以本次为准" onClick={() => void calculate()} disabled={loading}><RefreshCw size={15} />执行核算</button> : null}
</div>
<div className={styles.sabcSearchActions}>
<button type="submit" className={styles.secondaryButton} title="应用筛选"><Search size={15} />查询</button>
@ -315,6 +328,13 @@ export function ProductSabcView({ definition, canManage, canDownload }: { defini
</div>
{error ? <div className={styles.noticeError}>{error}</div> : null}
{!error && !salesDataAvailable ? <div className={styles.noticeInfo}>{year}年{month}月销售事实尚未同步,销售指标和 SABC 标签暂为空;请先完成产品全量档案销售数据同步。</div> : null}
{!error && salesDataAvailable ? (
<div className={styles.noticeInfo}>
{frozen
? `${year}年${month}月数据已于 ${formatShanghaiDateTime(generatedAt) || '本月生成日'} 生成并冻结,生成之后的销售状态、人员等变化不会改变本期数据;需要重算请由管理员点击「执行核算」。`
: `${year}年${month}月尚未生成正式数据,当前为实时口径;每月3日自动生成上月数据,生成后即冻结。`}
</div>
) : null}
<SabcOverviewSection year={year} month={month} onFilter={applyOverviewFilter} />
<div className={styles.tableWrap}>
<table className={styles.tableWide}>

View File

@ -1,4 +1,5 @@
import { notFound, redirect } from 'next/navigation'
import { isAdmin } from '@/lib/role-scope'
import { requireSession } from '@/lib/server-session'
import { getManagementBlankPage } from '@/lib/supply-chain-management-blank-pages'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
@ -17,7 +18,8 @@ export default async function ManagementBlankPage({ params }: { params: Promise<
if (!access.view) redirect('/supply-chain')
if (definition.key === 'blank-page-3') {
return <ProductSabcView definition={definition} canManage={access.edit} canDownload={access.download} />
// 商品 SABC 打标:月度核算结果生成后冻结,重新核算只允许管理员操作。
return <ProductSabcView definition={definition} canCalculate={isAdmin(authUser)} canDownload={access.download} />
}
return (

View File

@ -531,6 +531,113 @@ function resultData(target: ArchiveTarget, row: SabcPageRow, values: MonthlyValu
}
}
// —— 冻结口径 ——
// 生成(APPLY)时把整行明细写入 snapshot;页面/导出只读取快照,
// 生成之后档案的销售状态、人员字段等变化都不会再改变已生成月份的数据。
const SNAPSHOT_STRING_FIELDS = [
'id', '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', 'shipAmount', 'realPerformance',
'shipQty', 'grossProfit', 'afterSaleAmount', 'grossMargin', 'afterSaleRatio',
'assessmentStatus', 'noAssessmentReason', 'sameProductCode',
] as const satisfies ReadonlyArray<keyof SabcPageRow>
export function parseSabcSnapshotRow(value: unknown): SabcPageRow | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const raw = value as Record<string, unknown>
const productCode = String(raw.productCode ?? '').trim()
if (!productCode) return null
const row = { eligible: Boolean(raw.eligible) } as SabcPageRow
for (const field of SNAPSHOT_STRING_FIELDS) row[field] = String(raw[field] ?? '')
row.productCode = productCode
return row
}
type StoredSabcResultRow = {
barcode: string
archiveId: string
category: string
performanceScale: string
salesScale: string
finalScale: string
profitLabel: string
sabcRating: string
averageDailySales: Prisma.Decimal | null
feeRatio: Prisma.Decimal | null
grossMargin: Prisma.Decimal | null
shipAmount: Prisma.Decimal
realPerformance: Prisma.Decimal
shipQty: bigint
grossProfit: Prisma.Decimal
afterSaleAmount: Prisma.Decimal
reason: string
snapshot: unknown
}
type SabcPeriodRows = {
rows: SabcPageRow[]
frozen: boolean
generatedAt: string | null
salesDataRows: number
}
function emptySabcPageRow(barcode: string, id: string, replacement: ReplacementContext): SabcPageRow {
const row = { eligible: false } as SabcPageRow
for (const field of SNAPSHOT_STRING_FIELDS) row[field] = ''
row.id = id
row.productCode = barcode
row.sameProductCode = replacement.sameProductCode
return row
}
// 旧快照没有整行 JSON 时的兜底:用档案描述字段 + 历史指标值还原,保证读取到的仍是生成时数值。
function frozenValuesFromStoredResult(
row: SabcPageRow,
entry: StoredSabcResultRow,
periodEnd: Date,
): SabcPageRow {
const days = Math.max(1, periodEnd.getUTCDate())
const realPerformance = new Prisma.Decimal(entry.realPerformance ?? 0)
const afterSaleAmount = new Prisma.Decimal(entry.afterSaleAmount ?? 0)
row.categoryClass = entry.category || row.categoryClass
row.performanceScale = entry.performanceScale
row.salesScale = entry.salesScale
row.finalScale = entry.finalScale
row.profitLabel = entry.profitLabel
row.sabcRating = entry.sabcRating
row.shipAmount = new Prisma.Decimal(entry.shipAmount ?? 0).toFixed(4)
row.realPerformance = realPerformance.toFixed(4)
row.shipQty = String(entry.shipQty ?? 0)
row.grossProfit = new Prisma.Decimal(entry.grossProfit ?? 0).toFixed(4)
row.afterSaleAmount = afterSaleAmount.toFixed(4)
row.averageDailySales = entry.averageDailySales
? new Prisma.Decimal(entry.averageDailySales).toFixed(8)
: formatDecimal(new Prisma.Decimal(row.shipQty || 0).dividedBy(days), 8)
row.feeRatio = entry.feeRatio
? new Prisma.Decimal(entry.feeRatio).toFixed(8)
: realPerformance.isZero() ? '异常' : ''
row.grossMargin = entry.grossMargin ? new Prisma.Decimal(entry.grossMargin).toFixed(8) : ''
row.afterSaleRatio = realPerformance.isZero() ? '' : afterSaleAmount.dividedBy(realPerformance).toFixed(8)
// 人工表口径:最终规模为爆款/热卖款/动销款才是动销;发货件数为 0 时一律不动销。
row.movingStatus = BigInt(row.shipQty || '0') > BigInt(0) && ['爆款', '热卖款', '动销款'].includes(row.finalScale)
? '动销'
: '不动销'
row.noAssessmentReason = entry.reason || ''
row.assessmentStatus = row.noAssessmentReason === 'ODM' ? '不考核' : '考核'
return row
}
function sortPeriodRows(rows: SabcPageRow[]) {
return [...rows].sort((left, right) => {
const leftTime = left.createTime ?? ''
const rightTime = right.createTime ?? ''
if (leftTime !== rightTime) return leftTime < rightTime ? 1 : -1
return left.productCode.localeCompare(right.productCode)
})
}
type SabcOverviewSummary = {
year: number
month: number
@ -738,7 +845,11 @@ export class SupplyChainProductSabcService {
}) {
const sourceRows = input.rows.filter(({ hasAggregate, row }) => hasAggregate || Boolean(row.sabcRating))
const eligibleRows = sourceRows.filter(({ row }) => row.eligible)
const results = eligibleRows.map(({ target, values, row }) => resultData(target, row, values, input.start, input.end))
const results = eligibleRows.map(({ target, values, row }) => ({
...resultData(target, row, values, input.start, input.end),
// 冻结快照:整行明细原样落库,之后页面只读快照。
snapshot: row as unknown as Prisma.InputJsonValue,
}))
const tagGroups = new Map<string, string[]>()
for (const { target, row } of eligibleRows) {
const list = tagGroups.get(row.sabcRating) ?? []
@ -781,6 +892,90 @@ export class SupplyChainProductSabcService {
return periods.map((period) => ({ start: period.periodStart.toISOString().slice(0, 10), end: period.periodEnd.toISOString().slice(0, 10), label: `${period.periodEnd.getUTCFullYear()}年${period.periodEnd.getUTCMonth() + 1}月` }))
}
// 月度行数据:该月已生成核算结果时读取冻结快照,未生成时按实时口径计算。
private async loadPeriodRows(input: { year: number; month: number; fresh?: boolean }): Promise<SabcPeriodRows> {
const cacheKey = `periodRows:${input.year}:${input.month}`
const cached = input.fresh ? null : readSabcOverviewCache<SabcPeriodRows>(cacheKey)
if (cached) return cached
const { start, end } = createPeriodDates(input.year, input.month)
const result = (await this.loadFrozenPeriodRows(start, end))
?? (await this.buildLivePeriodRows(input.year, input.month))
writeSabcOverviewCache(cacheKey, result)
return result
}
private async loadFrozenPeriodRows(start: Date, end: Date): Promise<SabcPeriodRows | null> {
const stored = await prisma.supplyChainProductArchiveSabcResult.findMany({
where: { periodStart: start, periodEnd: end },
orderBy: { barcode: 'asc' },
select: {
barcode: true, archiveId: true, category: true, performanceScale: true, salesScale: true,
finalScale: true, profitLabel: true, sabcRating: true, averageDailySales: true, feeRatio: true,
grossMargin: true, shipAmount: true, realPerformance: true, shipQty: true, grossProfit: true,
afterSaleAmount: true, reason: true, snapshot: true,
},
}) as StoredSabcResultRow[]
if (stored.length === 0) return null
const legacyEntries = stored.filter((entry) => !parseSabcSnapshotRow(entry.snapshot))
const [replacementRules, legacyTargets, run] = await Promise.all([
legacyEntries.length > 0 ? listProductArchiveReplacementRules() : Promise.resolve([]),
legacyEntries.length > 0
? prisma.supplyChainProductArchive.findMany({
where: { barcode: { in: legacyEntries.map((entry) => entry.barcode) } },
select: archiveSelect,
})
: Promise.resolve([]),
prisma.supplyChainProductArchiveSabcRun.findFirst({
where: { periodStart: start, periodEnd: end },
orderBy: { completedAt: 'desc' },
select: { completedAt: true },
}),
])
const replacementIndex = buildReplacementIndex(replacementRules)
const targetMap = new Map(legacyTargets.map((target) => [normalizeCode(target.barcode), target]))
const rows = stored.map((entry) => {
const snapshot = parseSabcSnapshotRow(entry.snapshot)
if (snapshot) return snapshot
const replacement = replacementContextFor(entry.barcode, replacementIndex)
const target = targetMap.get(normalizeCode(entry.barcode))
const row = target
? createBlankRow(target, end, null, replacement)
: emptySabcPageRow(normalizeCode(entry.barcode), entry.archiveId, replacement)
return frozenValuesFromStoredResult(row, entry, end)
})
return {
rows: sortPeriodRows(rows),
frozen: true,
generatedAt: run?.completedAt?.toISOString() ?? null,
salesDataRows: rows.length,
}
}
private async buildLivePeriodRows(year: number, month: number): Promise<SabcPeriodRows> {
const { start, end } = createPeriodDates(year, month)
const [categoryConfigurationResult, replacementRules, targets] = await Promise.all([
loadCategoryConfigurations(),
listProductArchiveReplacementRules(),
prisma.supplyChainProductArchive.findMany({
where: createSabcArchiveScope(year, month),
select: archiveSelect,
orderBy: [{ businessCreatedAt: 'desc' }, { barcode: 'asc' }],
}),
])
const configurations = categoryConfigurationResult
const replacementIndex = buildReplacementIndex(replacementRules)
const aggregateMap = await listMonthlyAggregateMap(start, end, replacementIndex)
const rows = targets.map((target) => {
const configuredCategory = categoryConfiguration(target, configurations)
const replacement = replacementContextFor(target.barcode, replacementIndex)
const values = aggregateMap.get(normalizeCode(target.barcode))
return values
? applyValues(target, end, values, configuredCategory, replacement)
: applyValues(target, end, ZERO_MONTHLY_VALUES, configuredCategory, replacement)
})
return { rows, frozen: false, generatedAt: null, salesDataRows: aggregateMap.size }
}
// 经营总览:按 SABC 标签汇总商品数与业绩(商品数占比 vs 业绩占比对比)。
async summary(input: { year: number; month: number; fresh?: boolean }) {
const cacheKey = `summary:${input.year}:${input.month}`
@ -890,59 +1085,17 @@ export class SupplyChainProductSabcService {
return [...groups]
.filter((group) => !normalizedQuery || group.toLocaleLowerCase('zh-CN').includes(normalizedQuery))
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
}
const scope = createSabcArchiveScope(input.year, input.month)
const textFilter = query
? { contains: query, mode: 'insensitive' as const }
: { not: '' }
if (input.field === 'itemNumber') {
const rows = await prisma.supplyChainProductArchive.findMany({
where: { ...scope, sku: textFilter }, select: { sku: true },
distinct: ['sku'], orderBy: { sku: 'asc' },
})
return rows.map((row) => row.sku.trim()).filter(Boolean)
// 候选值取自本期行数据(已生成月份即冻结快照),保证与明细/导出可见范围一致。
const period = await this.loadPeriodRows({ year: input.year, month: input.month })
const values = new Set<string>()
for (const row of period.rows) {
const value = String(row[input.field] ?? '').trim()
if (!value) continue
if (normalizedQuery && !value.toLocaleLowerCase('zh-CN').includes(normalizedQuery)) continue
values.add(value)
}
if (input.field === 'styleCode') {
const rows = await prisma.supplyChainProductArchive.findMany({
where: { ...scope, spu: textFilter }, select: { spu: true },
distinct: ['spu'], orderBy: { spu: 'asc' },
})
return rows.map((row) => row.spu.trim()).filter(Boolean)
}
if (input.field === 'opsDeptHead') {
const rows = await prisma.supplyChainProductArchive.findMany({
where: { ...scope, opsDeptHead: textFilter }, select: { opsDeptHead: true },
distinct: ['opsDeptHead'], orderBy: { opsDeptHead: 'asc' },
})
return rows.map((row) => row.opsDeptHead.trim()).filter(Boolean)
}
if (input.field === 'opsHead') {
const rows = await prisma.supplyChainProductArchive.findMany({
where: { ...scope, opsHead: textFilter }, select: { opsHead: true },
distinct: ['opsHead'], orderBy: { opsHead: 'asc' },
})
return rows.map((row) => row.opsHead.trim()).filter(Boolean)
}
if (input.field === 'purchaser') {
const rows = await prisma.supplyChainProductArchive.findMany({
where: { ...scope, purchaseOwner: textFilter }, select: { purchaseOwner: true },
distinct: ['purchaseOwner'], orderBy: { purchaseOwner: 'asc' },
})
return rows.map((row) => row.purchaseOwner.trim()).filter(Boolean)
}
if (input.field === 'designer') {
const rows = await prisma.supplyChainProductArchive.findMany({
where: { ...scope, designer: textFilter }, select: { designer: true },
distinct: ['designer'], orderBy: { designer: 'asc' },
})
return rows.map((row) => row.designer.trim()).filter(Boolean)
}
const rows = await prisma.supplyChainProductArchive.findMany({
where: { ...scope, barcode: textFilter }, select: { barcode: true },
distinct: ['barcode'], orderBy: { barcode: 'asc' },
})
return rows.map((row) => row.barcode.trim()).filter(Boolean)
return [...values].sort((left, right) => left.localeCompare(right, 'zh-CN'))
}
async listPage(input: {
@ -954,69 +1107,6 @@ export class SupplyChainProductSabcService {
keyword?: string
filters?: SabcFieldFilters
rating?: SabcRating
}) {
const { start, end } = createPeriodDates(input.year, input.month)
const keyword = input.keyword?.trim()
const field = input.field ?? 'productCode'
const where: Prisma.SupplyChainProductArchiveWhereInput = {
...createSabcArchiveScope(input.year, input.month),
}
const keywords = splitSearchKeywords(keyword)
const fieldFilters: Array<{ field: SabcFilterField; terms: string[] }> = []
if (keywords.length > 0) fieldFilters.push({ field, terms: keywords })
for (const [key, value] of Object.entries(input.filters ?? {})) {
const terms = splitSearchKeywords(value)
if (terms.length > 0) fieldFilters.push({ field: key as SabcFilterField, terms })
}
const [categoryConfigurationResult, replacementRules] = await Promise.all([
loadCategoryConfigurations(),
listProductArchiveReplacementRules(),
])
const configurations = categoryConfigurationResult
const replacementIndex = buildReplacementIndex(replacementRules)
const aggregateMap = await listMonthlyAggregateMap(start, end, replacementIndex)
const needsComputedFiltering = fieldFilters.length > 0 || Boolean(input.rating)
const targets = await prisma.supplyChainProductArchive.findMany({
where,
select: archiveSelect,
orderBy: [{ businessCreatedAt: 'desc' }, { barcode: 'asc' }],
...(needsComputedFiltering ? {} : { skip: (input.page - 1) * input.pageSize, take: input.pageSize }),
})
const allRows = targets.map((target) => {
const configuredCategory = categoryConfiguration(target, configurations)
const replacement = replacementContextFor(target.barcode, replacementIndex)
const values = aggregateMap.get(normalizeCode(target.barcode))
return values
? applyValues(target, end, values, configuredCategory, replacement)
: applyValues(target, end, ZERO_MONTHLY_VALUES, configuredCategory, replacement)
})
const filteredRows = allRows.filter((row) => {
if (input.rating && row.sabcRating !== input.rating) return false
return fieldFilters.every(({ field: key, terms }) => {
const value = String(row[key] ?? '').toLocaleLowerCase('zh-CN')
return terms.some((term) => value.includes(term))
})
})
const total = needsComputedFiltering ? filteredRows.length : await prisma.supplyChainProductArchive.count({ where })
const rows = needsComputedFiltering
? filteredRows.slice((input.page - 1) * input.pageSize, input.page * input.pageSize)
: filteredRows
return {
rows,
pageInfo: { page: input.page, pageSize: input.pageSize, total, totalPages: Math.max(1, Math.ceil(total / input.pageSize)) },
periodEnd: end.toISOString().slice(0, 10),
salesDataAvailable: aggregateMap.size > 0,
salesDataRows: aggregateMap.size,
}
}
async listExportRows(input: {
year: number
month: number
field?: SabcFilterField
keyword?: string
filters?: SabcFieldFilters
rating?: SabcRating
}) {
const { end } = createPeriodDates(input.year, input.month)
const keyword = input.keyword?.trim()
@ -1028,34 +1118,52 @@ export class SupplyChainProductSabcService {
const terms = splitSearchKeywords(value)
if (terms.length > 0) fieldFilters.push({ field: key as SabcFilterField, terms })
}
const [categoryConfigurationResult, replacementRules, targets] = await Promise.all([
loadCategoryConfigurations(),
listProductArchiveReplacementRules(),
prisma.supplyChainProductArchive.findMany({
where: createSabcArchiveScope(input.year, input.month),
select: archiveSelect,
orderBy: [{ businessCreatedAt: 'desc' }, { barcode: 'asc' }],
}),
])
const configurations = categoryConfigurationResult
const replacementIndex = buildReplacementIndex(replacementRules)
const aggregateMap = await listMonthlyAggregateMap(createPeriodDates(input.year, input.month).start, end, replacementIndex)
return targets
.map((target) => {
const configuredCategory = categoryConfiguration(target, configurations)
const replacement = replacementContextFor(target.barcode, replacementIndex)
const values = aggregateMap.get(normalizeCode(target.barcode))
return values
? applyValues(target, end, values, configuredCategory, replacement)
: applyValues(target, end, ZERO_MONTHLY_VALUES, configuredCategory, replacement)
const period = await this.loadPeriodRows({ year: input.year, month: input.month })
const filteredRows = period.rows.filter((row) => {
if (input.rating && row.sabcRating !== input.rating) return false
return fieldFilters.every(({ field: key, terms }) => {
const value = String(row[key] ?? '').toLocaleLowerCase('zh-CN')
return terms.some((term) => value.includes(term))
})
.filter((row) => {
if (input.rating && row.sabcRating !== input.rating) return false
return fieldFilters.every(({ field: key, terms }) => {
const value = String(row[key] ?? '').toLocaleLowerCase('zh-CN')
return terms.some((term) => value.includes(term))
})
})
const total = filteredRows.length
const rows = filteredRows.slice((input.page - 1) * input.pageSize, input.page * input.pageSize)
return {
rows,
pageInfo: { page: input.page, pageSize: input.pageSize, total, totalPages: Math.max(1, Math.ceil(total / input.pageSize)) },
periodEnd: end.toISOString().slice(0, 10),
salesDataAvailable: period.salesDataRows > 0,
salesDataRows: period.salesDataRows,
frozen: period.frozen,
generatedAt: period.generatedAt,
}
}
async listExportRows(input: {
year: number
month: number
field?: SabcFilterField
keyword?: string
filters?: SabcFieldFilters
rating?: SabcRating
}) {
const keyword = input.keyword?.trim()
const field = input.field ?? 'productCode'
const keywords = splitSearchKeywords(keyword)
const fieldFilters: Array<{ field: SabcFilterField; terms: string[] }> = []
if (keywords.length > 0) fieldFilters.push({ field, terms: keywords })
for (const [key, value] of Object.entries(input.filters ?? {})) {
const terms = splitSearchKeywords(value)
if (terms.length > 0) fieldFilters.push({ field: key as SabcFilterField, terms })
}
const period = await this.loadPeriodRows({ year: input.year, month: input.month })
return period.rows.filter((row) => {
if (input.rating && row.sabcRating !== input.rating) return false
return fieldFilters.every(({ field: key, terms }) => {
const value = String(row[key] ?? '').toLocaleLowerCase('zh-CN')
return terms.some((term) => value.includes(term))
})
})
}
}

View File

@ -5,6 +5,12 @@ jest.mock('@/lib/prisma', () => ({
findMany: jest.fn(),
count: jest.fn(),
},
supplyChainProductArchiveSabcResult: {
findMany: jest.fn(),
},
supplyChainProductArchiveSabcRun: {
findFirst: jest.fn(),
},
},
}))
@ -29,6 +35,7 @@ import {
SABC_SCALE_RULES,
} from './supply-chain-product-sabc'
import {
clearSabcOverviewCache,
createSabcArchiveScope,
newProductPeriodDays,
SupplyChainProductSabcService,
@ -36,12 +43,18 @@ import {
const archiveFindMany = prisma.supplyChainProductArchive.findMany as jest.Mock
const archiveCount = prisma.supplyChainProductArchive.count as jest.Mock
const sabcResultFindMany = prisma.supplyChainProductArchiveSabcResult.findMany as jest.Mock
const sabcRunFindFirst = prisma.supplyChainProductArchiveSabcRun.findFirst as jest.Mock
const salesAggregates = listProductArchiveSalesAggregates as jest.Mock
const replacementRules = listProductArchiveReplacementRules as jest.Mock
beforeEach(() => {
jest.resetAllMocks()
clearSabcOverviewCache()
replacementRules.mockResolvedValue([])
// 默认没有已生成的冻结快照:保持实时口径用例。
sabcResultFindMany.mockResolvedValue([])
sabcRunFindFirst.mockResolvedValue(null)
})
describe('supply-chain-product-sabc rules', () => {
@ -160,7 +173,6 @@ describe('supply-chain-product-sabc rules', () => {
;(prisma.$queryRaw as jest.Mock).mockResolvedValueOnce([])
salesAggregates.mockResolvedValueOnce([])
archiveFindMany.mockResolvedValueOnce([])
archiveCount.mockResolvedValueOnce(0)
await new SupplyChainProductSabcService().listPage({
year: 2026,
@ -185,9 +197,6 @@ describe('supply-chain-product-sabc rules', () => {
],
}),
}))
expect(archiveCount).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ productStatus: '销售', productAttribute: '成品' }),
}))
})
it('does not silently use other thresholds when the scale table is missing', async () => {
@ -793,6 +802,133 @@ describe('supply-chain-product-sabc rules', () => {
}))
})
it('reads the frozen snapshot for a generated month', async () => {
sabcResultFindMany.mockResolvedValueOnce([{
barcode: 'FROZEN-001',
archiveId: 'archive-frozen',
category: '角柜',
performanceScale: '爆款',
salesScale: '动销款',
finalScale: '动销款',
profitLabel: '一级',
sabcRating: 'A',
averageDailySales: new Prisma.Decimal(0),
feeRatio: null,
grossMargin: null,
shipAmount: new Prisma.Decimal(0),
realPerformance: new Prisma.Decimal(0),
shipQty: BigInt(0),
grossProfit: new Prisma.Decimal(0),
afterSaleAmount: new Prisma.Decimal(0),
reason: '',
snapshot: {
id: 'archive-frozen',
styleCode: 'SPU-9',
itemNumber: 'SKU-9',
productCode: 'FROZEN-001',
productionStatus: '销售',
category: '住宅家具 - 角柜',
categoryClass: '角柜',
opsHead: '运营-甲',
sabcRating: 'A',
finalScale: '动销款',
realPerformance: '1234.0000',
shipQty: '10',
createTime: '2026-08-20T02:00:00.000Z',
eligible: true,
},
}])
sabcRunFindFirst.mockResolvedValueOnce({ completedAt: new Date('2026-09-03T01:00:00.000Z') })
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(result.frozen).toBe(true)
expect(result.generatedAt).toBe('2026-09-03T01:00:00.000Z')
expect(result.rows).toHaveLength(1)
expect(result.rows[0]).toEqual(expect.objectContaining({
productCode: 'FROZEN-001',
productionStatus: '销售',
opsHead: '运营-甲',
sabcRating: 'A',
finalScale: '动销款',
realPerformance: '1234.0000',
shipQty: '10',
}))
// 已生成月份只读快照:不再查询档案与销售事实
expect(archiveFindMany).not.toHaveBeenCalled()
expect(salesAggregates).not.toHaveBeenCalled()
})
it('keeps stored metrics when a legacy snapshot row has no json payload', async () => {
sabcResultFindMany.mockResolvedValueOnce([{
barcode: 'LEGACY-001',
archiveId: 'archive-legacy',
category: '角柜',
performanceScale: '异常',
salesScale: '异常',
finalScale: '滞销款',
profitLabel: '五级',
sabcRating: 'D',
averageDailySales: new Prisma.Decimal(0),
feeRatio: null,
grossMargin: null,
shipAmount: new Prisma.Decimal('12.3000'),
realPerformance: new Prisma.Decimal(0),
shipQty: BigInt(0),
grossProfit: new Prisma.Decimal(0),
afterSaleAmount: new Prisma.Decimal(0),
reason: 'ODM',
snapshot: null,
}])
archiveFindMany.mockResolvedValueOnce([{
id: 'archive-legacy',
barcode: 'LEGACY-001',
spu: 'SPU-1',
sku: 'SKU-1',
businessCreatedAt: new Date('2026-08-01T00:00:00.000Z'),
categoryL1: '住宅家具',
categoryL2: '住宅家具 - 角柜',
productName: '角柜',
productStatus: '退市',
productAttribute: '成品',
productionType: 'ODM',
brand: '品牌',
productTag: '',
sabcRating: '',
sabcUpdatedAt: null,
}])
const result = await new SupplyChainProductSabcService().listPage({
year: 2026,
month: 8,
page: 1,
pageSize: 50,
})
expect(result.frozen).toBe(true)
expect(result.rows[0]).toEqual(expect.objectContaining({
productCode: 'LEGACY-001',
productionStatus: '退市',
sabcRating: 'D',
finalScale: '滞销款',
profitLabel: '五级',
shipAmount: '12.3000',
realPerformance: '0.0000',
shipQty: '0',
averageDailySales: '0.00000000',
feeRatio: '异常',
movingStatus: '不动销',
assessmentStatus: '不考核',
noAssessmentReason: 'ODM',
}))
expect(salesAggregates).not.toHaveBeenCalled()
})
it('supports batch keyword search separated by newline/comma', async () => {
;(prisma.$queryRaw as jest.Mock).mockResolvedValueOnce([])
const archiveRow = (barcode: string) => ({