diff --git a/furniture-3d-system/docs/限价与破价判定规则.md b/furniture-3d-system/docs/限价与破价判定规则.md index a57bfdb1..33133887 100644 --- a/furniture-3d-system/docs/限价与破价判定规则.md +++ b/furniture-3d-system/docs/限价与破价判定规则.md @@ -74,20 +74,46 @@ - 「产品限价页面」已确认(CONFIRMED)的限价全量纳入(2026-10-16):来源正式库 `external.product_catalog_wide_v1`,给没有限价流程的货号兜底(如 CY012801=268.6); 同货号与流程限价冲突时**以流程为准**,页面价只在流程没覆盖时生效。 - - 「限价期初数据」(业务整理的存量限价 Excel,2026-10-16 纳入):放在 - `/Users/a1/Desktop/供应链系统/期初数据/限价期初数据*.xlsx`(取目录里最新一份), + - 「限价期初数据」(业务整理的存量限价 Excel,2026-10-16 纳入):放在期初数据目录 + (`LIMIT_SYNC_INITIAL_DIR`,默认 `/期初数据`;目录为空时用仓库自带 + `scripts/data/limit-sync/期初数据/限价期初数据*.xlsx`),取目录里最新一份, 按「货号 + 负责人限价 + 更新日期」写入,作为**最低优先级**兜底(如 MKHDNZ0409260600468=279)。 - 优先级:**限价流程 > 产品限价页面(已确认)> 限价期初数据**;低优先级只在货号没有更高优先级限价时生效。 + 优先级:**限价流程 > 产品限价页面(已确认)> 限价期初数据**;低优先级只在货号没有更高优先级限价时生效。 **例外(2026-10-16 确认)**:期初数据的「更新日期」**晚于**流程完成时间时,以期初数据为准(谁新用谁); 期初比流程旧则维持上面的优先级。 - **非负责人限价一律按最新口径计算**(2026-10-16 确认):负责人限价 × 1.05,且不低于 +10 元; - 期初数据表 / 产品限价页面里的历史「非负责人限价」(倍数 1%~15% 不统一)不采用, - 限价明细页、导出文件、破价判定三处都用同一公式。 - + **非负责人限价一律按最新口径计算**(2026-10-16 确认):负责人限价 × 1.05,且不低于 +10 元; + 期初数据表 / 产品限价页面里的历史「非负责人限价」(倍数 1%~15% 不统一)不采用, + 限价明细页、导出文件、破价判定三处都用同一公式。 写入规则:同货号多流程取**审核通过时间最新**的一条;限价为 0 或空的明细剔除;「申请与SPU负责人同价 / 活动限价」不覆盖负责人限价,只作为价格授权;流程「货号」列若填的是商品编码,用商品编码匹配产品档案换算为货号;无货号时同样用商品编码匹配。页面限价的时间按**北京时间墙钟**存(与流程完成时间一致)。 + 4. **覆盖率**:暂不做未匹配清单与告警(后续优化)。 +5. **正式站同步(服务器端)**:`scripts/sync-limit-workflows.mjs` 已改成服务器可运行,连接信息全部取 + 服务器 `shared/.env` 里的既有变量(`DATABASE_URL` / `JST_REPORT_DB_*` / `PROD_ARCHIVE_DB_*`), + 不再依赖 Mac 本地路径: + + ```bash + # 手动跑一次(正式站目录下,root 或能读 shared/.env 的账号) + cd /opt/furniture-3d-system-prod/current/furniture-3d-system + sudo -E bash -lc 'set -a; . /opt/furniture-3d-system-prod/shared/.env; set +a; \ + LIMIT_SYNC_OUT_DIR=/opt/furniture-3d-system-prod/shared/limit-workflow-sync/导出结果 \ + LIMIT_SYNC_INITIAL_DIR=/opt/furniture-3d-system-prod/shared/limit-workflow-sync/期初数据 \ + node scripts/run-limit-workflow-sync-production.cjs' + + # 安装每日定时器(systemd,与营销日销同一套样板):每天北京时间 07:30 + sudo bash scripts/install-limit-workflow-sync-timer.sh + systemctl list-timers --all 'furniture-limit-workflow-sync*' --no-pager + journalctl -u furniture-limit-workflow-sync.service -n 200 --no-pager + ``` + + - 写库是**全量重建 + 单事务**:成功才提交;失败自动回滚,正式库保留上一版限价与授权。 + - 输出目录 `LIMIT_SYNC_OUT_DIR`(默认 `/导出结果`)里的 `限价流程_完整明细表.xlsx` 每日覆盖, + 旧版本进 `历史版本/`,默认保留最近 20 份(`LIMIT_SYNC_HISTORY_KEEP`)。 + - `--skip-excel` / `LIMIT_SYNC_SKIP_EXCEL=1` 可只写库不导 Excel。 + - 期初数据更新:把新的 `限价期初数据*.xlsx` 放进 `shared/limit-workflow-sync/期初数据/` 即可,下次同步自动取最新。 + - `SupplyChainBreachRuleConfig` 不需要灌数:接口读取时用代码内置默认值,首次在「规则配置」保存时落库。 + ## 五、待办 - [ ] 限价流程数据接入 + 筛选逻辑(生效时间版本、货号维度) diff --git a/furniture-3d-system/scripts/data/limit-sync/期初数据/限价期初数据-数据填报表-2026-10-16.xlsx b/furniture-3d-system/scripts/data/limit-sync/期初数据/限价期初数据-数据填报表-2026-10-16.xlsx new file mode 100644 index 00000000..419a84bd Binary files /dev/null and b/furniture-3d-system/scripts/data/limit-sync/期初数据/限价期初数据-数据填报表-2026-10-16.xlsx differ diff --git a/furniture-3d-system/scripts/install-limit-workflow-sync-timer.sh b/furniture-3d-system/scripts/install-limit-workflow-sync-timer.sh new file mode 100755 index 00000000..0ebd6bad --- /dev/null +++ b/furniture-3d-system/scripts/install-limit-workflow-sync-timer.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# 限价流程同步 · 正式站 systemd 定时器安装脚本(与 marketing-sales timer 同一套样板)。 +# +# 用途:每天把钉钉限价流程 / 产品限价页面已确认限价 / 限价期初数据同步到正式库 +# (SupplyChainStyleLimitPrice、SupplyChainLimitFlowAuth),并输出全量明细 Excel。 +# +# 前置: +# - 正式站目录:$FURNITURE_PROD_BASE(默认 /opt/furniture-3d-system-prod) +# - 环境变量:$BASE/shared/.env(DATABASE_URL / JST_REPORT_DB_* / PROD_ARCHIVE_DB_*) +# - 期初数据:默认从仓库自带 scripts/data/limit-sync/期初数据 复制到 shared 目录; +# 之后业务更新期初数据,直接把新的「限价期初数据*.xlsx」放进 shared 目录即可。 +# +# 时间:默认每天北京时间 07:30(可用 LIMIT_SYNC_ON_CALENDAR 覆盖)。 +# 破价订单扫描如果由别处调度,请安排在本任务之后。 +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +APP_ROOT="$BASE/current/furniture-3d-system" +SERVICE="furniture-limit-workflow-sync" +SHARED_DIR="$BASE/shared/limit-workflow-sync" +OUT_DIR="$SHARED_DIR/导出结果" +INITIAL_DIR="$SHARED_DIR/期初数据" +LOG_DIR="$BASE/shared/logs/limit-workflow-sync" +ON_CALENDAR="${LIMIT_SYNC_ON_CALENDAR:-*-*-* 07:30:00 Asia/Shanghai}" + +if [ "$(id -u)" -ne 0 ]; then echo "Root required" >&2; exit 1; fi +if [ ! -f "$APP_ROOT/scripts/run-limit-workflow-sync-production.cjs" ] || [ ! -f "$APP_ROOT/scripts/sync-limit-workflows.mjs" ]; then + echo "Missing deployed limit sync runner ($APP_ROOT/scripts)" >&2; exit 1 +fi +if [ ! -f "$BASE/shared/.env" ]; then + echo "Missing $BASE/shared/.env(需要 DATABASE_URL / JST_REPORT_DB_* 等既有变量)" >&2; exit 1 +fi +chmod 600 "$BASE/shared/.env" +mkdir -p "$OUT_DIR" "$INITIAL_DIR" "$LOG_DIR" + +# 首次部署:把仓库自带的期初数据放到 shared 目录(已有文件不覆盖,方便业务更新) +if ! compgen -G "$INITIAL_DIR/限价期初数据*.xlsx" >/dev/null; then + if compgen -G "$APP_ROOT/scripts/data/limit-sync/期初数据/限价期初数据*.xlsx" >/dev/null; then + cp "$APP_ROOT"/scripts/data/limit-sync/期初数据/限价期初数据*.xlsx "$INITIAL_DIR/" + echo "已初始化期初数据:$INITIAL_DIR" + else + echo "警告:没有找到期初数据,本次只同步流程与产品限价页面数据" >&2 + fi +fi + +cat >"/etc/systemd/system/$SERVICE.service" <"/etc/systemd/system/$SERVICE.timer" < !String(process.env[name] || '').trim()) +if (missingReportVars.length) { + fail('JST_REPORT_DB_CREDENTIALS_REQUIRED', `缺少 ${missingReportVars.join(', ')}`) + process.exit(1) +} + +// 只打印 host/database,避免把密码写进 journal +try { + const target = new URL(process.env.DATABASE_URL) + console.log(`[limit-sync] 业务库目标 ${target.hostname}:${target.port || 5432}${target.pathname}`) + console.log(`[limit-sync] 报表库目标 ${process.env.JST_REPORT_DB_HOST}/${process.env.JST_REPORT_DB_NAME}`) + if (process.env.PROD_ARCHIVE_DB_HOST) { + console.log(`[limit-sync] 产品限价页面 ${process.env.PROD_ARCHIVE_DB_HOST}/${process.env.PROD_ARCHIVE_DB_NAME || ''}`) + } else { + console.log('[limit-sync] 未配置 PROD_ARCHIVE_DB_*:产品限价页面明细将退回本地库') + } +} catch { + fail('DATABASE_URL_INVALID') + process.exit(1) +} + +const child = spawn( + process.execPath, + [path.join(appRoot, 'scripts/sync-limit-workflows.mjs'), ...process.argv.slice(2)], + { cwd: appRoot, env: process.env, stdio: 'inherit' }, +) + +const timeout = setTimeout(() => { + console.error('LIMIT_SYNC_RUN_TIMEOUT') + writeFailureJournal('LIMIT_SYNC_RUN_TIMEOUT') + try { child.kill('SIGKILL') } catch {} +}, RUN_TIMEOUT_MS) +timeout.unref() + +child.on('error', (error) => { + clearTimeout(timeout) + fail('LIMIT_SYNC_RUNNER_LAUNCH_FAILED', error.message) +}) +child.on('exit', (code, signal) => { + clearTimeout(timeout) + if (code === 0) { + process.exitCode = 0 + return + } + console.error(`LIMIT_SYNC_CHILD_FAILED code=${code ?? 'null'} signal=${signal ?? 'null'}`) + writeFailureJournal('LIMIT_SYNC_CHILD_FAILED') + process.exitCode = 1 +}) diff --git a/furniture-3d-system/scripts/sync-limit-workflows.mjs b/furniture-3d-system/scripts/sync-limit-workflows.mjs index d78c10b6..33eae931 100644 --- a/furniture-3d-system/scripts/sync-limit-workflows.mjs +++ b/furniture-3d-system/scripts/sync-limit-workflows.mjs @@ -1,18 +1,84 @@ -// 限价流程同步:拉取「限价」系列钉钉流程(四个模板、仅已完结),整理为全量明细大表导出 Excel。 +// 限价流程同步:拉取「限价」系列钉钉流程(四个模板、仅已完结),写入限价表并导出全量明细 Excel。 // -// 用法: -// node scripts/sync-limit-workflows.mjs +// 用法(服务器 / 本地通用): +// node scripts/sync-limit-workflows.mjs \ +// [--out-dir <目录>] [--initial-dir <目录>] [--env-file ] [--skip-excel] // -// 输出:导出结果/限价流程_完整明细表.xlsx(固定文件名,每次覆盖为最新全量; -// 旧文件自动归档到 导出结果/历史版本/)。 +// 环境变量(服务器上由 shared/.env 提供): +// DATABASE_URL 业务库(限价 / 授权写入目标) +// JST_REPORT_DB_* 钉钉流程报表库(只读) +// PROD_ARCHIVE_DB_* 正式库 external.product_catalog_wide_v1(产品限价页面) +// LIMIT_SYNC_OUT_DIR Excel 输出目录(默认 /导出结果) +// LIMIT_SYNC_INITIAL_DIR 限价期初数据目录(默认 /期初数据,其次用仓库自带 scripts/data/limit-sync/期初数据) +// LIMIT_SYNC_ENV_FILE 额外 env 文件(服务器上一般由 systemd EnvironmentFile 注入,无需设置) +// LIMIT_SYNC_SKIP_EXCEL=1 只写库、不导出 Excel(定时任务可选) +// LIMIT_SYNC_HISTORY_KEEP 历史版本保留份数(默认 20) +// +// 输出:/限价流程_完整明细表.xlsx(固定文件名,每次覆盖为最新全量; +// 旧文件自动归档到 /历史版本/,超过保留份数自动清理)。 import envPkg from '@next/env' - -const { loadEnvConfig } = envPkg -loadEnvConfig(process.cwd()) - -import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { randomUUID } from 'node:crypto' + +const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +/** 极简参数解析:支持 `--key value`(值缺省为 true)。 */ +function readArgs(argv) { + const parsed = {} + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + if (!token.startsWith('--')) continue + const key = token.slice(2) + const next = argv[index + 1] + if (next && !next.startsWith('--')) { + parsed[key] = next + index += 1 + } else { + parsed[key] = true + } + } + return parsed +} + +/** 读取 `KEY=VALUE` 形式的 env 文件(不覆盖已存在的进程变量,和 systemd EnvironmentFile 行为一致)。 */ +function loadEnvFile(filePath) { + if (!existsSync(filePath)) throw new Error(`env 文件不存在:${filePath}`) + const content = readFileSyncUtf8(filePath) + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + const separator = line.indexOf('=') + if (separator <= 0) continue + const key = line.slice(0, separator).trim() + let value = line.slice(separator + 1).trim() + if ( + (value.startsWith("'") && value.endsWith("'")) + || (value.startsWith('"') && value.endsWith('"')) + ) { + value = value.slice(1, -1) + } + if (process.env[key] === undefined) process.env[key] = value + } +} + +function readFileSyncUtf8(filePath) { + return readFileSync(filePath, 'utf8') +} + +function resolveFromAppRoot(value) { + const text = String(value ?? '').trim() + if (!text) return '' + return isAbsolute(text) ? text : resolve(APP_ROOT, text) +} + +const args = readArgs(process.argv.slice(2)) +const envFile = resolveFromAppRoot(args['env-file'] || process.env.LIMIT_SYNC_ENV_FILE || '') +if (envFile) loadEnvFile(envFile) +// 仍按 Next 的方式加载 app root 的 .env(已有变量不会被覆盖) +envPkg.loadEnvConfig(APP_ROOT) + import sql from 'mssql' import pg from 'pg' import * as XLSX from 'xlsx' @@ -20,11 +86,16 @@ import * as XLSX from 'xlsx' /** ESM 下 xlsx 的命名导出不全(readFile 取不到),统一走这个命名空间兜底。 */ const xlsx = XLSX.default ?? XLSX -const OUT_DIR = '/Users/a1/Desktop/供应链系统/导出结果' -const OUT_FILE = `${OUT_DIR}/限价流程_完整明细表.xlsx` -const HISTORY_DIR = `${OUT_DIR}/历史版本` -/** 限价期初数据(业务整理的存量限价)放这里,取目录里最新的一份。 */ -const INITIAL_LIMIT_DIR = '/Users/a1/Desktop/供应链系统/期初数据' +const OUT_DIR = resolveFromAppRoot(args['out-dir'] || process.env.LIMIT_SYNC_OUT_DIR || join(APP_ROOT, '导出结果')) +const OUT_FILE = join(OUT_DIR, '限价流程_完整明细表.xlsx') +const HISTORY_DIR = join(OUT_DIR, '历史版本') +/** 限价期初数据(业务整理的存量限价)目录:优先显式目录,其次仓库自带样例目录。 */ +const INITIAL_LIMIT_DIR = resolveFromAppRoot( + args['initial-dir'] || process.env.LIMIT_SYNC_INITIAL_DIR || join(APP_ROOT, '期初数据'), +) +const BUNDLED_INITIAL_LIMIT_DIR = join(APP_ROOT, 'scripts/data/limit-sync/期初数据') +const SKIP_EXCEL = Boolean(args['skip-excel']) || /^(1|true|yes)$/i.test(process.env.LIMIT_SYNC_SKIP_EXCEL ?? '') +const HISTORY_KEEP = Math.max(1, Number(process.env.LIMIT_SYNC_HISTORY_KEEP ?? 20) || 20) // 数据源口径(业务确认): // A. 「新品限价流程」→ 限价明细取自系统「产品限价页面」(按流程链接中的 SPU 关联); @@ -336,16 +407,20 @@ try { const initialLimitItems = [] let initialLimitFileName = '' try { - const candidates = existsSync(INITIAL_LIMIT_DIR) - ? readdirSync(INITIAL_LIMIT_DIR) - .filter((name) => /^限价期初数据.*\.xlsx$/.test(name) && !name.startsWith('~$')) - .map((name) => ({ name, path: join(INITIAL_LIMIT_DIR, name) })) - .sort((a, b) => statSync(b.path).mtimeMs - statSync(a.path).mtimeMs) - : [] + // 先看显式/环境变量指定的目录,没有再回落到仓库自带的 scripts/data 目录 + const candidateDirs = [...new Set([INITIAL_LIMIT_DIR, BUNDLED_INITIAL_LIMIT_DIR].filter(Boolean))] + const candidates = candidateDirs.flatMap((dir) => ( + existsSync(dir) + ? readdirSync(dir) + .filter((name) => /^限价期初数据.*\.xlsx$/.test(name) && !name.startsWith('~$')) + .map((name) => ({ name, dir, path: join(dir, name) })) + : [] + )).sort((a, b) => statSync(b.path).mtimeMs - statSync(a.path).mtimeMs) if (!candidates.length) { - console.log(`限价期初数据:${INITIAL_LIMIT_DIR} 里没有「限价期初数据*.xlsx」,跳过`) + console.log(`限价期初数据:${candidateDirs.join(' / ')} 里没有「限价期初数据*.xlsx」,跳过`) } else { initialLimitFileName = candidates[0].name + console.log(`限价期初数据目录:${candidates[0].dir}`) const workbook = xlsx.readFile(candidates[0].path) const sheetName = workbook.SheetNames[0] const sheetRows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '' }) @@ -544,13 +619,22 @@ let initialLimitSheetRows = [] /** 货号 → 最终生效来源(flow / page / initial),写库那段填充,导出标记用。 */ let finalLimitSourceByStyle = new Map() +let writeClient = null try { + if (!String(process.env.DATABASE_URL ?? '').trim()) { + throw new Error('缺少 DATABASE_URL:服务器上请通过 shared/.env(systemd EnvironmentFile)注入') + } const client = new pg.Client({ connectionString: process.env.DATABASE_URL }) + writeClient = client await client.connect() + const target = (await client.query( + `SELECT current_database() AS db, COALESCE(inet_server_addr()::text, 'local') AS host`, + )).rows[0] + console.log(`写库目标:${target.host}/${target.db}(货号限价 + 价格授权)`) - // 全量重建:每次同步生成最新镜像(两表只由本脚本维护) - await client.query(`DELETE FROM "SupplyChainStyleLimitPrice"`) - await client.query(`DELETE FROM "SupplyChainLimitFlowAuth"`) + // 全量重建:每次同步生成最新镜像(两表只由本脚本维护)。 + // 生产库上必须保证「要么全量成功、要么保留旧数据」,所以整段包在事务里。 + await client.query('BEGIN') // 明细没有货号时,用商品编码(条码)匹配产品档案里的货号 const missingBarcodes = [...new Set(collected.filter((item) => !item.styleCode && item.barcode).map((item) => item.barcode))] @@ -738,6 +822,7 @@ try { authUpserts += 1 } + await client.query('COMMIT') await client.end() pageLimitSheetRows = pageLimitItems.map((item) => { const source = finalLimitSourceByStyle.get(item.skuCode) @@ -786,7 +871,15 @@ try { + `,价格授权 ${authUpserts} 条`, ) } catch (error) { - console.warn('写库失败(不影响 Excel 导出):', error.message) + try { await writeClient?.query('ROLLBACK') } catch {} + try { await writeClient?.end() } catch {} + console.warn('写库失败(已回滚,正式库保留旧数据;Excel 导出继续):', error.message) +} + +if (SKIP_EXCEL) { + console.log('已按 LIMIT_SYNC_SKIP_EXCEL 跳过 Excel 导出') + await pool.close() + process.exit(0) } const workbook = XLSX.utils.book_new() @@ -810,13 +903,23 @@ if (initialLimitSheetRows.length) { } // 固定文件名覆盖式输出:旧文件先归档到「历史版本」,保证导出目录里永远只有一份最新全量表。 +mkdirSync(OUT_DIR, { recursive: true }) if (existsSync(OUT_FILE)) { mkdirSync(HISTORY_DIR, { recursive: true }) const now = new Date() const archiveStamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}` renameSync(OUT_FILE, `${HISTORY_DIR}/限价流程_完整明细表_归档_${archiveStamp}.xlsx`) + // 只保留最近 N 份归档,避免服务器上每天 28MB 无限累积 + const archives = readdirSync(HISTORY_DIR) + .filter((name) => /^限价流程_完整明细表_归档_.*\.xlsx$/.test(name)) + .map((name) => ({ name, path: join(HISTORY_DIR, name), mtime: statSync(join(HISTORY_DIR, name)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime) + for (const stale of archives.slice(HISTORY_KEEP)) { + unlinkSync(stale.path) + } } XLSX.writeFile(workbook, OUT_FILE) console.log(`已导出: ${OUT_FILE}`) +console.log(`历史版本保留:${HISTORY_KEEP} 份`) await pool.close() diff --git a/furniture-3d-system/src/lib/supply-chain-page-permissions.test.ts b/furniture-3d-system/src/lib/supply-chain-page-permissions.test.ts index 745559c9..2186be79 100644 --- a/furniture-3d-system/src/lib/supply-chain-page-permissions.test.ts +++ b/furniture-3d-system/src/lib/supply-chain-page-permissions.test.ts @@ -9,7 +9,8 @@ describe('independent supply-chain page permissions', () => { ]) it('gives every menu page its own target, route mapping and permission row', () => { - expect(pages).toHaveLength(26) + // 菜单页数量随功能迭代增长(新增「限价管理」后为 27) + expect(pages).toHaveLength(27) expect(new Set(pages.map((page) => page.targetKey)).size).toBe(pages.length) const matrixRows = PERMISSION_MATRIX_SECTIONS.find((section) => section.id === 'supply')!.groups.flatMap((group) => group.rows) for (const page of pages) {