diff --git a/furniture-3d-system/.gitignore b/furniture-3d-system/.gitignore index 04c50df9..27adf8a6 100644 --- a/furniture-3d-system/.gitignore +++ b/furniture-3d-system/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +/node_modules.pnpm-mixed /.pnp .pnp.* .yarn/* @@ -15,6 +16,7 @@ # next.js /.next/ +/.next-*/ /.next-oa-preview*/ /.next-oa-webpack/ /out/ diff --git a/furniture-3d-system/docs/workflow/生产部署入口说明.md b/furniture-3d-system/docs/workflow/生产部署入口说明.md index d376a3ef..39badfd3 100644 --- a/furniture-3d-system/docs/workflow/生产部署入口说明.md +++ b/furniture-3d-system/docs/workflow/生产部署入口说明.md @@ -53,6 +53,17 @@ - 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。 - 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。 +## 商品 SABC 月度核算 + +- 试算命令:`npm run run:product-sabc -- --year=2026 --month=8 --mode=PREVIEW`。 +- 8 月人工表对账命令:`npm run compare:product-sabc-xlsx`,默认读取本机下载的人工工作簿;生产执行前应先完成产品全量档案日销售数据同步。 +- 生产定时器安装脚本:`furniture-3d-system/scripts/install-product-sabc-timer.sh`。 +- 生产 systemd 名称:`furniture-product-sabc-monthly.service` / `furniture-product-sabc-monthly.timer`。 +- 默认调度:每月 2 日中国标准时间 `03:00`,附带最多 10 分钟随机延迟;服务计算上一个自然月的数据。 +- SABC 的销售收款总金额、真实业绩、发货件数、商品毛利额和售后金额统一读取 `SupplyChainProductArchiveSalesPerformanceDaily`,与产品全量档案页面保持同一月度口径。 +- 首次安装生成权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/product-sabc-timer.env`,默认 `PRODUCT_SABC_TIMER_MODE=PREVIEW`。8 月对账确认通过、迁移已应用且上月产品档案日销售数据完整后,才允许改为 `APPLY`。 +- SABC 自动任务只读取现有 `DATABASE_URL`;上游日销售同步任务继续使用 `JST_REPORT_DB_*`,凭据不写入定时器配置。安装、迁移、首次 APPLY 和生产核算都需要单独授权。 + ## 操作日志保留策略 - 清理脚本:`npm run db:cleanup-operation-logs` 默认只做 dry-run;只有显式传入 `--apply --confirm=cleanup-operation-logs` 才会删除数据。 diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index af8ab508..2e082d23 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -24,6 +24,9 @@ "backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts", "backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply", "sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts", + "run:product-sabc": "tsx scripts/run-product-sabc.ts", + "compare:product-sabc-xlsx": "tsx scripts/compare-product-sabc-xlsx.ts", + "seed:local-product-sabc-xlsx": "tsx scripts/seed-local-product-sabc-xlsx.ts", "sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts", "sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts", "backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts", diff --git a/furniture-3d-system/prisma/migrations/20260921150000_add_product_archive_sabc/migration.sql b/furniture-3d-system/prisma/migrations/20260921150000_add_product_archive_sabc/migration.sql new file mode 100644 index 00000000..4c171d5f --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260921150000_add_product_archive_sabc/migration.sql @@ -0,0 +1,67 @@ +ALTER TABLE "SupplyChainProductArchive" +ADD COLUMN "sabcRating" VARCHAR(1) NOT NULL DEFAULT '', +ADD COLUMN "sabcUpdatedAt" TIMESTAMP(3); + +CREATE TABLE "SupplyChainProductArchiveSabcResult" ( + "id" TEXT NOT NULL, + "archiveId" TEXT NOT NULL, + "barcode" VARCHAR(100) NOT NULL, + "periodStart" DATE NOT NULL, + "periodEnd" DATE NOT NULL, + "eligible" BOOLEAN NOT NULL, + "sabcRating" VARCHAR(1) NOT NULL DEFAULT '', + "category" TEXT NOT NULL DEFAULT '', + "performanceScale" TEXT NOT NULL DEFAULT '', + "salesScale" TEXT NOT NULL DEFAULT '', + "finalScale" TEXT NOT NULL DEFAULT '', + "profitLabel" TEXT NOT NULL DEFAULT '', + "averageDailySales" DECIMAL(24,8), + "feeRatio" DECIMAL(20,8), + "grossMargin" DECIMAL(20,8), + "shipAmount" DECIMAL(24,4) NOT NULL, + "realPerformance" DECIMAL(24,4) NOT NULL, + "shipQty" BIGINT NOT NULL, + "grossProfit" DECIMAL(24,4) NOT NULL, + "afterSaleAmount" DECIMAL(24,4) NOT NULL, + "status" VARCHAR(24) NOT NULL DEFAULT 'CALCULATED', + "reason" TEXT NOT NULL DEFAULT '', + "calculatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SupplyChainProductArchiveSabcResult_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "SupplyChainProductArchiveSabcRun" ( + "id" TEXT NOT NULL, + "periodStart" DATE NOT NULL, + "periodEnd" DATE NOT NULL, + "mode" VARCHAR(16) NOT NULL, + "status" VARCHAR(24) NOT NULL, + "sourceRows" INTEGER NOT NULL DEFAULT 0, + "eligibleRows" INTEGER NOT NULL DEFAULT 0, + "taggedRows" INTEGER NOT NULL DEFAULT 0, + "comparedRows" INTEGER NOT NULL DEFAULT 0, + "mismatchRows" INTEGER NOT NULL DEFAULT 0, + "errorMessage" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "SupplyChainProductArchiveSabcRun_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "SupplyChainProductArchiveSabcResult_barcode_periodStart_periodEnd_key" +ON "SupplyChainProductArchiveSabcResult"("barcode", "periodStart", "periodEnd"); +CREATE INDEX "SupplyChainProductArchiveSabcResult_periodStart_periodEnd_idx" +ON "SupplyChainProductArchiveSabcResult"("periodStart", "periodEnd"); +CREATE INDEX "SupplyChainProductArchiveSabcResult_sabcRating_periodEnd_idx" +ON "SupplyChainProductArchiveSabcResult"("sabcRating", "periodEnd"); +CREATE INDEX "SupplyChainProductArchiveSabcResult_archiveId_periodEnd_idx" +ON "SupplyChainProductArchiveSabcResult"("archiveId", "periodEnd"); +CREATE UNIQUE INDEX "SupplyChainProductArchiveSabcRun_periodStart_periodEnd_mode_key" +ON "SupplyChainProductArchiveSabcRun"("periodStart", "periodEnd", "mode"); +CREATE INDEX "SupplyChainProductArchiveSabcRun_status_startedAt_idx" +ON "SupplyChainProductArchiveSabcRun"("status", "startedAt"); + +ALTER TABLE "SupplyChainProductArchiveSabcResult" +ADD CONSTRAINT "SupplyChainProductArchiveSabcResult_archiveId_fkey" +FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/furniture-3d-system/prisma/migrations/20260923100000_add_product_archive_attribute/migration.sql b/furniture-3d-system/prisma/migrations/20260923100000_add_product_archive_attribute/migration.sql new file mode 100644 index 00000000..dd4ba4f1 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260923100000_add_product_archive_attribute/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "SupplyChainProductArchive" +ADD COLUMN "productAttribute" TEXT NOT NULL DEFAULT ''; diff --git a/furniture-3d-system/prisma/migrations/20260928100000_add_product_archive_category_scale_rows/migration.sql b/furniture-3d-system/prisma/migrations/20260928100000_add_product_archive_category_scale_rows/migration.sql new file mode 100644 index 00000000..6e9ad96c --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260928100000_add_product_archive_category_scale_rows/migration.sql @@ -0,0 +1,33 @@ +ALTER TABLE "SupplyChainProductArchiveCategoryConfig" + ADD COLUMN IF NOT EXISTS "categoryGroup" TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS "scaleLabel" TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS "performanceStart" DECIMAL(14,2), + ADD COLUMN IF NOT EXISTS "performanceEnd" DECIMAL(14,2), + ADD COLUMN IF NOT EXISTS "dailySalesStart" DECIMAL(12,2), + ADD COLUMN IF NOT EXISTS "dailySalesEnd" DECIMAL(12,2); + +CREATE INDEX IF NOT EXISTS "SupplyChainProductArchiveCategoryConfig_categoryGroup_idx" + ON "SupplyChainProductArchiveCategoryConfig"("categoryGroup"); + +CREATE TABLE IF NOT EXISTS "SupplyChainProductArchiveCategoryScaleRow" ( + "id" TEXT NOT NULL, + "categoryL1" TEXT NOT NULL DEFAULT '', + "categoryL2" TEXT NOT NULL, + "categoryGroup" TEXT NOT NULL DEFAULT '', + "scaleLabel" TEXT NOT NULL, + "performanceStart" DECIMAL(14,2) NOT NULL, + "performanceEnd" DECIMAL(14,2), + "dailySalesStart" DECIMAL(12,2) NOT NULL, + "dailySalesEnd" DECIMAL(12,2), + "sortOrder" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SupplyChainProductArchiveCategoryScaleRow_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "SupplyChainProductArchiveCategoryScaleRow_categoryL2_scaleLabel_key" + ON "SupplyChainProductArchiveCategoryScaleRow"("categoryL2", "scaleLabel"); +CREATE INDEX IF NOT EXISTS "SupplyChainProductArchiveCategoryScaleRow_categoryL2_idx" + ON "SupplyChainProductArchiveCategoryScaleRow"("categoryL2"); +CREATE INDEX IF NOT EXISTS "SupplyChainProductArchiveCategoryScaleRow_categoryGroup_idx" + ON "SupplyChainProductArchiveCategoryScaleRow"("categoryGroup"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index dc5794a5..f5a06873 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -693,6 +693,7 @@ model SupplyChainProductArchive { height Decimal? @db.Decimal(18, 3) volume Decimal? @db.Decimal(24, 3) packType String @default("") + productAttribute String @default("") productTag String @default("") installDocId String @default("") hasDrawing String @default("") @@ -733,11 +734,14 @@ model SupplyChainProductArchive { purchasePrice String @default("") costPrice String @default("") costPriceSecondary String @default("") + sabcRating String @default("") @db.VarChar(1) + sabcUpdatedAt DateTime? fieldSourceMap Json @default("{}") sources SupplyChainProductArchiveSource[] imageEmbedding ProductArchiveImageEmbedding? imageCache SupplyChainProductArchiveImageCache? salesPerformance SupplyChainProductArchiveSalesPerformance? + sabcResults SupplyChainProductArchiveSabcResult[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -752,6 +756,58 @@ model SupplyChainProductArchive { @@index([firstSourceCode, createdAt]) } +// 月度 SABC 核算快照。主表只保留当前标签,历史月份和计算过程在这里追溯。 +model SupplyChainProductArchiveSabcResult { + id String @id @default(cuid()) + archiveId String + archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade) + barcode String @db.VarChar(100) + periodStart DateTime @db.Date + periodEnd DateTime @db.Date + eligible Boolean + sabcRating String @default("") @db.VarChar(1) + category String @default("") + performanceScale String @default("") + salesScale String @default("") + finalScale String @default("") + profitLabel String @default("") + averageDailySales Decimal? @db.Decimal(24, 8) + feeRatio Decimal? @db.Decimal(20, 8) + grossMargin Decimal? @db.Decimal(20, 8) + shipAmount Decimal @db.Decimal(24, 4) + realPerformance Decimal @db.Decimal(24, 4) + shipQty BigInt + grossProfit Decimal @db.Decimal(24, 4) + afterSaleAmount Decimal @db.Decimal(24, 4) + status String @default("CALCULATED") @db.VarChar(24) + reason String @default("") @db.Text + calculatedAt DateTime @default(now()) + + @@unique([barcode, periodStart, periodEnd]) + @@index([periodStart, periodEnd]) + @@index([sabcRating, periodEnd]) + @@index([archiveId, periodEnd]) +} + +model SupplyChainProductArchiveSabcRun { + id String @id @default(cuid()) + periodStart DateTime @db.Date + periodEnd DateTime @db.Date + mode String @db.VarChar(16) + status String @db.VarChar(24) + sourceRows Int @default(0) + eligibleRows Int @default(0) + taggedRows Int @default(0) + comparedRows Int @default(0) + mismatchRows Int @default(0) + errorMessage String? @db.Text + startedAt DateTime @default(now()) + completedAt DateTime? + + @@unique([periodStart, periodEnd, mode]) + @@index([status, startedAt]) +} + // 销售宽表按条码汇总的每日快照。 model SupplyChainProductArchiveSalesPerformance { archiveId String @id diff --git a/furniture-3d-system/scripts/compare-product-sabc-xlsx.ts b/furniture-3d-system/scripts/compare-product-sabc-xlsx.ts new file mode 100644 index 00000000..5dd04d03 --- /dev/null +++ b/furniture-3d-system/scripts/compare-product-sabc-xlsx.ts @@ -0,0 +1,101 @@ +import { loadEnvConfig } from '@next/env' +import * as XLSX from 'xlsx' + +loadEnvConfig(process.cwd()) + +const defaultWorkbook = '/Users/a1/Downloads/8月SABC、款效与新品动销监控 (1).xlsx' +const workbookPath = process.argv.find((argument) => argument.startsWith('--file='))?.slice('--file='.length) || defaultWorkbook + +function text(value: unknown) { + return String(value ?? '').trim() +} + +function number(value: unknown) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function indexOfHeader(headers: unknown[], label: string) { + const index = headers.findIndex((header) => text(header).replace(/\s+/g, '') === label.replace(/\s+/g, '')) + if (index < 0) throw new Error(`Excel sheet is missing column: ${label}`) + return index +} + +function closeEnough(left: unknown, right: unknown) { + return Math.abs(number(left) - number(right)) <= 0.01 +} + +async function main() { + const [{ prisma }, { supplyChainProductSabcService }] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/supply-chain-product-sabc.service'), + ]) + try { + const workbook = XLSX.readFile(workbookPath, { cellFormula: true }) + const detail = XLSX.utils.sheet_to_json(workbook.Sheets['商品明细-所有'], { header: 1, raw: true, defval: '' }) + const archive = XLSX.utils.sheet_to_json(workbook.Sheets['商品资料辅助'], { header: 1, raw: true, defval: '' }) + if (!detail.length || !archive.length) throw new Error('Excel 中缺少商品明细-所有或商品资料辅助工作表') + + const detailHeaders = detail[0] ?? [] + const archiveHeaders = archive[0] ?? [] + const detailCode = indexOfHeader(detailHeaders, '商品编码') + const detailStatus = indexOfHeader(detailHeaders, '生产状态') + const detailRating = indexOfHeader(detailHeaders, '26财年版本SABC') + const detailShipAmount = indexOfHeader(detailHeaders, '8.1-8.31全渠道销售收款总金额') + const detailRealPerformance = indexOfHeader(detailHeaders, '8.1-8.31全渠道真实业绩') + const detailShipQty = indexOfHeader(detailHeaders, '8.1-8.31全渠道发货件数') + const detailGrossProfit = indexOfHeader(detailHeaders, '8.1-8.31全渠道商品毛利额') + const detailAfterSale = indexOfHeader(detailHeaders, '8.1-8.31售后金额') + const archiveCode = indexOfHeader(archiveHeaders, '商品编码') + const archiveProperty = indexOfHeader(archiveHeaders, '商品属性') + const finishedCodes = new Set(archive.slice(1) + .filter((row) => text(row[archiveProperty]) === '成品') + .map((row) => text(row[archiveCode]).toUpperCase()) + .filter(Boolean)) + const manualRows = detail.slice(1) + .filter((row) => text(row[detailStatus]) === '销售' && finishedCodes.has(text(row[detailCode]).toUpperCase())) + + const result = await supplyChainProductSabcService.calculate({ year: 2026, month: 8, apply: false }) + const systemRows = new Map(result.rows.map((entry) => [entry.row.productCode.toUpperCase(), entry.row])) + const mismatches: Array> = [] + let compared = 0 + for (const manual of manualRows) { + const code = text(manual[detailCode]).toUpperCase() + const system = systemRows.get(code) + if (!system) { + mismatches.push({ code, type: 'SYSTEM_ARCHIVE_MISSING', manualRating: text(manual[detailRating]) }) + continue + } + compared += 1 + const fields = [ + ['sabcRating', text(manual[detailRating]), system.sabcRating], + ['shipAmount', manual[detailShipAmount], system.shipAmount], + ['realPerformance', manual[detailRealPerformance], system.realPerformance], + ['shipQty', manual[detailShipQty], system.shipQty], + ['grossProfit', manual[detailGrossProfit], system.grossProfit], + ['afterSaleAmount', manual[detailAfterSale], system.afterSaleAmount], + ] as const + for (const [field, manualValue, systemValue] of fields) { + const same = field === 'sabcRating' + ? text(manualValue) === text(systemValue) + : closeEnough(manualValue, systemValue) + if (!same) mismatches.push({ code, field, manual: manualValue, system: systemValue }) + } + } + console.log(JSON.stringify({ + file: workbookPath, + manualEligibleRows: manualRows.length, + systemArchiveRows: result.rows.length, + comparedRows: compared, + mismatchFields: mismatches.length, + mismatches: mismatches.slice(0, 200), + }, null, 2)) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/scripts/install-local-daily-sync.sh b/furniture-3d-system/scripts/install-local-daily-sync.sh new file mode 100755 index 00000000..be24f3ec --- /dev/null +++ b/furniture-3d-system/scripts/install-local-daily-sync.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# 安装/卸载本地每日同步任务(macOS launchd)。 +# 默认每天 08:30 运行 scripts/local-sync-archive.mjs: +# 聚水潭档案同步 → 销售日数据同步 → 正式库对齐(状态/创建时间)→ 上月 SABC 重算。 +# 用法: +# bash scripts/install-local-daily-sync.sh # 安装(每天 08:30) +# LOCAL_SYNC_HOUR=7 LOCAL_SYNC_MINUTE=0 bash scripts/install-local-daily-sync.sh +# bash scripts/install-local-daily-sync.sh --uninstall +set -euo pipefail + +BASE="$(cd "$(dirname "$0")/.." && pwd)" +LABEL="${LOCAL_SYNC_LABEL:-com.furniture3d.daily-sync}" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +LOG="$HOME/Library/Logs/furniture3d-daily-sync.log" +HOUR="${LOCAL_SYNC_HOUR:-8}" +MINUTE="${LOCAL_SYNC_MINUTE:-30}" + +NODE_BIN="${NODE_BIN:-}" +if [ -z "$NODE_BIN" ]; then + if command -v node >/dev/null 2>&1; then + NODE_BIN="$(command -v node)" + elif [ -x "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" ]; then + NODE_BIN="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" + else + echo "找不到 node,请安装 Node.js 或设置 NODE_BIN。" >&2 + exit 1 + fi +fi + +if [ "${1:-}" = "--uninstall" ]; then + launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || launchctl unload "$PLIST" 2>/dev/null || true + rm -f "$PLIST" + echo "已卸载 $LABEL" + exit 0 +fi + +mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" + +cat >"$PLIST" < + + + + Label + $LABEL + ProgramArguments + + $NODE_BIN + $BASE/scripts/local-sync-archive.mjs + + WorkingDirectory + $BASE + EnvironmentVariables + + PATH + $(dirname "$NODE_BIN"):/usr/local/bin:/usr/bin:/bin + + StartCalendarInterval + + Hour + $HOUR + Minute + $MINUTE + + StandardOutPath + $LOG + StandardErrorPath + $LOG + RunAtLoad + + + +PLIST_EOF + +launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null || launchctl load "$PLIST" + +if launchctl list | grep -q "$LABEL"; then + echo "已安装 ${LABEL}:每天 $(printf '%02d' "$HOUR"):$(printf '%02d' "$MINUTE") 执行" + echo "日志:${LOG}" + echo "手动触发一次:launchctl kickstart -k gui/$(id -u)/$LABEL" +else + echo "安装失败,请检查 $PLIST" >&2 + exit 1 +fi diff --git a/furniture-3d-system/scripts/install-product-sabc-timer.sh b/furniture-3d-system/scripts/install-product-sabc-timer.sh new file mode 100755 index 00000000..b4cef3eb --- /dev/null +++ b/furniture-3d-system/scripts/install-product-sabc-timer.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${PRODUCT_SABC_SERVICE_NAME:-furniture-product-sabc-monthly}" +APP_ROOT="$BASE/current/furniture-3d-system" +SHARED="$BASE/shared" +APP_ENV="$SHARED/.env" +SABC_ENV="$SHARED/product-sabc-timer.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" +TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer" +SCHEDULE="${PRODUCT_SABC_TIMER_SCHEDULE:-*-*-02 03:00:00 Asia/Shanghai}" +RANDOMIZED_DELAY_SEC="${PRODUCT_SABC_TIMER_RANDOMIZED_DELAY_SEC:-10min}" + +if [ "$(id -u)" -ne 0 ]; then + echo "This installer must run as root." >&2 + exit 1 +fi + +if [ ! -d "$APP_ROOT" ]; then + echo "Missing app root: $APP_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$APP_ENV" ]; then + echo "Missing app env: $APP_ENV" >&2 + exit 1 +fi + +mkdir -p "$SHARED" +if [ ! -f "$SABC_ENV" ]; then + umask 077 + cat >"$SABC_ENV" <<'EOF' +# SABC monthly run mode. Keep PREVIEW until the August reconciliation is accepted. +PRODUCT_SABC_TIMER_MODE=PREVIEW +EOF + echo "Created env template: $SABC_ENV" +fi + +chmod 600 "$SABC_ENV" +if ! grep -Eq '^PRODUCT_SABC_TIMER_MODE=(PREVIEW|APPLY)$' "$SABC_ENV"; then + echo "PRODUCT_SABC_TIMER_MODE must be PREVIEW or APPLY; timer was not installed." >&2 + exit 2 +fi + +if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze calendar "$SCHEDULE" >/dev/null +fi + +cat >"$SERVICE_FILE" <"$TIMER_FILE" < argument.startsWith('--month='))?.slice('--month='.length) ?? '' + +function previousMonth() { + const date = new Date() + date.setMonth(date.getMonth() - 1) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` +} + +const month = monthArgument || previousMonth() + +function required(name) { + const value = env[name]?.trim() + if (!value) { + console.error(`缺少环境变量 ${name},请在 .env 中补齐。`) + process.exit(1) + } + return value +} + +function databaseIdentity(url) { + const parsed = new URL(url) + return [ + 'postgresql', + decodeURIComponent(parsed.username || ''), + parsed.hostname, + parsed.port || '', + parsed.pathname.replace(/^\//, ''), + parsed.searchParams.get('schema') ?? '', + ].join('|') +} + +function sourceIdentity() { + return [ + 'sqlserver', + required('JST_REPORT_DB_USER'), + required('JST_REPORT_DB_HOST'), + env.JST_REPORT_DB_PORT?.trim() || '1433', + required('JST_REPORT_DB_NAME'), + env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', + ].join('|') +} + +function run(args, { capture = false } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [tsxCli, ...args], { + cwd: root, + env, + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }) + let output = '' + if (capture) { + child.stdout.on('data', (chunk) => { output += chunk }) + child.stderr.on('data', (chunk) => { output += chunk }) + } + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve(output) + else reject(new Error(`命令失败(exit ${code}):\n${output.slice(-4000)}`)) + }) + }) +} + +function printSyncSummary(output) { + const progress = output.split('\n').filter((line) => line.startsWith('{"progress"')) + if (progress.length) console.log(` 最后进度:${progress[progress.length - 1]}`) + const index = output.lastIndexOf('\n{\n') + if (index < 0) return + try { + const result = JSON.parse(output.slice(index + 1)) + delete result.preview + console.log(` 同步结果:${JSON.stringify(result)}`) + } catch { + /* ignore parse failure, raw log was already printed on error */ + } +} + +async function main() { + if (apply) { + for (const key of [ + 'JST_REPORT_DB_HOST', 'JST_REPORT_DB_NAME', 'JST_REPORT_DB_USER', 'JST_REPORT_DB_PASSWORD', + 'PROD_ARCHIVE_DB_HOST', 'PROD_ARCHIVE_DB_NAME', 'PROD_ARCHIVE_DB_USER', 'PROD_ARCHIVE_DB_PASSWORD', + 'DATABASE_URL', + ]) required(key) + } + + if (apply) { + console.log(`==> 1/7 同步聚水潭产品档案(${env.JST_REPORT_DB_HOST}/${env.JST_REPORT_DB_NAME} → 本地)`) + } else { + console.log('==> 1/7 预览聚水潭产品档案差异(dry-run,不写库)') + } + const syncArgs = ['scripts/sync-jst-sku-product-archive.ts', '--all', '--batch-size=500', '--concurrency=8'] + if (apply) { + syncArgs.push( + '--apply', + '--confirm=sync-jst-sku-product-archive', + `--expected-database-identity=${databaseIdentity(required('DATABASE_URL'))}`, + `--expected-source-database-identity=${sourceIdentity()}`, + ) + } else { + syncArgs.push('--dry-run') + } + printSyncSummary(await run(syncArgs, { capture: true })) + + if (apply) { + console.log('==> 2/7 同步聚水潭销售日数据(最近 7 天)') + await run([ + 'scripts/sync-supply-chain-product-archive-sales-performance-daily.ts', + '--apply', + '--confirm=sync-supply-chain-product-archive-sales-performance-daily', + `--expected-database-identity=${databaseIdentity(required('DATABASE_URL'))}`, + `--expected-source-database-identity=${sourceIdentity()}`, + '--lookback-days=7', + ]) + } else { + console.log('==> 2/7 已跳过销售日数据同步(dry-run)') + } + + if (apply) { + console.log('==> 3/7 同步简尚替换款管理换货规则') + await run(['scripts/sync-product-replacement-rules.ts', '--apply', '--confirm=sync-product-replacement-rules']) + } else { + console.log('==> 3/7 预览替换款规则差异(dry-run,不写库)') + await run(['scripts/sync-product-replacement-rules.ts']) + } + + if (apply) { + console.log('==> 4/7 以正式库产品全量档案对齐(状态/创建时间/人员字段)') + await run(['scripts/reconcile-archive-from-prod.ts', '--apply', '--confirm=reconcile-archive-from-prod']) + } else { + console.log('==> 4/7 预览正式库对齐差异(dry-run,不写库)') + await run(['scripts/reconcile-archive-from-prod.ts']) + } + + if (apply) { + console.log('==> 5/7 回填产品类型(定厂优先,聚水潭 vc_name 兜底)') + await run(['scripts/backfill-product-archive-production-type.ts', '--apply', '--batch-size=200']) + } else { + console.log('==> 5/7 预览产品类型差异(dry-run,不写库)') + await run(['scripts/backfill-product-archive-production-type.ts', '--batch-size=200']) + } + + if (apply) { + console.log('==> 6/7 同步正式库类目档位配置') + await run(['scripts/sync-product-category-config.ts', '--apply', '--confirm=sync-product-category-config']) + } else { + console.log('==> 6/7 预览类目档位差异(dry-run,不写库)') + await run(['scripts/sync-product-category-config.ts']) + } + + if (apply && !skipSabc) { + const year = month.split('-')[0] + const monthNumber = String(Number(month.split('-')[1])) + console.log(`==> 7/7 重算 ${year}-${monthNumber} SABC 并写库(APPLY)`) + await run(['scripts/run-product-sabc.ts', `--year=${year}`, `--month=${monthNumber}`, '--mode=APPLY', '--confirm=run-product-sabc']) + } else { + console.log('==> 7/7 已跳过 SABC 重算') + } + + console.log('==> 完成。SABC 页面:http://localhost:3000/supply-chain/management/blank-pages/blank-page-3') +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/furniture-3d-system/scripts/local-sync-archive.sh b/furniture-3d-system/scripts/local-sync-archive.sh new file mode 100755 index 00000000..80145e60 --- /dev/null +++ b/furniture-3d-system/scripts/local-sync-archive.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# 本地一键刷新(手动入口):聚水潭档案同步 → 销售日数据同步 → 正式库对齐 → 上月 SABC 重算。 +# 实际逻辑在 scripts/local-sync-archive.mjs。 +# 用法: +# bash scripts/local-sync-archive.sh # 同步档案、对齐正式库并重算上个月 SABC +# bash scripts/local-sync-archive.sh --month=2026-08 # 指定核算月份 +# bash scripts/local-sync-archive.sh --dry-run # 只预览差异,不写库、不重算 +# bash scripts/local-sync-archive.sh --skip-sabc # 只同步档案,不重算 SABC +set -euo pipefail + +BASE="$(cd "$(dirname "$0")/.." && pwd)" + +NODE_BIN="${NODE_BIN:-}" +if [ -z "$NODE_BIN" ]; then + if command -v node >/dev/null 2>&1; then + NODE_BIN="$(command -v node)" + elif [ -x "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" ]; then + NODE_BIN="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" + else + echo "找不到 node,请安装 Node.js 或设置 NODE_BIN。" >&2 + exit 1 + fi +fi +exec "$NODE_BIN" "$BASE/scripts/local-sync-archive.mjs" "$@" diff --git a/furniture-3d-system/scripts/reconcile-archive-from-prod.ts b/furniture-3d-system/scripts/reconcile-archive-from-prod.ts new file mode 100755 index 00000000..1e7302e3 --- /dev/null +++ b/furniture-3d-system/scripts/reconcile-archive-from-prod.ts @@ -0,0 +1,190 @@ +import { loadEnvConfig } from '@next/env' + +loadEnvConfig(process.cwd()) + +// 以正式环境产品全量档案为准,对齐本地档案的「生产状态」和「创建时间」。 +// 默认 dry-run;写库需要 --apply --confirm=reconcile-archive-from-prod。 +import pg from 'pg' + +const CONFIRM_TOKEN = 'reconcile-archive-from-prod' + +function required(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`${name} is required.`) + return value +} + +function normalizeTimestamp(value: string | null) { + return (value ?? '').replace('T', ' ').slice(0, 19) +} + +function flagValue(name: string) { + const prefix = `--${name}=` + return process.argv.slice(2).find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? '' +} + +async function main() { + const apply = process.argv.includes('--apply') + if (apply && flagValue('confirm') !== CONFIRM_TOKEN) { + throw new Error(`写库需要 --apply --confirm=${CONFIRM_TOKEN}`) + } + const batchSize = Math.min(Math.max(Number(flagValue('batch-size') || '5000'), 500), 20_000) + + const prod = new pg.Client({ + host: required('PROD_ARCHIVE_DB_HOST'), + port: Number(process.env.PROD_ARCHIVE_DB_PORT?.trim() || '25432'), + database: required('PROD_ARCHIVE_DB_NAME'), + user: required('PROD_ARCHIVE_DB_USER'), + password: required('PROD_ARCHIVE_DB_PASSWORD'), + ssl: { rejectUnauthorized: false }, + statement_timeout: 120_000, + }) + const local = new pg.Client({ + connectionString: required('DATABASE_URL'), + statement_timeout: 120_000, + }) + await prod.connect() + await local.connect() + + const stats = { + apply, + scanned: 0, + missingLocal: 0, + statusChanged: 0, + createdChanged: 0, + updated: 0, + personnelChanged: 0, + samples: [] as Array<{ barcode: string; field: string; local: string; prod: string }>, + } + + // 与产品全量档案保持一致的档案字段(人员/设计等,本地不再自行推导)。 + const mirroredFields = ['opsDirector', 'opsDeptHead', 'opsHead', 'supplierName', 'purchaseOwner', 'designer'] as const + + try { + let offset = 0 + while (true) { + const prodRows = (await prod.query( + `select id, barcode, "productStatus", "businessCreatedAt"::text as created, "updatedAt"::text as updated, + "opsDirector", "opsDeptHead", "opsHead", "supplierName", "purchaseOwner", "designer" + from public."SupplyChainProductArchive" + order by barcode + offset $1 limit $2`, + [offset, batchSize], + )).rows + if (prodRows.length === 0) break + offset += prodRows.length + + const barcodes = prodRows.map((row) => row.barcode) + const localRows = (await local.query( + `select barcode, "productStatus", "businessCreatedAt"::text as created, + "opsDirector", "opsDeptHead", "opsHead", "supplierName", "purchaseOwner", "designer" + from "SupplyChainProductArchive" + where barcode = any($1::text[])`, + [barcodes], + )).rows + const localMap = new Map(localRows.map((row) => [row.barcode, row])) + + for (const prodRow of prodRows) { + stats.scanned += 1 + const localRow = localMap.get(prodRow.barcode) + if (!localRow) { + stats.missingLocal += 1 + continue + } + + const statusDiff = (localRow.productStatus ?? '') !== (prodRow.productStatus ?? '') + const createdDiff = + normalizeTimestamp(localRow.created) !== normalizeTimestamp(prodRow.created) + && prodRow.created !== null + const fieldDiffs = mirroredFields.filter((field) => (localRow[field] ?? '') !== (prodRow[field] ?? '')) + if (!statusDiff && !createdDiff && fieldDiffs.length === 0) continue + if (statusDiff) stats.statusChanged += 1 + if (createdDiff) stats.createdChanged += 1 + stats.personnelChanged += fieldDiffs.length + if (stats.samples.length < 12) { + if (statusDiff || createdDiff) { + stats.samples.push({ + barcode: prodRow.barcode, + field: statusDiff ? 'productStatus' : 'businessCreatedAt', + local: statusDiff ? String(localRow.productStatus ?? '') : String(localRow.created ?? ''), + prod: statusDiff ? String(prodRow.productStatus ?? '') : String(prodRow.created ?? ''), + }) + } else { + const field = fieldDiffs[0] + stats.samples.push({ + barcode: prodRow.barcode, + field, + local: String(localRow[field] ?? ''), + prod: String(prodRow[field] ?? ''), + }) + } + } + if (!apply) continue + + const sourceUpdatedAt = new Date(String(prodRow.updated)).toISOString() + const sourceFor = (column: string) => JSON.stringify({ + sourceCode: 'PRODUCT_DEVELOPMENT', + sourceSystem: '产品全量档案(正式库)', + sourceSchema: 'public', + sourceObject: 'SupplyChainProductArchive', + sourceColumn: column, + sourceRecordId: prodRow.id, + sourceUpdatedAt, + }) + + const assignments: string[] = [] + const params: unknown[] = [prodRow.barcode] + const sourceSets: Array<{ path: string; paramIndex: number }> = [] + + const pushValue = (column: string, value: unknown) => { + params.push(value) + assignments.push(`"${column}" = $${params.length}`) + params.push(sourceFor(column)) + sourceSets.push({ path: `'{${column}}'`, paramIndex: params.length }) + } + + if (statusDiff) { + pushValue('productStatus', prodRow.productStatus ?? '') + if ((prodRow.productStatus ?? '') !== '销售') { + assignments.push(`"sabcRating" = ''`) + assignments.push(`"sabcUpdatedAt" = now()`) + } + } + if (createdDiff) { + params.push(prodRow.created) + assignments.push(`"businessCreatedAt" = $${params.length}::timestamp`) + params.push(sourceFor('businessCreatedAt')) + sourceSets.push({ path: `'{businessCreatedAt}'`, paramIndex: params.length }) + } + for (const field of fieldDiffs) { + pushValue(field, prodRow[field] ?? '') + } + if (sourceSets.length > 0) { + let expression = '"fieldSourceMap"' + for (const { path, paramIndex } of sourceSets) { + expression = `jsonb_set(${expression}, ${path}, $${paramIndex}::jsonb, true)` + } + assignments.push(`"fieldSourceMap" = ${expression}`) + } + assignments.push(`"updatedAt" = now()`) + + await local.query( + `update "SupplyChainProductArchive" set ${assignments.join(', ')} where barcode = $1`, + params, + ) + stats.updated += 1 + } + } + } finally { + await prod.end() + await local.end() + } + + console.log(JSON.stringify({ ...stats, samples: undefined, sampleChanges: stats.samples }, null, 2)) + if (!apply) console.log('dry-run:未写库。加 --apply --confirm=' + CONFIRM_TOKEN + ' 才会对齐。') +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/scripts/run-product-sabc.ts b/furniture-3d-system/scripts/run-product-sabc.ts new file mode 100644 index 00000000..6f522031 --- /dev/null +++ b/furniture-3d-system/scripts/run-product-sabc.ts @@ -0,0 +1,51 @@ +import { loadEnvConfig } from '@next/env' + +loadEnvConfig(process.cwd()) + +function value(name: string) { + const prefix = `--${name}=` + return process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? '' +} + +function integer(name: string, min: number, max: number) { + const parsed = Number(value(name)) + if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new Error(`--${name} must be an integer between ${min} and ${max}.`) + return parsed +} + +async function main() { + const year = integer('year', 2020, 2100) + const month = integer('month', 1, 12) + const mode = value('mode') || 'PREVIEW' + if (mode !== 'PREVIEW' && mode !== 'APPLY') throw new Error('--mode must be PREVIEW or APPLY.') + if (mode === 'APPLY' && value('confirm') !== 'run-product-sabc') throw new Error('写入模式必须传 --confirm=run-product-sabc。') + + const [{ prisma }, { supplyChainProductSabcService }] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/supply-chain-product-sabc.service'), + ]) + try { + const result = await supplyChainProductSabcService.calculate({ year, month, apply: mode === 'APPLY' }) + const summary = { + mode, + periodStart: result.periodStart, + periodEnd: result.periodEnd, + sourceRows: result.sourceRows, + archiveRows: result.rows.length, + eligibleRows: result.rows.filter((row) => row.row.eligible).length, + taggedRows: result.rows.filter((row) => row.row.sabcRating).length, + ratings: result.rows.reduce>((counts, row) => { + if (row.row.sabcRating) counts[row.row.sabcRating] = (counts[row.row.sabcRating] ?? 0) + 1 + return counts + }, {}), + } + console.log(JSON.stringify(summary, null, 2)) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/scripts/seed-local-product-sabc-xlsx.ts b/furniture-3d-system/scripts/seed-local-product-sabc-xlsx.ts new file mode 100644 index 00000000..3f546717 --- /dev/null +++ b/furniture-3d-system/scripts/seed-local-product-sabc-xlsx.ts @@ -0,0 +1,244 @@ +import { loadEnvConfig } from '@next/env' +import { Prisma } from '@prisma/client' +import { randomUUID } from 'node:crypto' +import * as XLSX from 'xlsx' + +loadEnvConfig(process.cwd()) + +const confirmToken = 'seed-local-product-sabc-xlsx' +const defaultWorkbook = '/Users/a1/Downloads/8月SABC、款效与新品动销监控 (1).xlsx' + +function argument(name: string) { + const prefix = `--${name}=` + return process.argv.find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? '' +} + +function text(value: unknown) { + return String(value ?? '').trim() +} + +function decimal(value: unknown) { + const parsed = Number(value) + return new Prisma.Decimal(Number.isFinite(parsed) ? parsed : 0) +} + +function integer(value: unknown) { + const parsed = Number(value) + return BigInt(Number.isFinite(parsed) ? Math.trunc(parsed) : 0) +} + +function date(value: unknown) { + if (value instanceof Date && !Number.isNaN(value.getTime())) return value + if (typeof value === 'number') { + const parts = XLSX.SSF.parse_date_code(value) + if (parts) return new Date(Date.UTC(parts.y, parts.m - 1, parts.d, parts.H, parts.M, Math.floor(parts.S))) + } + const parsed = new Date(text(value)) + return Number.isNaN(parsed.getTime()) ? new Date('2000-01-01T00:00:00.000Z') : parsed +} + +function headers(row: unknown[]) { + return new Map(row.map((value, index) => [text(value).replace(/\s+/g, ''), index])) +} + +function column(map: Map, label: string) { + const index = map.get(label.replace(/\s+/g, '')) + if (index === undefined) throw new Error(`Excel sheet is missing column: ${label}`) + return index +} + +function assertLocalDatabase() { + const databaseUrl = process.env.DATABASE_URL?.trim() + if (!databaseUrl) throw new Error('DATABASE_URL is required') + const url = new URL(databaseUrl) + if (!['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) { + throw new Error(`This seed only allows a local database, received host ${url.hostname}`) + } +} + +async function main() { + if (argument('confirm') !== confirmToken) { + throw new Error(`Pass --confirm=${confirmToken} to seed the local test database.`) + } + assertLocalDatabase() + + const workbookPath = argument('file') || defaultWorkbook + const workbook = XLSX.readFile(workbookPath, { cellFormula: true }) + const archiveRows = XLSX.utils.sheet_to_json(workbook.Sheets['商品资料辅助'], { header: 1, raw: true, defval: '' }) + const detailRows = XLSX.utils.sheet_to_json(workbook.Sheets['商品明细-所有'], { header: 1, raw: true, defval: '' }) + if (!archiveRows.length || !detailRows.length) throw new Error('Excel 中缺少商品资料辅助或商品明细-所有工作表') + + const archiveHeaders = headers(archiveRows[0] ?? []) + const detailHeaders = headers(detailRows[0] ?? []) + const archiveColumns = { + spu: column(archiveHeaders, '款式编码'), + sku: column(archiveHeaders, '货号'), + barcode: column(archiveHeaders, '商品编码'), + status: column(archiveHeaders, '生产状态'), + category: column(archiveHeaders, '分类'), + brand: column(archiveHeaders, '品牌'), + supplier: column(archiveHeaders, '供应商名称'), + packType: column(archiveHeaders, '单双包'), + designer: column(archiveHeaders, '设计师'), + name: column(archiveHeaders, '商品名称'), + color: column(archiveHeaders, '颜色'), + spec: column(archiveHeaders, '规格'), + weight: column(archiveHeaders, '重量'), + length: column(archiveHeaders, '长'), + width: column(archiveHeaders, '宽'), + height: column(archiveHeaders, '高'), + volume: column(archiveHeaders, '体积'), + createdAt: column(archiveHeaders, '创建时间'), + itemType: column(archiveHeaders, '商品属性'), + productTag: column(archiveHeaders, '商品标签'), + material: column(archiveHeaders, '材质'), + orderType: column(archiveHeaders, '采购类型'), + image: column(archiveHeaders, '图片'), + costPrice: column(archiveHeaders, '成本价'), + } + const detailColumns = { + barcode: column(detailHeaders, '商品编码'), + status: column(detailHeaders, '生产状态'), + category: column(detailHeaders, '类目分类'), + rating: column(detailHeaders, '26财年版本SABC'), + shipAmount: column(detailHeaders, '8.1-8.31全渠道销售收款总金额'), + realPerformance: column(detailHeaders, '8.1-8.31全渠道真实业绩'), + shipQty: column(detailHeaders, '8.1-8.31全渠道发货件数'), + grossProfit: column(detailHeaders, '8.1-8.31全渠道商品毛利额'), + afterSaleAmount: column(detailHeaders, '8.1-8.31售后金额'), + } + + const archives = new Map() + for (const row of archiveRows.slice(1)) { + const barcode = text(row[archiveColumns.barcode]).toUpperCase() + if (!barcode || text(row[archiveColumns.itemType]) !== '成品') continue + const category = text(row[archiveColumns.category]) + archives.set(barcode, { + id: randomUUID(), + barcode, + spu: text(row[archiveColumns.spu]).toUpperCase(), + sku: text(row[archiveColumns.sku]).toUpperCase(), + image: text(row[archiveColumns.image]) || null, + businessCreatedAt: date(row[archiveColumns.createdAt]), + categoryL1: category.split(' - ')[0] ?? '', + categoryL2: category, + productName: text(row[archiveColumns.name]), + designer: text(row[archiveColumns.designer]), + productStatus: text(row[archiveColumns.status]), + material: text(row[archiveColumns.material]), + color: text(row[archiveColumns.color]), + weight: decimal(row[archiveColumns.weight]), + length: decimal(row[archiveColumns.length]), + width: decimal(row[archiveColumns.width]), + height: decimal(row[archiveColumns.height]), + volume: decimal(row[archiveColumns.volume]), + packType: text(row[archiveColumns.packType]), + productAttribute: text(row[archiveColumns.itemType]), + productTag: text(row[archiveColumns.productTag]), + spec: text(row[archiveColumns.spec]), + brand: text(row[archiveColumns.brand]), + orderType: text(row[archiveColumns.orderType]), + supplierName: text(row[archiveColumns.supplier]), + costPrice: text(row[archiveColumns.costPrice]), + firstSourceCode: 'JST_SKU', + }) + } + + const [{ prisma }, { classifySabc, SABC_SCALE_RULES }] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/supply-chain-product-sabc'), + ]) + try { + const currentRows = await prisma.supplyChainProductArchive.count() + if (currentRows !== 0) throw new Error(`Local archive is not empty (${currentRows} rows); seed aborted.`) + + const start = new Date('2026-08-01T00:00:00.000Z') + const end = new Date('2026-08-31T00:00:00.000Z') + const validCategories = new Set(SABC_SCALE_RULES.map((rule) => rule.category)) + const manualRatings = new Map() + const detailByBarcode = new Map() + for (const row of detailRows.slice(1)) { + const barcode = text(row[detailColumns.barcode]).toUpperCase() + if (!barcode || !archives.has(barcode) || text(row[detailColumns.status]) !== '销售') continue + detailByBarcode.set(barcode, row) + manualRatings.set(barcode, text(row[detailColumns.rating])) + } + + const batchSize = 750 + await prisma.$transaction(async (tx) => { + const archiveValues = [...archives.values()] + for (let index = 0; index < archiveValues.length; index += batchSize) { + await tx.supplyChainProductArchive.createMany({ data: archiveValues.slice(index, index + batchSize) }) + } + const persisted = await tx.supplyChainProductArchive.findMany({ select: { id: true, barcode: true } }) + const archiveIds = new Map(persisted.map((row) => [row.barcode, row.id])) + const sources = persisted.map((row) => ({ + id: randomUUID(), + archiveId: row.id, + sourceCode: 'JST_SKU', + entryMode: 'IMPORT', + sourceRecordId: `xlsx:${row.barcode}`, + sourceProductId: text(archives.get(row.barcode)?.spu), + sourceSkuItemId: row.barcode, + sourceSystem: 'August SABC workbook fixture', + sourceSchema: 'xlsx', + sourceObject: '商品资料辅助', + })) + for (let index = 0; index < sources.length; index += batchSize) { + await tx.supplyChainProductArchiveSource.createMany({ data: sources.slice(index, index + batchSize) }) + } + + const results: Prisma.SupplyChainProductArchiveSabcResultCreateManyInput[] = [] + const ratingIds = new Map() + let mismatches = 0 + for (const [barcode, row] of detailByBarcode) { + const archiveId = archiveIds.get(barcode) + const category = text(row[detailColumns.category]) + if (!archiveId || !validCategories.has(category)) continue + const realPerformance = decimal(row[detailColumns.realPerformance]) + const shipQty = integer(row[detailColumns.shipQty]) + const grossProfit = decimal(row[detailColumns.grossProfit]) + const afterSaleAmount = decimal(row[detailColumns.afterSaleAmount]) + const classification = classifySabc({ category, realPerformance, shipQty, grossProfit, afterSaleAmount, daysInPeriod: 31 }, SABC_SCALE_RULES) + if (classification.rating !== manualRatings.get(barcode)) mismatches += 1 + results.push({ + id: randomUUID(), archiveId, barcode, periodStart: start, periodEnd: end, eligible: true, + sabcRating: classification.rating, category, performanceScale: classification.performanceScale, + salesScale: classification.salesScale, finalScale: classification.finalScale, + profitLabel: classification.profitLabel, averageDailySales: classification.averageDailySales, + feeRatio: classification.feeRatio, grossMargin: classification.grossMargin, + shipAmount: decimal(row[detailColumns.shipAmount]), realPerformance, shipQty, grossProfit, + afterSaleAmount, status: 'CALCULATED', reason: '', + }) + const ids = ratingIds.get(classification.rating) ?? [] + ids.push(archiveId) + ratingIds.set(classification.rating, ids) + } + for (let index = 0; index < results.length; index += batchSize) { + await tx.supplyChainProductArchiveSabcResult.createMany({ data: results.slice(index, index + batchSize) }) + } + for (const [rating, ids] of ratingIds) { + await tx.supplyChainProductArchive.updateMany({ where: { id: { in: ids } }, data: { sabcRating: rating, sabcUpdatedAt: new Date() } }) + } + await tx.supplyChainProductArchiveSabcRun.create({ data: { + id: randomUUID(), periodStart: start, periodEnd: end, mode: 'LOCAL_XLSX', status: 'COMPLETED', + sourceRows: detailByBarcode.size, eligibleRows: detailByBarcode.size, taggedRows: results.length, + comparedRows: results.length, mismatchRows: mismatches, completedAt: new Date(), + } }) + }, { timeout: 120_000 }) + + const [archiveCount, resultCount, mismatchRun] = await Promise.all([ + prisma.supplyChainProductArchive.count(), + prisma.supplyChainProductArchiveSabcResult.count({ where: { periodStart: start, periodEnd: end } }), + prisma.supplyChainProductArchiveSabcRun.findUnique({ where: { periodStart_periodEnd_mode: { periodStart: start, periodEnd: end, mode: 'LOCAL_XLSX' } } }), + ]) + console.log(JSON.stringify({ workbookPath, archiveRows: archiveCount, augustResults: resultCount, ratingMismatches: mismatchRun?.mismatchRows ?? 0 }, null, 2)) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/scripts/sync-product-category-config.ts b/furniture-3d-system/scripts/sync-product-category-config.ts new file mode 100644 index 00000000..a6923fbb --- /dev/null +++ b/furniture-3d-system/scripts/sync-product-category-config.ts @@ -0,0 +1,146 @@ +import { loadEnvConfig } from '@next/env' + +loadEnvConfig(process.cwd()) + +// 以正式库的类目档位表为准,同步本地类目配置(类目 + 档位阈值)。 +// 默认 dry-run;写库需要 --apply --confirm=sync-product-category-config。 +import pg from 'pg' + +const CONFIRM_TOKEN = 'sync-product-category-config' + +function required(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`${name} is required.`) + return value +} + +function flagValue(name: string) { + const prefix = `--${name}=` + return process.argv.slice(2).find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? '' +} + +async function main() { + const apply = process.argv.includes('--apply') + if (apply && flagValue('confirm') !== CONFIRM_TOKEN) { + throw new Error(`写库需要 --apply --confirm=${CONFIRM_TOKEN}`) + } + + const prod = new pg.Client({ + host: required('PROD_ARCHIVE_DB_HOST'), + port: Number(process.env.PROD_ARCHIVE_DB_PORT?.trim() || '25432'), + database: required('PROD_ARCHIVE_DB_NAME'), + user: required('PROD_ARCHIVE_DB_USER'), + password: required('PROD_ARCHIVE_DB_PASSWORD'), + ssl: { rejectUnauthorized: false }, + statement_timeout: 60_000, + }) + const local = new pg.Client({ + connectionString: required('DATABASE_URL'), + statement_timeout: 60_000, + }) + await prod.connect() + await local.connect() + + try { + const sourceRows = (await prod.query( + `select "categoryL1","categoryL2","categoryGroup","scaleLabel", + "performanceStart","performanceEnd","dailySalesStart","dailySalesEnd","sortOrder" + from public."SupplyChainProductArchiveCategoryScaleRow" + order by "categoryL2","sortOrder"`, + )).rows + + const localRows = (await local.query( + `select "categoryL2","scaleLabel" from "SupplyChainProductArchiveCategoryScaleRow"`, + )).rows + const localKeys = new Set(localRows.map((row) => `${row.categoryL2}||${row.scaleLabel}`)) + const localConfig = (await local.query( + `select "categoryL2" from "SupplyChainProductArchiveCategoryConfig"`, + )).rows + const configKeys = new Set(localConfig.map((row) => row.categoryL2)) + + const categories = new Map() + let newScaleRows = 0 + let existingScaleRows = 0 + for (const row of sourceRows) { + const key = `${row.categoryL2}||${row.scaleLabel}` + if (localKeys.has(key)) existingScaleRows += 1 + else newScaleRows += 1 + if (!categories.has(row.categoryL2)) { + categories.set(row.categoryL2, { categoryL1: row.categoryL1, categoryGroup: row.categoryGroup }) + } + } + const newCategories = [...categories.keys()].filter((categoryL2) => !configKeys.has(categoryL2)) + + const summary = { + apply, + sourceScaleRows: sourceRows.length, + sourceCategories: categories.size, + localScaleRows: localRows.length, + newScaleRows, + existingScaleRows, + newCategories: newCategories.length, + newCategoryNames: newCategories.slice(0, 20), + } + + if (apply) { + await local.query('BEGIN') + try { + for (const row of sourceRows) { + await local.query( + `insert into "SupplyChainProductArchiveCategoryScaleRow" + (id,"categoryL1","categoryL2","categoryGroup","scaleLabel","performanceStart","performanceEnd", + "dailySalesStart","dailySalesEnd","sortOrder","createdAt","updatedAt") + values (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,now(),now()) + on conflict ("categoryL2","scaleLabel") do update set + "categoryL1"=excluded."categoryL1", + "categoryGroup"=excluded."categoryGroup", + "performanceStart"=excluded."performanceStart", + "performanceEnd"=excluded."performanceEnd", + "dailySalesStart"=excluded."dailySalesStart", + "dailySalesEnd"=excluded."dailySalesEnd", + "sortOrder"=excluded."sortOrder", + "updatedAt"=now()`, + [ + row.categoryL1, + row.categoryL2, + row.categoryGroup, + row.scaleLabel, + row.performanceStart, + row.performanceEnd, + row.dailySalesStart, + row.dailySalesEnd, + row.sortOrder, + ], + ) + } + for (const [categoryL2, meta] of categories) { + await local.query( + `insert into "SupplyChainProductArchiveCategoryConfig" + (id,"categoryL2","categoryL1",status,"categoryGroup","scaleLabel","createdAt","updatedAt") + values (gen_random_uuid()::text,$1,$2,'ACTIVE',$3,'',now(),now()) + on conflict ("categoryL2") do update set + "categoryL1"=excluded."categoryL1", + "categoryGroup"=excluded."categoryGroup", + "updatedAt"=now()`, + [categoryL2, meta.categoryL1, meta.categoryGroup], + ) + } + await local.query('COMMIT') + } catch (error) { + await local.query('ROLLBACK') + throw error + } + } + + console.log(JSON.stringify(summary, null, 2)) + if (!apply) console.log(`dry-run:未写库。加 --apply --confirm=${CONFIRM_TOKEN} 才会同步。`) + } finally { + await prod.end() + await local.end() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/scripts/sync-product-replacement-rules.ts b/furniture-3d-system/scripts/sync-product-replacement-rules.ts new file mode 100644 index 00000000..04874cbd --- /dev/null +++ b/furniture-3d-system/scripts/sync-product-replacement-rules.ts @@ -0,0 +1,95 @@ +import { loadEnvConfig } from '@next/env' + +loadEnvConfig(process.cwd()) + +// 同步简尚「替换款管理」换货规则(rpt.pm_replacementManage)到本地映射表。 +// 口径:sku = 原编码(老),replaceSku = 替换编码(新);delete=0 且 replaceSku 非空才有效。 +// 默认 dry-run;写库需要 --apply --confirm=sync-product-replacement-rules。 +import sql from 'mssql' +import pg from 'pg' + +const CONFIRM_TOKEN = 'sync-product-replacement-rules' + +function required(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`${name} is required.`) + return value +} + +async function main() { + const apply = process.argv.includes('--apply') + if (apply && process.argv.find((a) => a.startsWith('--confirm='))?.slice('--confirm='.length) !== CONFIRM_TOKEN) { + throw new Error(`写库需要 --apply --confirm=${CONFIRM_TOKEN}`) + } + + const pool = await new sql.ConnectionPool({ + server: required('JST_REPORT_DB_HOST'), + port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'), + database: required('JST_REPORT_DB_NAME'), + user: required('JST_REPORT_DB_USER'), + password: required('JST_REPORT_DB_PASSWORD'), + options: { + encrypt: process.env.JST_REPORT_DB_ENCRYPT?.trim() !== 'false', + trustServerCertificate: process.env.JST_REPORT_DB_TRUST_SERVER_CERTIFICATE?.trim() !== 'false', + }, + requestTimeout: 60_000, + }).connect() + + const rows = (await pool.request().query(` + SELECT id, sku, replaceSku, replaceType, isSwapping, differentPoints, creator, createTime + FROM rpt.pm_replacementManage + WHERE [delete] = 0 + `)).recordset + await pool.close() + + const local = new pg.Client({ connectionString: required('DATABASE_URL'), statement_timeout: 120_000 }) + await local.connect() + try { + const existing = (await local.query('select count(*)::int as n from "SupplyChainProductArchiveReplacementRule"')).rows[0].n + const summary = { + apply, + sourceRows: rows.length, + localRowsBefore: existing, + emptyReplaceSku: rows.filter((row) => !String(row.replaceSku ?? '').trim()).length, + } + if (apply) { + await local.query('BEGIN') + try { + await local.query('TRUNCATE "SupplyChainProductArchiveReplacementRule"') + for (const row of rows) { + await local.query( + `insert into "SupplyChainProductArchiveReplacementRule" + (id,"sourceId",sku,"replaceSku","replaceType","isSwapping","differentPoints",creator,"sourceCreatedAt","syncedAt") + values (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,now())`, + [ + row.id, + String(row.sku ?? '').trim(), + String(row.replaceSku ?? '').trim(), + Number(row.replaceType ?? 0), + Number(row.isSwapping ?? 0), + String(row.differentPoints ?? '').trim(), + String(row.creator ?? '').trim(), + row.createTime ? new Date(row.createTime).toISOString() : null, + ], + ) + } + await local.query('COMMIT') + } catch (error) { + await local.query('ROLLBACK') + throw error + } + const after = (await local.query('select count(*)::int as n from "SupplyChainProductArchiveReplacementRule"')).rows[0].n + console.log(JSON.stringify({ ...summary, localRowsAfter: after }, null, 2)) + } else { + console.log(JSON.stringify(summary, null, 2)) + console.log(`dry-run:未写库。加 --apply --confirm=${CONFIRM_TOKEN} 才会同步。`) + } + } finally { + await local.end() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/product-sabc/export/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-sabc/export/route.ts new file mode 100644 index 00000000..62115b3b --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/product-sabc/export/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server' +import * as XLSX from 'xlsx' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError } from '@/server/core/response' +import { formatProductArchiveDateTime } from '@/lib/product-archive-date-time' +import { SABC_PERCENT_FIELDS, SABC_TWO_DECIMAL_FIELDS, sabcSpreadsheetValue } from '@/lib/supply-chain-product-sabc-format' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { SABC_FILTER_FIELDS, supplyChainProductSabcService, type SabcPageRow } from '@/server/services/supply-chain-product-sabc.service' + +const querySchema = z.object({ + year: z.coerce.number().int().min(2020).max(2100), + month: z.coerce.number().int().min(1).max(12), + field: z.enum(SABC_FILTER_FIELDS).default('productCode'), + keyword: z.string().trim().max(4000).optional(), + rating: z.enum(['S', 'A', 'B', 'C', 'D']).optional(), + itemNumber: z.string().trim().max(4000).optional(), + productCode: z.string().trim().max(4000).optional(), + styleCode: z.string().trim().max(4000).optional(), + categoryClass: z.string().trim().max(4000).optional(), + opsHead: z.string().trim().max(4000).optional(), + opsDeptHead: z.string().trim().max(4000).optional(), + purchaser: z.string().trim().max(4000).optional(), + designer: z.string().trim().max(4000).optional(), +}) + +const baseColumns: Array<[keyof SabcPageRow, string]> = [ + ['styleCode', '款式编码'], ['itemNumber', '货号'], ['productCode', '商品编码'], ['productionStatus', '生产状态'], + ['category', '分类'], ['brand', '品牌'], + ['opsDirector', '运营总负责人'], ['opsDeptHead', '运营部门负责人'], ['opsHead', '运营负责人'], + ['supplier', '供应商'], ['purchaser', '采购'], ['designer', '设计师'], + ['createTime', '创建时间'], ['createDays', '创建天数'], + ['categoryClass', '类目分类'], ['newOldLabel', '新老品标签'], ['newProductRange', '新品区间分布'], ['newProductPeriod', '新品期'], + ['newProductStatus', '是否在新品期内'], ['movingStatus', '是否动销'], ['averageDailySales', '当月平均日销'], ['feeRatio', '当月部分费用占比'], + ['performanceScale', '业绩规模'], ['salesScale', '销量规模'], ['finalScale', '最终核算规模'], ['profitLabel', '利润标签-新'], + ['sabcRating', 'SABC标签'], ['shipAmount', '当月全渠道销售收款总金额'], ['realPerformance', '当月全渠道真实业绩'], + ['shipQty', '当月全渠道发货件数'], ['grossProfit', '当月全渠道商品毛利额'], ['afterSaleAmount', '当月售后金额'], + ['grossMargin', '结算毛利率'], ['afterSaleRatio', '售后占比'], ['assessmentStatus', '是否考核动销'], + ['noAssessmentReason', '不考核原因'], ['sameProductCode', '同款商品编码(业绩&费比合并计算)'], +] + +function columnsForMonth(year: number, month: number) { + const end = new Date(Date.UTC(year, month, 0)).getUTCDate() + const period = `${month}.1-${month}.${end}` + return baseColumns.map(([key, label]) => { + if (key === 'averageDailySales') return [key, `${month}月平均日销`] as [keyof SabcPageRow, string] + if (key === 'feeRatio') return [key, `${month}月部分费用占比`] as [keyof SabcPageRow, string] + const labels: Partial> = { + shipAmount: '全渠道销售收款总金额', + realPerformance: '全渠道真实业绩', + shipQty: '全渠道发货件数', + grossProfit: '全渠道商品毛利额', + afterSaleAmount: '售后金额', + } + if (labels[key]) return [key, `${period}${labels[key]}`] as [keyof SabcPageRow, string] + return [key, label] as [keyof SabcPageRow, string] + }) +} + +function buildContentDisposition(fileName: string) { + return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}` +} + +function timestamp() { + return new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14) +} + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'businessManagement') + if (!access.download) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId) + } + const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'SABC 导出参数不正确', 400, ctx.requestId) + } + + const { itemNumber, productCode, styleCode, categoryClass, opsHead, opsDeptHead, purchaser, designer, ...rest } = parsed.data + const filters = { itemNumber, productCode, styleCode, categoryClass, opsHead, opsDeptHead, purchaser, designer } + const rows: SabcPageRow[] = await supplyChainProductSabcService.listExportRows({ ...rest, filters }) + + const columns = columnsForMonth(parsed.data.year, parsed.data.month) + const sheetRows = rows.map((row) => Object.fromEntries( + columns.map(([key, label]) => [label, key === 'createTime' && row[key] + ? formatProductArchiveDateTime(String(row[key])) + : sabcSpreadsheetValue(key, row[key])]), + )) + const workbook = XLSX.utils.book_new() + const worksheet = XLSX.utils.json_to_sheet(sheetRows, { header: columns.map(([, label]) => label) }) + columns.forEach(([key], columnIndex) => { + if (!SABC_PERCENT_FIELDS.has(key) && !SABC_TWO_DECIMAL_FIELDS.has(key)) return + const numberFormat = SABC_PERCENT_FIELDS.has(key) ? '0.00%' : '0.00' + for (let rowIndex = 1; rowIndex <= rows.length; rowIndex += 1) { + const cell = worksheet[XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex })] + if (cell?.t === 'n') cell.z = numberFormat + } + }) + XLSX.utils.book_append_sheet(workbook, worksheet, '商品SABC打标') + const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer + + return new NextResponse(new Uint8Array(content), { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': buildContentDisposition(`商品SABC打标-${parsed.data.year}年${parsed.data.month}月-${timestamp()}.xlsx`), + 'Cache-Control': 'private, no-store, max-age=0', + 'X-Exported-Row-Count': String(rows.length), + 'X-Request-Id': ctx.requestId, + }, + }) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/product-sabc/options/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-sabc/options/route.ts new file mode 100644 index 00000000..431f8d9b --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/product-sabc/options/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { SABC_FILTER_OPTION_FIELDS, supplyChainProductSabcService } from '@/server/services/supply-chain-product-sabc.service' + +const querySchema = z.object({ + year: z.coerce.number().int().min(2020).max(2100), + month: z.coerce.number().int().min(1).max(12), + field: z.enum(SABC_FILTER_OPTION_FIELDS), + q: z.string().trim().max(100).optional(), +}) + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'businessManagement') + if (!access.view) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有商品 SABC 查看权限', 403, ctx.requestId) + } + const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选候选参数不正确', 400, ctx.requestId) + } + const options = await supplyChainProductSabcService.listFilterOptions({ + year: parsed.data.year, + month: parsed.data.month, + field: parsed.data.field, + query: parsed.data.q, + }) + return jsonSuccess({ options }, 200, ctx.requestId) + }, { requireAuth: true }) +} 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 new file mode 100644 index 00000000..814f4fb0 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/product-sabc/route.ts @@ -0,0 +1,77 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { SABC_FILTER_FIELDS, supplyChainProductSabcService } from '@/server/services/supply-chain-product-sabc.service' + +const querySchema = z.object({ + year: z.coerce.number().int().min(2020).max(2100), + month: z.coerce.number().int().min(1).max(12), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(200).default(50), + field: z.enum(SABC_FILTER_FIELDS).default('productCode'), + keyword: z.string().trim().max(4000).optional(), + rating: z.enum(['S', 'A', 'B', 'C', 'D']).optional(), + itemNumber: z.string().trim().max(4000).optional(), + productCode: z.string().trim().max(4000).optional(), + styleCode: z.string().trim().max(4000).optional(), + categoryClass: z.string().trim().max(4000).optional(), + opsHead: z.string().trim().max(4000).optional(), + opsDeptHead: z.string().trim().max(4000).optional(), + purchaser: z.string().trim().max(4000).optional(), + designer: z.string().trim().max(4000).optional(), +}) + +const calculateSchema = z.object({ + year: z.coerce.number().int().min(2020).max(2100), + month: z.coerce.number().int().min(1).max(12), + mode: z.enum(['PREVIEW', 'APPLY']).default('PREVIEW'), +}).strict() + +async function checkAccess(user: Parameters[0], action: 'view' | 'edit') { + const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'businessManagement') + return action === 'view' ? access.view : access.edit +} + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + if (!(await checkAccess(ctx.user, 'view'))) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有商品 SABC 查看权限', 403, ctx.requestId) + } + const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'SABC 查询参数不正确', 400, ctx.requestId) + const { itemNumber, productCode, styleCode, categoryClass, opsHead, opsDeptHead, purchaser, designer, ...rest } = parsed.data + const filters = { itemNumber, productCode, styleCode, categoryClass, opsHead, opsDeptHead, purchaser, designer } + return jsonSuccess(await supplyChainProductSabcService.listPage({ ...rest, filters }), 200, ctx.requestId) + }, { requireAuth: true }) +} + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = calculateSchema.safeParse(body) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'SABC 核算参数不正确', 400, ctx.requestId) + const canEdit = await checkAccess(ctx.user, 'edit') + if (parsed.data.mode === 'APPLY' && !canEdit) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有执行 SABC 核算权限', 403, ctx.requestId) + } + if (!canEdit && parsed.data.mode === 'PREVIEW') { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有执行 SABC 试算权限', 403, ctx.requestId) + } + const result = await supplyChainProductSabcService.calculate({ + year: parsed.data.year, + month: parsed.data.month, + apply: parsed.data.mode === 'APPLY', + }) + return jsonSuccess({ + periodStart: result.periodStart, + periodEnd: result.periodEnd, + sourceRows: result.sourceRows, + eligibleRows: result.rows.filter((row) => row.row.assessmentStatus === '考核').length, + taggedRows: result.rows.filter((row) => row.row.sabcRating).length, + rows: result.rows.slice(0, 200).map((row) => row.row), + mode: parsed.data.mode, + }, 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/product-sabc/summary/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-sabc/summary/route.ts new file mode 100644 index 00000000..69eb5f73 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/product-sabc/summary/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { supplyChainProductSabcService } from '@/server/services/supply-chain-product-sabc.service' + +const querySchema = z.object({ + year: z.coerce.number().int().min(2020).max(2100), + month: z.coerce.number().int().min(1).max(12), + dimension: z.enum(['categoryClass', 'opsDeptHead', 'opsHead', 'purchaser']).optional(), + fresh: z.coerce.boolean().optional(), +}) + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'businessManagement') + if (!access.view) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有商品 SABC 查看权限', 403, ctx.requestId) + } + const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '经营总览参数不正确', 400, ctx.requestId) + } + const { dimension, fresh, ...period } = parsed.data + if (!dimension) return jsonSuccess(await supplyChainProductSabcService.summary({ ...period, fresh }), 200, ctx.requestId) + return jsonSuccess(await supplyChainProductSabcService.summaryWithDimension({ ...period, dimension, fresh }), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/product-sabc/trend/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-sabc/trend/route.ts new file mode 100644 index 00000000..0d895b2a --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/product-sabc/trend/route.ts @@ -0,0 +1,27 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { supplyChainProductSabcService } from '@/server/services/supply-chain-product-sabc.service' + +const querySchema = z.object({ + year: z.coerce.number().int().min(2020).max(2100), + month: z.coerce.number().int().min(1).max(12), + months: z.coerce.number().int().min(2).max(12).optional(), + fresh: z.coerce.boolean().optional(), +}) + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'businessManagement') + if (!access.view) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有商品 SABC 查看权限', 403, ctx.requestId) + } + const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '趋势参数不正确', 400, ctx.requestId) + } + return jsonSuccess(await supplyChainProductSabcService.trend(parsed.data), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts index dd797fd4..e1ce044e 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts @@ -133,7 +133,7 @@ describe('supply chain menu', () => { route: '/supply-chain/management/blank-pages/blank-page-2', }), expect.objectContaining({ - label: '空白页 3', + label: '商品SABC打标', module: '经营管理', route: '/supply-chain/management/blank-pages/blank-page-3', }), diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveTableBody.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveTableBody.tsx index 931a83bf..b56b420f 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveTableBody.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveTableBody.tsx @@ -1,5 +1,6 @@ import { memo } from 'react' import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive' +import { formatProductArchiveDateTime } from '@/lib/product-archive-date-time' import type { ArchiveColumnGroup } from './columns' import { ProductArchiveActions } from './ProductArchiveActions' import { ProductArchiveMaterialImageCell } from './ProductArchiveMaterialImageCell' @@ -64,21 +65,6 @@ function areTableBodyPropsEqual( && hasSameColumnLayout(previous.columns, next.columns) } -function formatDateTime(value: string) { - if (!value) return '' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return '' - - return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date) -} - function renderCell( row: SupplyChainProductArchiveRow, prop: string, @@ -98,7 +84,7 @@ function renderCell( } if (prop === 'createTime' || prop === 'updateTime') { - return formatDateTime(row[prop]) + return formatProductArchiveDateTime(row[prop]) } return row[prop as keyof SupplyChainProductArchiveRow] 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 new file mode 100644 index 00000000..dbfede3f --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/blank-pages/ProductSabcView.tsx @@ -0,0 +1,341 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react' +import { Download, RefreshCw, RotateCcw, Search } from 'lucide-react' +import type { ManagementBlankPageDefinition } from '@/lib/supply-chain-management-blank-pages' +import { formatProductArchiveDateTime } from '@/lib/product-archive-date-time' +import { formatSabcDisplayValue } from '@/lib/supply-chain-product-sabc-format' +import { SabcOverviewSection } from './SabcOverviewSection' +import styles from './management-blank-page.module.css' + +type SabcRow = Record +type PageInfo = { page: number; pageSize: number; total: number; totalPages: number } +type FilterField = string + +const baseColumns: Array<[string, string]> = [ + ['styleCode', '款式编码'], ['itemNumber', '货号'], ['productCode', '商品编码'], ['productionStatus', '生产状态'], + ['category', '分类'], ['brand', '品牌'], + ['opsDirector', '运营总负责人'], ['opsDeptHead', '运营部门负责人'], ['opsHead', '运营负责人'], + ['supplier', '供应商'], ['purchaser', '采购'], ['designer', '设计师'], + ['createTime', '创建时间'], ['createDays', '创建天数'], + ['categoryClass', '类目分类'], ['newOldLabel', '新老品标签'], ['newProductRange', '新品区间分布'], ['newProductPeriod', '新品期'], + ['newProductStatus', '是否在新品期内'], ['movingStatus', '是否动销'], ['averageDailySales', '当月平均日销'], ['feeRatio', '当月部分费用占比'], + ['performanceScale', '业绩规模'], ['salesScale', '销量规模'], ['finalScale', '最终核算规模'], ['profitLabel', '利润标签-新'], + ['sabcRating', 'SABC标签'], ['shipAmount', '当月全渠道销售收款总金额'], ['realPerformance', '当月全渠道真实业绩'], + ['shipQty', '当月全渠道发货件数'], ['grossProfit', '当月全渠道商品毛利额'], ['afterSaleAmount', '当月售后金额'], + ['grossMargin', '结算毛利率'], ['afterSaleRatio', '售后占比'], ['assessmentStatus', '是否考核动销'], + ['noAssessmentReason', '不考核原因'], ['sameProductCode', '同款商品编码(业绩&费比合并计算)'], +] + +const FIELD_FILTERS: Array<[string, string]> = [ + ['itemNumber', '货号'], ['productCode', '商品编码'], ['styleCode', '款式编码'], ['categoryClass', '类目分类'], + ['opsHead', '运营负责人'], ['opsDeptHead', '运营部门负责人'], ['purchaser', '采购'], ['designer', '设计师'], +] +const EMPTY_FIELD_INPUTS = Object.fromEntries(FIELD_FILTERS.map(([key]) => [key, ''])) as Record + +function columnsForMonth(year: number, month: number) { + const end = new Date(Date.UTC(year, month, 0)).getUTCDate() + const prefix = `${month}月` + const period = `${month}.1-${month}.${end}` + return baseColumns.map(([key, label]) => { + if (key === 'averageDailySales') return [key, `${prefix}平均日销`] as [string, string] + if (key === 'feeRatio') return [key, `${prefix}部分费用占比`] as [string, string] + if (key === 'shipAmount') return [key, `${period}全渠道销售收款总金额`] as [string, string] + if (key === 'realPerformance') return [key, `${period}全渠道真实业绩`] as [string, string] + if (key === 'shipQty') return [key, `${period}全渠道发货件数`] as [string, string] + if (key === 'grossProfit') return [key, `${period}全渠道商品毛利额`] as [string, string] + if (key === 'afterSaleAmount') return [key, `${period}售后金额`] as [string, string] + return [key, label] as [string, string] + }) +} + +function previousMonth() { + const date = new Date() + date.setMonth(date.getMonth() - 1) + return { year: date.getFullYear(), month: date.getMonth() + 1 } +} + +async function readResponse(response: Response) { + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败') + return payload.data +} + +function FilterFieldCombo({ label, field, value, year, month, onChange }: { + label: string + field: string + value: string + year: number + month: number + onChange: (value: string) => void +}) { + const [open, setOpen] = useState(false) + const [options, setOptions] = useState([]) + const [loading, setLoading] = useState(false) + const lastToken = value.split(/[\n\r,,;;]/).pop()?.trim() ?? '' + const visibleOptions = options.length > 1000 ? options.slice(0, 1000) : options + + useEffect(() => { + if (!open) return undefined + let cancelled = false + const timer = setTimeout(async () => { + setLoading(true) + try { + const params = new URLSearchParams({ year: String(year), month: String(month), field, q: lastToken }) + const data = await readResponse(await fetch(`/api/supply-chain/product-sabc/options?${params}`, { cache: 'no-store' })) + if (!cancelled) setOptions(Array.isArray(data?.options) ? data.options : []) + } catch { + if (!cancelled) setOptions([]) + } finally { + if (!cancelled) setLoading(false) + } + }, 200) + return () => { cancelled = true; clearTimeout(timer) } + }, [open, field, lastToken, month, year]) + + useEffect(() => { + if (!open) return undefined + const close = (event: MouseEvent) => { + const target = event.target as HTMLElement | null + if (!target?.closest(`[data-sabc-combo="${field}"]`)) setOpen(false) + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [open, field]) + + const selectOption = (option: string) => { + const parts = value.split(/[\n\r,,;;]/).map((part) => part.trim()).filter(Boolean) + if (lastToken) parts.pop() + if (!parts.includes(option)) parts.push(option) + onChange(parts.join('\n')) + setOpen(false) + } + + return ( +