feat(supply-chain): SABC 每月3日生成上月并冻结,执行核算仅管理员
This commit is contained in:
parent
39f603c091
commit
6eebb56ba0
@ -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 和生产核算都需要单独授权。
|
||||
|
||||
## 操作日志保留策略
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- 商品 SABC 月度核算冻结:保存生成时的整行明细快照。
|
||||
-- 生成后(每月 3 日自动、或管理员手动执行核算)页面只读取该快照,
|
||||
-- 后续档案销售状态、人员字段等变化不会再改变已生成月份的数据。
|
||||
ALTER TABLE "SupplyChainProductArchiveSabcResult"
|
||||
ADD COLUMN IF NOT EXISTS "snapshot" JSONB;
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 重算')
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user