diff --git a/furniture-3d-system/docs/workflow/生产部署入口说明.md b/furniture-3d-system/docs/workflow/生产部署入口说明.md index 39badfd3..fa219c42 100644 --- a/furniture-3d-system/docs/workflow/生产部署入口说明.md +++ b/furniture-3d-system/docs/workflow/生产部署入口说明.md @@ -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 和生产核算都需要单独授权。 ## 操作日志保留策略 diff --git a/furniture-3d-system/prisma/migrations/20260930120000_add_product_sabc_snapshot/migration.sql b/furniture-3d-system/prisma/migrations/20260930120000_add_product_sabc_snapshot/migration.sql new file mode 100644 index 00000000..ec7338fa --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260930120000_add_product_sabc_snapshot/migration.sql @@ -0,0 +1,5 @@ +-- 商品 SABC 月度核算冻结:保存生成时的整行明细快照。 +-- 生成后(每月 3 日自动、或管理员手动执行核算)页面只读取该快照, +-- 后续档案销售状态、人员字段等变化不会再改变已生成月份的数据。 +ALTER TABLE "SupplyChainProductArchiveSabcResult" + ADD COLUMN IF NOT EXISTS "snapshot" JSONB; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index f5a06873..8fa78ee3 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -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]) diff --git a/furniture-3d-system/scripts/install-product-sabc-timer.sh b/furniture-3d-system/scripts/install-product-sabc-timer.sh index b4cef3eb..03a6a2b3 100755 --- a/furniture-3d-system/scripts/install-product-sabc-timer.sh +++ b/furniture-3d-system/scripts/install-product-sabc-timer.sh @@ -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" < 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 重算') } diff --git a/furniture-3d-system/scripts/run-product-sabc.ts b/furniture-3d-system/scripts/run-product-sabc.ts index 6f522031..9473934a 100644 --- a/furniture-3d-system/scripts/run-product-sabc.ts +++ b/furniture-3d-system/scripts/run-product-sabc.ts @@ -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, diff --git a/furniture-3d-system/src/app/api/supply-chain/product-sabc/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-sabc/route.ts index 814f4fb0..2807163c 100644 --- a/furniture-3d-system/src/app/api/supply-chain/product-sabc/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/product-sabc/route.ts @@ -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[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, diff --git a/furniture-3d-system/src/app/supply-chain/management/blank-pages/ProductSabcView.tsx b/furniture-3d-system/src/app/supply-chain/management/blank-pages/ProductSabcView.tsx index dbfede3f..04e739eb 100644 --- a/furniture-3d-system/src/app/supply-chain/management/blank-pages/ProductSabcView.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/blank-pages/ProductSabcView.tsx @@ -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(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 月 - {canManage ? : null} - {canManage ? : null} + {canCalculate ? : null}
@@ -315,6 +328,13 @@ export function ProductSabcView({ definition, canManage, canDownload }: { defini
{error ?
{error}
: null} {!error && !salesDataAvailable ?
{year}年{month}月销售事实尚未同步,销售指标和 SABC 标签暂为空;请先完成产品全量档案销售数据同步。
: null} + {!error && salesDataAvailable ? ( +
+ {frozen + ? `${year}年${month}月数据已于 ${formatShanghaiDateTime(generatedAt) || '本月生成日'} 生成并冻结,生成之后的销售状态、人员等变化不会改变本期数据;需要重算请由管理员点击「执行核算」。` + : `${year}年${month}月尚未生成正式数据,当前为实时口径;每月3日自动生成上月数据,生成后即冻结。`} +
+ ) : null}
diff --git a/furniture-3d-system/src/app/supply-chain/management/blank-pages/[pageKey]/page.tsx b/furniture-3d-system/src/app/supply-chain/management/blank-pages/[pageKey]/page.tsx index 09190854..84a296bb 100644 --- a/furniture-3d-system/src/app/supply-chain/management/blank-pages/[pageKey]/page.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/blank-pages/[pageKey]/page.tsx @@ -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 + // 商品 SABC 打标:月度核算结果生成后冻结,重新核算只允许管理员操作。 + return } return ( diff --git a/furniture-3d-system/src/server/services/supply-chain-product-sabc.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-sabc.service.ts index 5de479e0..e5857ff7 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-sabc.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-sabc.service.ts @@ -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 + +export function parseSabcSnapshotRow(value: unknown): SabcPageRow | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const raw = value as Record + 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() 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 { + const cacheKey = `periodRows:${input.year}:${input.month}` + const cached = input.fresh ? null : readSabcOverviewCache(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 { + 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 { + 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() + 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)) }) + }) } } diff --git a/furniture-3d-system/src/server/services/supply-chain-product-sabc.test.ts b/furniture-3d-system/src/server/services/supply-chain-product-sabc.test.ts index 5f1eaa95..755e9d4c 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-sabc.test.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-sabc.test.ts @@ -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) => ({