feat(supply-chain): 限价流程同步改为服务器端可运行 + systemd 每日定时器
- sync-limit-workflows.mjs 路径参数化:新增 --out-dir/--initial-dir/--env-file/--skip-excel, 环境变量 LIMIT_SYNC_OUT_DIR / LIMIT_SYNC_INITIAL_DIR / LIMIT_SYNC_ENV_FILE / LIMIT_SYNC_SKIP_EXCEL / LIMIT_SYNC_HISTORY_KEEP; 默认目录改为应用根目录,不再写死 Mac 桌面 - 期初数据:优先显式目录,其次仓库自带 scripts/data/limit-sync/期初数据(首次部署可直接使用), 服务器上业务更新只需把新的「限价期初数据*.xlsx」放进 shared 目录 - 写库安全:校验 DATABASE_URL、打印目标库(不含密码)、DELETE+INSERT 包在单事务里, 失败自动回滚保留上一版限价/授权;Excel 导出失败不影响已提交的库数据 - 输出治理:输出目录自动创建;历史版本默认保留最近 20 份,避免服务器磁盘每天累积 30MB - 新增 run-limit-workflow-sync-production.cjs:预检 shared/.env 变量(DATABASE_URL / JST_REPORT_DB_*)、 打印库目标、透传参数、30 分钟硬超时、失败写 job 日志 - 新增 install-limit-workflow-sync-timer.sh:照 marketing-sales 样板安装 systemd service+timer, EnvironmentFile=$BASE/shared/.env,默认每天北京时间 07:30,输出/期初数据放 $BASE/shared/limit-workflow-sync - 文档补充「正式站同步」章节;顺带修正 supply-chain-page-permissions 测试的菜单页数(新增限价管理后 27 页) - 未改动 20261014/20261015 三个迁移目录(正式库已按现名建表)
This commit is contained in:
parent
6b2ff9159a
commit
e656fa6d5d
@ -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`,默认 `<app>/期初数据`;目录为空时用仓库自带
|
||||
`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`(默认 `<app>/导出结果`)里的 `限价流程_完整明细表.xlsx` 每日覆盖,
|
||||
旧版本进 `历史版本/`,默认保留最近 20 份(`LIMIT_SYNC_HISTORY_KEEP`)。
|
||||
- `--skip-excel` / `LIMIT_SYNC_SKIP_EXCEL=1` 可只写库不导 Excel。
|
||||
- 期初数据更新:把新的 `限价期初数据*.xlsx` 放进 `shared/limit-workflow-sync/期初数据/` 即可,下次同步自动取最新。
|
||||
- `SupplyChainBreachRuleConfig` 不需要灌数:接口读取时用代码内置默认值,首次在「规则配置」保存时落库。
|
||||
|
||||
## 五、待办
|
||||
|
||||
- [ ] 限价流程数据接入 + 筛选逻辑(生效时间版本、货号维度)
|
||||
|
||||
Binary file not shown.
84
furniture-3d-system/scripts/install-limit-workflow-sync-timer.sh
Executable file
84
furniture-3d-system/scripts/install-limit-workflow-sync-timer.sh
Executable file
@ -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" <<EOF
|
||||
[Unit]
|
||||
Description=Synchronize limit workflow prices and price authorizations
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=$APP_ROOT
|
||||
EnvironmentFile=$BASE/shared/.env
|
||||
Environment=NODE_ENV=production
|
||||
Environment=LIMIT_SYNC_OUT_DIR=$OUT_DIR
|
||||
Environment=LIMIT_SYNC_INITIAL_DIR=$INITIAL_DIR
|
||||
Environment=LIMIT_SYNC_JOB_LOG_DIR=$LOG_DIR
|
||||
Environment=LIMIT_SYNC_HISTORY_KEEP=20
|
||||
ExecStart=/usr/bin/node scripts/run-limit-workflow-sync-production.cjs
|
||||
TimeoutStartSec=35min
|
||||
KillMode=control-group
|
||||
EOF
|
||||
|
||||
cat >"/etc/systemd/system/$SERVICE.timer" <<EOF
|
||||
[Unit]
|
||||
Description=Limit workflow sync once a day at Beijing 07:30
|
||||
|
||||
[Timer]
|
||||
OnCalendar=$ON_CALENDAR
|
||||
OnBootSec=5min
|
||||
AccuracySec=1s
|
||||
Persistent=true
|
||||
Unit=$SERVICE.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE.timer"
|
||||
systemctl restart "$SERVICE.timer"
|
||||
systemctl list-timers --all "$SERVICE*" --no-pager
|
||||
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 限价流程同步 · 服务器运行入口(systemd oneshot 调用)。
|
||||
*
|
||||
* 职责:
|
||||
* 1. 从应用根目录加载 shared 环境变量(systemd EnvironmentFile 已注入的优先);
|
||||
* 2. 预检数据库连接所需变量,避免带着空配置去跑同步;
|
||||
* 3. 以子进程运行 scripts/sync-limit-workflows.mjs,并设置硬超时;
|
||||
* 4. 退出码透传,systemd / 值班同学据 journal 或日志目录判断结果。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/run-limit-workflow-sync-production.cjs [--skip-excel] [...透传给同步脚本的参数]
|
||||
*/
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const { spawn } = require('node:child_process')
|
||||
const { loadEnvConfig } = require('@next/env')
|
||||
|
||||
const appRoot = path.resolve(__dirname, '..')
|
||||
loadEnvConfig(appRoot)
|
||||
|
||||
const RUN_TIMEOUT_MS = Number(process.env.LIMIT_SYNC_RUN_TIMEOUT_MS || 30 * 60_000)
|
||||
const jobLogDir = process.env.LIMIT_SYNC_JOB_LOG_DIR || path.join(appRoot, '导出结果')
|
||||
|
||||
function fail(code, detail) {
|
||||
console.error(code, detail ? `- ${detail}` : '')
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
function writeFailureJournal(code) {
|
||||
try {
|
||||
fs.mkdirSync(jobLogDir, { recursive: true })
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '')
|
||||
fs.writeFileSync(
|
||||
path.join(jobLogDir, `limit-sync-failure-${stamp}.json`),
|
||||
JSON.stringify({ failureAt: new Date().toISOString(), code }) + '\n',
|
||||
{ flag: 'wx' },
|
||||
)
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') console.error('LIMIT_SYNC_FAILURE_JOURNAL_UNAVAILABLE')
|
||||
}
|
||||
}
|
||||
|
||||
if (!String(process.env.DATABASE_URL || '').trim()) {
|
||||
fail('DATABASE_URL_REQUIRED', '请在 shared/.env 配置业务库连接(限价表写入目标)')
|
||||
process.exit(1)
|
||||
}
|
||||
const requiredReportVars = ['JST_REPORT_DB_HOST', 'JST_REPORT_DB_NAME', 'JST_REPORT_DB_USER', 'JST_REPORT_DB_PASSWORD']
|
||||
const missingReportVars = requiredReportVars.filter((name) => !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
|
||||
})
|
||||
@ -1,18 +1,84 @@
|
||||
// 限价流程同步:拉取「限价」系列钉钉流程(四个模板、仅已完结),整理为全量明细大表导出 Excel。
|
||||
// 限价流程同步:拉取「限价」系列钉钉流程(四个模板、仅已完结),写入限价表并导出全量明细 Excel。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/sync-limit-workflows.mjs
|
||||
// 用法(服务器 / 本地通用):
|
||||
// node scripts/sync-limit-workflows.mjs \
|
||||
// [--out-dir <目录>] [--initial-dir <目录>] [--env-file <env 文件>] [--skip-excel]
|
||||
//
|
||||
// 输出:导出结果/限价流程_完整明细表.xlsx(固定文件名,每次覆盖为最新全量;
|
||||
// 旧文件自动归档到 导出结果/历史版本/)。
|
||||
// 环境变量(服务器上由 shared/.env 提供):
|
||||
// DATABASE_URL 业务库(限价 / 授权写入目标)
|
||||
// JST_REPORT_DB_* 钉钉流程报表库(只读)
|
||||
// PROD_ARCHIVE_DB_* 正式库 external.product_catalog_wide_v1(产品限价页面)
|
||||
// LIMIT_SYNC_OUT_DIR Excel 输出目录(默认 <app>/导出结果)
|
||||
// LIMIT_SYNC_INITIAL_DIR 限价期初数据目录(默认 <app>/期初数据,其次用仓库自带 scripts/data/limit-sync/期初数据)
|
||||
// LIMIT_SYNC_ENV_FILE 额外 env 文件(服务器上一般由 systemd EnvironmentFile 注入,无需设置)
|
||||
// LIMIT_SYNC_SKIP_EXCEL=1 只写库、不导出 Excel(定时任务可选)
|
||||
// LIMIT_SYNC_HISTORY_KEEP 历史版本保留份数(默认 20)
|
||||
//
|
||||
// 输出:<out-dir>/限价流程_完整明细表.xlsx(固定文件名,每次覆盖为最新全量;
|
||||
// 旧文件自动归档到 <out-dir>/历史版本/,超过保留份数自动清理)。
|
||||
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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user