From 08318ef8d9167bca537f0277a20025b29cf58bd8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 28 Oct 2026 11:52:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E8=B4=A2=E5=8A=A1=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF=E6=95=B0=E6=8D=AE=E9=98=B6=E6=AE=B5=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E4=B8=8E=E8=BF=90=E7=BB=B4=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 deploy/post-deploy.sh:回填 → 业务配置 → 同步 → 验收四阶段编排,支持 --only/--dry-run/窗口参数 - 新增 tools 镜像与 scripts/verify-finance-launch.sql(15 项上线验收) - 验收口径:成本闸门二选一、要求已过账凭证、备用金支出关联、成本覆盖 WARN - 加固:参数校验、工具镜像每次刷新、docker run 兜底超时、迁移前缀探测、验收失败退出码 2 - 工具镜像预建 /app/data,消除日志 EACCES - 回填后与 restore-db 恢复后自动 VACUUM (ANALYZE),修复订单列表分页 COUNT 全表扫描(41 万单实测 27s → 0.17s) - 文档:41 万单同步用 7 天窗 + 180s 超时;回填/恢复后刷新统计 --- deploy/docker/tools.Dockerfile | 49 +++ deploy/post-deploy.sh | 484 ++++++++++++++++++++++++ deploy/restore-db.sh | 22 ++ docker-compose.yml | 34 ++ docs/数据库备份与恢复.md | 8 +- docs/财务上线初始化与验收-2026-10-26.md | 18 + docs/部署启动与数据库重建.md | 59 ++- scripts/verify-finance-launch.sql | 311 +++++++++++++++ 8 files changed, 977 insertions(+), 8 deletions(-) create mode 100644 deploy/docker/tools.Dockerfile create mode 100755 deploy/post-deploy.sh create mode 100644 scripts/verify-finance-launch.sql diff --git a/deploy/docker/tools.Dockerfile b/deploy/docker/tools.Dockerfile new file mode 100644 index 00000000..93631fc8 --- /dev/null +++ b/deploy/docker/tools.Dockerfile @@ -0,0 +1,49 @@ +# 财务运维工具镜像(一次性/按需容器) +# +# 为什么需要它:生产 backend 镜像为了体积与安全做了两件事—— +# 1) `npm prune --omit=dev`(删掉 tsx 等 devDependencies) +# 2) 只 `COPY dist`(tsconfig.build.json 的 rootDir=src,scripts/ 不进 dist) +# 因此 `docker compose exec backend npm run revenue:backfill` 在生产**必然失败**。 +# 本镜像保留源码 + devDependencies,专门用来跑 scripts/ 下的回填/同步脚本。 +# +# 构建(仓库根目录): +# docker compose --profile tools build tools +# 或: +# docker build -f deploy/docker/tools.Dockerfile -t order-site/tools:latest . +# +# 直接执行单个脚本(不需要 compose): +# docker run --rm --network order_site_default --env-file .env \ +# order-site/tools:latest npm run revenue:backfill -- --dryRun +# +# 说明:脚本只读写数据库,不写文件;镜像不挂载业务数据卷。 + +ARG BASE_REGISTRY=docker.m.daocloud.io + +FROM ${BASE_REGISTRY}/library/node:22-bookworm + +ARG DEBIAN_MIRROR=mirrors.aliyun.com +ARG NPM_REGISTRY=https://registry.npmmirror.com + +ENV DEBIAN_FRONTEND=noninteractive \ + NPM_CONFIG_REGISTRY=${NPM_REGISTRY} \ + TZ=Asia/Shanghai \ + NODE_ENV=development + +WORKDIR /app + +# 先装依赖再拷源码,命中 Docker 层缓存:只改脚本时不必重装 node_modules。 +COPY apps/backend/package.json apps/backend/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --include=dev + +COPY --chown=node:node apps/backend/ ./ + +# 脚本运行时会加载 backend 的 logger,它会把日志写到 $DATA_ROOT/logs。 +# 镜像以 node 用户运行,必须提前建好该目录并授权,否则每条日志都会刷 +# EACCES: permission denied, mkdir '/app/data'。 +RUN mkdir -p /app/data && chown -R node:node /app/data + +USER node + +# 不带命令运行时列出可用脚本,避免误用。 +CMD ["npm", "run"] diff --git a/deploy/post-deploy.sh b/deploy/post-deploy.sh new file mode 100755 index 00000000..55dd3010 --- /dev/null +++ b/deploy/post-deploy.sh @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# 财务上线「数据阶段」编排脚本 +# +# 位置:在 deploy/ubuntu-deploy.sh 成功之后执行(代码与迁移已生效、backend 已 ready)。 +# +# 它把以前散落在文档里的脚本步骤串成一条可重复执行的流水线: +# ① 存量订单项收入回填 revenue:backfill +# ② 履约方式回填 fulfillment:backfill +# ③ 财务历史同步(分窗口) finance:sync --mode=all +# ④ 上线验收(只读 SQL) scripts/verify-finance-launch.sql +# +# 设计原则: +# * 幂等:每一步都能重跑,中断后直接再跑一次即可; +# * 串行:回填与同步都会争 order_items 行锁,**不要并发**; +# * 只读验收:验收脚本不写业务表,随时可跑; +# * 不碰人工步骤:备用金/积分/商品目录/人工 SKU/总账同步仍需在后台完成,脚本末尾会打印清单。 +# +# 常用: +# bash deploy/post-deploy.sh --dry-run # 只打印计划与待回填量 +# bash deploy/post-deploy.sh --no-verify # 回填 + 同步,跳过验收(日常重跑) +# bash deploy/post-deploy.sh # 全流程(默认最近 90 天) +# bash deploy/post-deploy.sh --from=2026-07-01 --to=2026-10-27 +# bash deploy/post-deploy.sh --only=backfill # 只跑 ①② +# bash deploy/post-deploy.sh --only=sync # 只跑 ③ +# bash deploy/post-deploy.sh --only=verify # 只跑 ④ +# bash deploy/post-deploy.sh --no-build --no-verify +# +# 依赖:docker compose(或 docker-compose)已在项目根目录可用,且 .env 存在。 + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +TOOLS_IMAGE="${TOOLS_IMAGE:-order-site/tools:latest}" +TOOLS_DOCKERFILE="deploy/docker/tools.Dockerfile" +VERIFY_SQL="scripts/verify-finance-launch.sql" + +WINDOW_DAYS="${WINDOW_DAYS:-30}" +BATCH_SIZE="${BATCH_SIZE:-5000}" +MAX_BATCHES="${MAX_BATCHES:-1000}" +MAX_BACKFILL_ROUNDS="${MAX_BACKFILL_ROUNDS:-5}" + +DRY_RUN=false +SKIP_BACKFILL=false +SKIP_SYNC=false +NO_VERIFY=false +NO_BUILD=false +ONLY="" +FROM_DATE="" +TO_DATE="" +LAUNCH_AT="" + +log() { printf '\033[1;34m[post-deploy]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[post-deploy]\033[0m %s\n' "$*"; } +fail() { + printf '\033[1;31m[post-deploy]\033[0m %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +用法:bash deploy/post-deploy.sh [选项] + +选项: + --dry-run 只打印计划 + 只读统计,不写库、不构建镜像 + --from=YYYY-MM-DD 财务同步起始日(默认:今天往前 90 天) + --to=YYYY-MM-DD 财务同步结束日(默认:今天) + --window-days=N 同步窗口天数(默认 30;41 万单规模建议 7) + --batch-size=N 回填批大小(默认 5000) + --max-batches=N 单次回填最大批数(默认 1000) + --launch-at=TIMESTAMP 验收基准时间(默认:本次执行时间),用于「上线后」类检查 + --only=backfill|sync|verify 只跑指定阶段 + --skip-backfill 跳过 ①②(回填) + --skip-sync 跳过 ③(财务历史同步) + --no-verify 跳过 ④(验收) + --no-build 不构建/刷新工具镜像(镜像不存在时直接失败) + -h, --help 显示本帮助 + +环境变量: + POST_DEPLOY_COMPOSE_FILE 指定 compose 文件(默认 docker-compose.yml) + TOOLS_IMAGE 工具镜像名(默认 order-site/tools:latest) + WINDOW_DAYS / BATCH_SIZE / MAX_BATCHES 同上对应选项的默认值 + +首次上线顺序(避免商品未归一先同步产生大量异常): + ① bash deploy/post-deploy.sh --only=backfill + (结束后自动 VACUUM ANALYZE order_items/orders,约 30–60 秒,不阻塞读写) + ② 后台完成业务配置 1)–4)(商品目录必须先于财务同步) + ③ bash deploy/post-deploy.sh --only=sync + ④ 后台完成业务配置 5)(总账同步) + ⑤ bash deploy/post-deploy.sh --only=verify + +业务配置(后台 UI): + 1) 钱包管理 → 补建客服账户 → 拨付备用金 + 2) 商品与成本 → 积分钱包 → 发放三平台积分 + 3) 商品与成本 → 标准商品 → 模板初始化 / 按工单回填 / 归一人工商品 + 4) 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置 + 5) 财务 → 总账与对账 → 同步销售事实到总账 → 试算平衡 → 按需关账 +详见 docs/财务上线初始化与验收-2026-10-26.md + +退出码:0 = 完成且验收无 FAIL;1 = 执行失败;2 = 验收未通过。 +存量初始化完成后,日常重跑可直接 `bash deploy/post-deploy.sh`(回填 + 同步 + 验收一条龙)。 +EOF +} + +# ---------- 参数解析 ---------- + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --skip-backfill) SKIP_BACKFILL=true ;; + --skip-sync) SKIP_SYNC=true ;; + --no-verify) NO_VERIFY=true ;; + --no-build) NO_BUILD=true ;; + --from=*) FROM_DATE="${1#*=}" ;; + --to=*) TO_DATE="${1#*=}" ;; + --launch-at=*) LAUNCH_AT="${1#*=}" ;; + --window-days=*) WINDOW_DAYS="${1#*=}" ;; + --batch-size=*) BATCH_SIZE="${1#*=}" ;; + --max-batches=*) MAX_BATCHES="${1#*=}" ;; + --only=*) ONLY="${1#*=}" ;; + -h | --help) + usage + exit 0 + ;; + *) fail "未知参数:$1(--help 查看用法)" ;; + esac + shift +done + +case "$ONLY" in + '' | backfill | sync | verify) ;; + *) fail "--only 只能是 backfill / sync / verify(收到:${ONLY})" ;; +esac + +# 数值参数必须在进入循环前校验:WINDOW_DAYS<=0 会让分窗循环原地打转。 +validate_integer() { + local name="$1" value="$2" min="$3" max="$4" + [[ "$value" =~ ^[0-9]+$ ]] || fail "${name} 需要整数(收到:${value})" + local number=$((10#$value)) + if ((number < min || number > max)); then + fail "${name} 需要在 ${min}–${max} 之间(收到:${value})" + fi +} + +validate_integer "--window-days" "$WINDOW_DAYS" 1 400 +validate_integer "--batch-size" "$BATCH_SIZE" 1 50000 +validate_integer "--max-batches" "$MAX_BATCHES" 1 10000 +validate_integer "MAX_BACKFILL_ROUNDS" "$MAX_BACKFILL_ROUNDS" 1 100 + +should_run() { + [[ -z "$ONLY" || "$ONLY" == "$1" ]] +} + +# ---------- 日期工具(兼容 GNU date 与 macOS BSD date) ---------- + +if date -u -d '2026-01-01 + 1 day' +%F >/dev/null 2>&1; then + DATE_STYLE=gnu +else + DATE_STYLE=bsd +fi + +date_today() { date +%F; } + +date_add_days() { + local base="$1" days="$2" signed="$2" + if [[ "$days" == "0" ]]; then + printf '%s\n' "$base" + return 0 + fi + # BSD date 必须显式带符号(-v+30d / -v-90d),且 -v 必须写在 -f 之前。 + [[ "$days" != -* ]] && signed="+$days" + if [[ "$DATE_STYLE" == gnu ]]; then + date -d "$base $days days" +%F + else + date -j -v"${signed}d" -f '%F' "$base" +%F + fi +} + +date_days_ago() { date_add_days "$(date_today)" "-$1"; } + +is_iso_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; } + +# ---------- compose / docker 封装 ---------- + +COMPOSE_FILE_OVERRIDE="${POST_DEPLOY_COMPOSE_FILE:-}" + +if [[ -n "$COMPOSE_FILE_OVERRIDE" ]]; then + COMPOSE_CMD=(docker compose -f "$COMPOSE_FILE_OVERRIDE") +elif docker compose version >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD=(docker-compose) +else + fail "找不到 docker compose(或 docker-compose)" +fi + +compose() { "${COMPOSE_CMD[@]}" "$@"; } + +HAS_TOOLS_SERVICE=false +PROFILE_SUPPORTED=false +TOOLS_IMAGE_READY=false +NETWORK="" + +detect_capabilities() { + if compose --profile tools config --services 2>/dev/null | grep -qx 'tools'; then + HAS_TOOLS_SERVICE=true + fi + if compose --profile tools config >/dev/null 2>&1; then + PROFILE_SUPPORTED=true + fi +} + +detect_network() { + local cid + cid="$(compose ps -q postgres 2>/dev/null | head -1 || true)" + if [[ -n "$cid" ]]; then + NETWORK="$(docker inspect "$cid" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | head -1 || true)" + fi + if [[ -z "$NETWORK" ]]; then + NETWORK="$(basename "$ROOT_DIR" | tr '[:upper:]' '[:lower:]')_default" + fi +} + +env_database_url() { + local value="" + if [[ -f .env ]]; then + value="$(grep -E '^DATABASE_URL=' .env | head -1 | cut -d= -f2- || true)" + fi + if [[ -z "$value" ]]; then + value="postgres://postgres:postgres@postgres:5432/order_site" + fi + printf '%s' "$value" +} + +# ---------- psql 封装(全部在 postgres 容器内执行) ---------- + +psql_scalar() { + local sql="$1" + compose exec -T -e SQL_QUERY="$sql" postgres sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAX -v ON_ERROR_STOP=1 -c "$SQL_QUERY"' +} + +# ---------- 工具镜像 ---------- + +ensure_tools_image() { + # 全流程里 backfill 和 sync 各调用一次,同一次运行只构建一遍。 + [[ "$TOOLS_IMAGE_READY" == true ]] && return 0 + + local image_exists=false + if docker image inspect "$TOOLS_IMAGE" >/dev/null 2>&1; then + image_exists=true + fi + + if [[ "$DRY_RUN" == true ]]; then + [[ "$image_exists" == true ]] || warn "工具镜像 $TOOLS_IMAGE 不存在(dry-run 不构建);正式执行时会自动构建" + TOOLS_IMAGE_READY=true + return 0 + fi + + if [[ "$NO_BUILD" == true ]]; then + [[ "$image_exists" == true ]] || fail "工具镜像 $TOOLS_IMAGE 不存在,且指定了 --no-build" + log "沿用现有工具镜像 ${TOOLS_IMAGE}(--no-build)" + TOOLS_IMAGE_READY=true + return 0 + fi + + # 每次都重建:有层缓存时通常只要几秒,但能保证 git pull 后跑的是当前脚本, + # 而不是上次部署留在服务器上的旧 tools:latest。 + log "刷新工具镜像 ${TOOLS_IMAGE}(首次约 2–4 分钟,之后走层缓存)" + if [[ "$HAS_TOOLS_SERVICE" == true && "$PROFILE_SUPPORTED" == true ]]; then + compose --profile tools build tools + else + docker build -f "$TOOLS_DOCKERFILE" -t "$TOOLS_IMAGE" . + fi + TOOLS_IMAGE_READY=true +} + +run_tool() { + local script="$1" + shift + if [[ "$HAS_TOOLS_SERVICE" == true && "$PROFILE_SUPPORTED" == true ]]; then + compose --profile tools run --rm --no-deps -T tools npm run "$script" -- "$@" + else + docker run --rm \ + --network "$NETWORK" \ + --env-file .env \ + -e DATABASE_URL="$(env_database_url)" \ + -e DATABASE_STATEMENT_TIMEOUT_MS="${DATABASE_STATEMENT_TIMEOUT_MS:-60000}" \ + -e TZ="${TZ:-Asia/Shanghai}" \ + "$TOOLS_IMAGE" \ + npm run "$script" -- "$@" + fi +} + +# 回填脚本在「还有剩余」时返回 2,语义是“继续跑下一轮”,不是失败。 +run_backfill() { + local script="$1" + shift + local round=1 code=0 + while :; do + log " 第 ${round} 轮:npm run ${script} -- $*" + set +e + run_tool "$script" "$@" + code=$? + set -e + if [[ $code -eq 0 ]]; then + return 0 + fi + if [[ $code -eq 2 && $round -lt $MAX_BACKFILL_ROUNDS ]]; then + warn " 仍有剩余(退出码 2),继续下一轮" + round=$((round + 1)) + continue + fi + if [[ $code -eq 2 ]]; then + fail "${script} 跑满 ${MAX_BACKFILL_ROUNDS} 轮仍有剩余;调大 --max-batches 后重跑" + fi + fail "${script} 失败(退出码 ${code})" + done +} + +# ---------- 各阶段 ---------- + +PENDING_REVENUE="?" +PENDING_FULFILLMENT="?" + +preflight() { + log "预检" + [[ -f .env ]] || fail "项目根目录缺少 .env(生产由 ubuntu-deploy.sh 检查)" + [[ -f "$VERIFY_SQL" ]] || fail "缺少验收脚本 $VERIFY_SQL" + + local running + running="$(compose ps -q postgres 2>/dev/null | head -1 || true)" + [[ -n "$running" ]] || fail "postgres 容器未运行:先执行 bash deploy/ubuntu-deploy.sh" + + detect_capabilities + detect_network + + local migrations + migrations="$(psql_scalar "SELECT (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^103_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^104_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^105_'))::int" | tr -d '[:space:]')" + if [[ "$migrations" != "3" ]]; then + fail "迁移 103–105 未全部应用(当前 ${migrations}/3):先确认 deploy/ubuntu-deploy.sh 成功、backend 日志无迁移错误" + fi + + PENDING_REVENUE="$(psql_scalar "SELECT count(*) FROM order_items WHERE revenue_basis = 'unknown'" | tr -d '[:space:]')" + PENDING_FULFILLMENT="$(psql_scalar "SELECT count(*) FROM order_items WHERE fulfillment_mode IS NULL" | tr -d '[:space:]')" + + log " postgres 运行中 / 迁移 103–105 已应用" + log " 待回填收入项:${PENDING_REVENUE} 行;待回填履约方式:${PENDING_FULFILLMENT} 行" + log " 工具镜像:${TOOLS_IMAGE}(services=${HAS_TOOLS_SERVICE}, network=${NETWORK})" +} + +step_backfill() { + log "① 回填存量订单项收入(revenue:backfill)" + if [[ "$DRY_RUN" == true ]]; then + if docker image inspect "$TOOLS_IMAGE" >/dev/null 2>&1; then + run_tool revenue:backfill --dryRun || warn "dry-run 预览失败(不影响后续)" + else + log " [dry-run] 跳过执行(工具镜像未构建)" + fi + else + run_backfill revenue:backfill --batchSize="$BATCH_SIZE" --maxBatches="$MAX_BATCHES" + fi + + log "② 回填履约方式(fulfillment:backfill,只填 NULL)" + if [[ "$DRY_RUN" == true ]]; then + log " [dry-run] 跳过执行" + else + run_backfill fulfillment:backfill --batchSize="$BATCH_SIZE" --maxBatches="$MAX_BATCHES" + fi + + if [[ "$DRY_RUN" != true ]]; then + refresh_stats + fi +} + +# 回填会 UPDATE 40 万行 order_items:既产生大量死元组,又给新列 fulfillment_mode +# 留下空统计。autovacuum 追不上时,订单列表的 COUNT 会被估算成 orders 全表扫描, +# 冷缓存实测 20 秒级(测试库曾因此 500)。这里在回填后立刻补一次,VACUUM 只拿 +# SHARE UPDATE EXCLUSIVE,不阻塞读写。 +refresh_stats() { + log "②' 刷新统计信息与可见性(VACUUM ANALYZE order_items/orders)" + if ! compose exec -T postgres sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE) order_items" -c "VACUUM (ANALYZE) orders"'; then + warn "VACUUM ANALYZE 失败:统计信息未刷新,订单列表 COUNT 可能变慢;请手动补跑后再放开后台" + fi +} + +step_sync() { + local from="$FROM_DATE" to="$TO_DATE" win_to + local products + products="$(psql_scalar 'SELECT count(*) FROM product_catalog' | tr -d '[:space:]')" + if [[ "$products" == "0" ]]; then + warn "商品目录为空:finance:sync 会为未归一订单项生成大量 product_mapping_missing 异常;建议先完成商品初始化再跑 --only=sync" + fi + log "③ 财务历史同步(finance:sync --mode=all),区间 ${from} → ${to},每窗 ${WINDOW_DAYS} 天" + while :; do + win_to="$(date_add_days "$from" $((WINDOW_DAYS - 1)))" + if [[ "$win_to" > "$to" ]]; then + win_to="$to" + fi + if [[ "$DRY_RUN" == true ]]; then + log " [dry-run] npm run finance:sync -- --mode=all --from=${from} --to=${win_to}" + else + log " 窗口 ${from} → ${win_to}" + run_tool finance:sync --mode=all --from="$from" --to="$win_to" + fi + [[ "$win_to" == "$to" ]] && break + from="$(date_add_days "$win_to" 1)" + done +} + +step_verify() { + log "④ 上线验收(只读)" + local code=0 + set +e + compose exec -T -e LAUNCH_AT="$LAUNCH_AT" postgres sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v launch_at="$LAUNCH_AT" -f -' \ + <"$VERIFY_SQL" + code=$? + set -e + if [[ $code -ne 0 ]]; then + warn "验收未通过(psql 退出码 ${code}):按上面 FAIL 项的「处理建议」逐条处理后,重跑 --only=verify" + return 1 + fi + log "验收全部通过" +} + +manual_checklist() { + cat <<'EOF' + +──────────────────────────────────────────────────────────── +剩余人工步骤(后台 UI,脚本无法代替): + 1) 钱包管理 → 补建客服账户 → 拨付备用金 + 2) 商品与成本 → 积分钱包 → 发放三平台积分 + 3) 商品与成本 → 标准商品 →「从接单模板初始化」→「按工单回填商品」→「归一人工商品」 + 4) 商品与成本 → 人工 SKU 配置 →「扫描」并逐项配置 + 5) 财务 → 总账与对账 →「同步销售事实到总账」→ 试算平衡 → 按需关账 +首次上线顺序:--only=backfill → 完成 1)–4) → --only=sync → 完成 5) → --only=verify +手册:docs/财务上线初始化与验收-2026-10-26.md +──────────────────────────────────────────────────────────── +EOF +} + +# ---------- 主流程 ---------- + +main() { + if [[ -z "$FROM_DATE" ]]; then FROM_DATE="$(date_days_ago 90)"; fi + if [[ -z "$TO_DATE" ]]; then TO_DATE="$(date_today)"; fi + if [[ -z "$LAUNCH_AT" ]]; then LAUNCH_AT="$(date '+%Y-%m-%d %H:%M:%S%z')"; fi + + is_iso_date "$FROM_DATE" || fail "--from 需要 YYYY-MM-DD(收到:${FROM_DATE})" + is_iso_date "$TO_DATE" || fail "--to 需要 YYYY-MM-DD(收到:${TO_DATE})" + [[ "$FROM_DATE" < "$TO_DATE" || "$FROM_DATE" == "$TO_DATE" ]] || fail "--from 不能晚于 --to" + + log "计划:dry-run=${DRY_RUN} only=${ONLY:-all} from=${FROM_DATE} to=${TO_DATE} window=${WINDOW_DAYS}d launch-at=${LAUNCH_AT}" + + preflight + + if should_run backfill && [[ "$SKIP_BACKFILL" != true ]]; then + ensure_tools_image + step_backfill + fi + + if should_run sync && [[ "$SKIP_SYNC" != true ]]; then + ensure_tools_image + step_sync + fi + + local verify_code=0 + if should_run verify && [[ "$NO_VERIFY" != true ]]; then + step_verify || verify_code=1 + fi + + manual_checklist + + if [[ $verify_code -ne 0 && "$DRY_RUN" != true ]]; then + warn "数据阶段已完成,但验收未通过:按上面的 FAIL 项处理后重跑 bash deploy/post-deploy.sh --only=verify" + exit 2 + fi + log "完成" +} + +main "$@" diff --git a/deploy/restore-db.sh b/deploy/restore-db.sh index 110c20a0..98c55b01 100755 --- a/deploy/restore-db.sh +++ b/deploy/restore-db.sh @@ -199,6 +199,26 @@ restore_configs() { rm -rf -- "${staging}" } +# pg_restore 不会带 pg_statistic,恢复后 planner 会按默认选择率选计划 +# (41 万单实测会把订单列表 COUNT 走成 orders 全表扫描,冷缓存 20 秒以上)。 +# 同时 VACUUM 重建可见性映射,让 index-only scan 真正只读索引。 +refresh_stats() { + local database_url + database_url="$(read_env_value DATABASE_URL)" + log "刷新统计信息与可见性(VACUUM ANALYZE),恢复后首次查询才不会全表扫描" + if [[ -z "${database_url}" || "${database_url}" == *"@postgres:"* ]]; then + if ! docker compose exec -T postgres sh -c \ + 'psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-order_site}" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE)"'; then + warn "VACUUM ANALYZE 失败。恢复已完成,但请手动执行后再开放后台,否则列表查询可能很慢。" + fi + else + if ! docker compose exec -T -e DB_RESTORE_URL="${database_url}" postgres sh -c \ + 'psql "$DB_RESTORE_URL" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE)"'; then + warn "VACUUM ANALYZE 失败。恢复已完成,但请手动执行后再开放后台,否则列表查询可能很慢。" + fi + fi +} + restore_live_database() { local key="$1" target="$2" config_target="${3:-}" @@ -235,6 +255,8 @@ restore_live_database() { fail "迁移失败。请查看日志;数据已恢复到备份时点。" fi + refresh_stats + if [[ -n "${config_target}" ]]; then restore_configs "${config_target}" fi diff --git a/docker-compose.yml b/docker-compose.yml index fe7d4666..eff95463 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -141,6 +141,40 @@ services: volumes: - ./apps/backend/data:/app/data + # 财务运维工具容器:跑 scripts/ 下的回填/同步脚本。 + # + # 生产 backend 镜像 `npm prune --omit=dev` 且只 COPY dist,没有 tsx 与 scripts/, + # 所以回填脚本必须用这个镜像执行。默认不随 `up` 启动(profiles: tools)。 + # + # 用法: + # docker compose --profile tools run --rm --no-deps tools npm run revenue:backfill -- --dryRun + # 或者直接用编排脚本: + # bash deploy/post-deploy.sh --dry-run + tools: + profiles: ["tools"] + image: ${TOOLS_IMAGE:-order-site/tools:latest} + build: + context: . + dockerfile: deploy/docker/tools.Dockerfile + args: + BASE_REGISTRY: ${BASE_REGISTRY:-docker.m.daocloud.io} + DEBIAN_MIRROR: ${DEBIAN_MIRROR:-mirrors.aliyun.com} + NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} + restart: "no" + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + environment: + TZ: ${TZ:-Asia/Shanghai} + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site} + DATABASE_SSL: ${DATABASE_SSL:-false} + # 回填按批提交,单条语句可能比 Web 请求慢;这里比后端默认值放宽。 + DATABASE_STATEMENT_TIMEOUT_MS: ${DATABASE_STATEMENT_TIMEOUT_MS:-60000} + DATA_ROOT: /app/data + logging: *json-log-rotation + web: build: context: . diff --git a/docs/数据库备份与恢复.md b/docs/数据库备份与恢复.md index 49fe7a4c..01f21ed7 100644 --- a/docs/数据库备份与恢复.md +++ b/docs/数据库备份与恢复.md @@ -97,7 +97,13 @@ crontab -e bash deploy/restore-db.sh ``` -流程:下载最新备份并强制校验 sha256 → 停 backend/web 并确认已停止 → `pg_restore --clean --if-exists` → 执行迁移补差 → 重启并等待健康检查。任一步失败会尝试恢复原先运行的服务,并明确报告数据库可能处于部分恢复状态。执行前需交互输入 `RESTORE` 确认(非交互终端用 `RESTORE_CONFIRM=1` 或 `--yes`)。 +流程:下载最新备份并强制校验 sha256 → 停 backend/web 并确认已停止 → `pg_restore --clean --if-exists` → 执行迁移补差 → `VACUUM (ANALYZE)` 刷新统计信息与可见性 → 重启并等待健康检查。任一步失败会尝试恢复原先运行的服务,并明确报告数据库可能处于部分恢复状态。执行前需交互输入 `RESTORE` 确认(非交互终端用 `RESTORE_CONFIRM=1` 或 `--yes`)。 + +> 恢复后必须 `VACUUM (ANALYZE)`:dump 不带 `pg_statistic`,没有统计信息时订单列表的 `COUNT` 会被规划成 `orders` 全表扫描,41 万单冷缓存实测 14–27 秒(默认语句超时 15 秒会直接 500)。`restore-db.sh` 已自动执行;手工 `pg_restore` 请补跑: +> +> ```bash +> docker compose exec -T postgres psql -U postgres -d order_site -c 'VACUUM (ANALYZE)' +> ``` 如需同时恢复 `apps/backend/data` 配置: diff --git a/docs/财务上线初始化与验收-2026-10-26.md b/docs/财务上线初始化与验收-2026-10-26.md index d27ec657..55e17aa5 100644 --- a/docs/财务上线初始化与验收-2026-10-26.md +++ b/docs/财务上线初始化与验收-2026-10-26.md @@ -167,6 +167,15 @@ npm run fulfillment:backfill # 只填 NULL(可选,报表用) npm run finance:sync -- --mode=all --from=2026-07-10 --to=2026-10-22 ``` +> 回填完成后必须刷新统计信息,否则订单列表的分页总数会被规划成 `orders` 全表扫描: +> +> ```sql +> VACUUM (ANALYZE) order_items; +> VACUUM (ANALYZE) orders; +> ``` +> +> `bash deploy/post-deploy.sh --only=backfill` 已自动执行这一步;手工回填请补跑。 + 验证: ```sql @@ -174,6 +183,15 @@ SELECT COUNT(*) FROM order_items WHERE revenue_basis = 'unknown'; -- 退款上 ``` 长区间按 30 天窗口分段执行(默认 `DATABASE_STATEMENT_TIMEOUT_MS=15000`)。 + +> 41 万单规模的实测口径:30 天窗口的单条语句会超过 60 秒,被 statement timeout 打断。 +> 这种体量请用 **7 天窗口 + 180 秒超时**: +> +> ```bash +> DATABASE_STATEMENT_TIMEOUT_MS=180000 bash deploy/post-deploy.sh \ +> --only=sync --from=2026-09-27 --to=2026-10-27 --window-days=7 +> ``` + 注意:人工历史成本按既定口径**不补录**,历史人工毛利仍不可用。 --- diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md index b92f27c9..2ba095e1 100644 --- a/docs/部署启动与数据库重建.md +++ b/docs/部署启动与数据库重建.md @@ -219,19 +219,64 @@ docker compose ps docker compose logs -f backend ``` -## 财务/成本上线后的初始化(迁移 102–111) +## 财务/成本上线后的初始化(迁移 103–105) -代码与迁移只负责建表;**备用金账户、积分余额、人工 SKU 配置不会自动创建**。 -首次上线后按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 执行: +代码与迁移只负责建表;**存量订单回填、备用金账户、积分余额、商品目录、人工 SKU 不会自动创建**。 +部署成功后按「回填 → 商品初始化 → 财务同步 → 验收」四步走: + +### 1. 数据回填 + +```bash +bash deploy/post-deploy.sh --dry-run # 先看计划与待回填量 +bash deploy/post-deploy.sh --only=backfill # ① 收入回填 + ② 履约方式回填 +``` + +> 不要在商品目录为空时直接跑 `--only=sync`:财务同步会为每个未归一订单项写一条 +> `product_mapping_missing` 成本异常(生产规模 90 天实测约 36 万条),后台异常队列会被淹没。 + +脚本包含 `revenue:backfill`、`fulfillment:backfill`、`finance:sync` 与只读验收四段;任何一步都能重跑, +`--only=backfill|sync|verify` 可单独执行。 + +> 生产 backend 镜像没有 `tsx` 与 `scripts/`,脚本通过 `tools` 镜像执行 +> ([deploy/docker/tools.Dockerfile](../deploy/docker/tools.Dockerfile),`docker compose --profile tools`)。 + +### 2. 业务配置:后台 UI 1. 钱包管理 → 补建客服备用金账户 → 按需拨付; 2. 商品与成本 → 积分钱包 → 给客服发放三平台积分; -3. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项); -4. 打开「财务 → 总账与对账」,同步总账、检查试算平衡;按需关账/导入平台账单; -5. 用手册 §二 的验收 SQL 检查:配置覆盖、人工成本快照、`manual_cost_missing`、销售事实与总账对平。 +3. 商品与成本 → 标准商品 → 从接单模板初始化 → 按工单回填商品 → 归一人工商品; +4. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项)。 + +> 第 3 步的商品目录/工单回填必须先于下一节 `--only=sync`。商品目录为空时同步会为每个 +> 未归一订单项写一条 `product_mapping_missing` 异常(实测 90 天约 36 万条)。 + +### 3. 财务同步 + +商品归一完成后执行: + +```bash +bash deploy/post-deploy.sh --only=sync # ③ 成本快照 / 销售事实 +``` + +> 工具镜像每次运行都会重建(层缓存下只需几秒),确保 `git pull` 后执行的是当前脚本。 + +### 4. 总账与验收 + +5. 财务 → 总账与对账 → 同步销售事实到总账 → 检查试算平衡;按需关账/导入平台账单。 + +```bash +bash deploy/post-deploy.sh --only=verify # ④ 只读验收 +``` + +退出码:`0` = 完成且验收无 FAIL、`1` = 执行失败、`2` = 验收未通过。 +15 项检查全部 PASS 才算上线完成。若 15 号「近 7 天非人工销售事实成本覆盖」显示 WARN, +说明自动发货/接单平台仍有成本缺口,需要先归一商品并配置成本版本;WARN 不阻断验收, +但会直接影响毛利口径。 + +存量初始化完成后,日常重跑可直接 `bash deploy/post-deploy.sh`(回填 + 同步 + 验收一条龙)。 > 历史人工成本不补录;人工成本监控从第一次创建人工履约配置的时间开始。 -> 定时任务 `cost-finance-sync` 每 5 分钟运行,负责履约归集、人工 SKU 回填、成本快照与销售事实。 +> 定时任务 `cost-finance-sync` 每 5 分钟运行,但**只覆盖最近 7 天**;更早历史靠 `post-deploy.sh` 的分窗同步。 --- diff --git a/scripts/verify-finance-launch.sql b/scripts/verify-finance-launch.sql new file mode 100644 index 00000000..0e820e6d --- /dev/null +++ b/scripts/verify-finance-launch.sql @@ -0,0 +1,311 @@ +-- 财务上线验收(只读) +-- +-- 用途:部署 + 数据初始化完成后,一次跑完所有闸门,输出 PASS / FAIL 清单。 +-- 不写任何业务表;结果只落在会话级临时表 _launch_checks。 +-- +-- 用法: +-- # 1) 通过编排脚本(推荐,自动带上 launch_at) +-- bash deploy/post-deploy.sh --only=verify --launch-at='2026-10-27 18:00:00+08' +-- +-- # 2) 手工执行(变量必须传,否则用脚本内的兜底时间) +-- docker compose exec -T postgres sh -lc \ +-- 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ +-- -v ON_ERROR_STOP=1 -v launch_at="2026-10-27 18:00:00+08" -f -' \ +-- < scripts/verify-finance-launch.sql +-- +-- 退出码:0 = 无 FAIL(WARN 不阻断);3 = 存在 FAIL(末尾 RAISE EXCEPTION)。 +-- 前置条件:迁移 103–105 已应用;否则先跑 deploy/ubuntu-deploy.sh。 + +\set ON_ERROR_STOP on + +\if :{?launch_at} +\else + -- 与 post-deploy.sh 一致:默认取本次执行时间(容器 TZ=Asia/Shanghai)。 + \set launch_at `date '+%Y-%m-%d %H:%M:%S%z'` +\endif + +\echo '' +\echo '==================== 财务上线验收 ====================' +\echo '上线时间(launch_at): ' :launch_at +\echo '' + +-- 建表与 15 条检查的 INSERT 不输出到终端(错误仍会进 stderr)。 +\o /dev/null +SET client_min_messages = warning; +DROP TABLE IF EXISTS _launch_checks; + +CREATE TEMP TABLE _launch_checks ( + seq int, + id text, + title text, + actual text, + expected text, + status text, + hint text +); + +-- 01 迁移基线 -------------------------------------------------------------- +INSERT INTO _launch_checks +SELECT 1, '01', '迁移基线 103–105 已应用', + v::text, '3', + CASE WHEN v = 3 THEN 'PASS' ELSE 'FAIL' END, + '后端未完成迁移:docker compose logs backend | grep -i migration' +FROM ( + -- 按版本前缀逐个判断,避免后续 105_xxx_hotfix 之类增量迁移把计数顶成 4。 + SELECT (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^103_'))::int + + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^104_'))::int + + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^105_'))::int AS v +) s; + +-- 02 存量订单项收入回填 ---------------------------------------------------- +INSERT INTO _launch_checks +SELECT 2, '02', '存量订单项收入已回填(revenue_basis)', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '跑 npm run revenue:backfill(deploy/post-deploy.sh 会自动执行);未回填时退款按钮置灰' +FROM ( + SELECT count(*) AS v FROM order_items WHERE revenue_basis = 'unknown' +) s; + +-- 03 履约方式回填 ---------------------------------------------------------- +INSERT INTO _launch_checks +SELECT 3, '03', '履约方式已回填(fulfillment_mode)', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '跑 npm run fulfillment:backfill;NULL 会让订单/成本页口径缺失' +FROM ( + SELECT count(*) AS v FROM order_items WHERE fulfillment_mode IS NULL +) s; + +-- 04 履约方式取值合法 ------------------------------------------------------ +INSERT INTO _launch_checks +SELECT 4, '04', '履约方式取值合法', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '存在未知 fulfillment_mode,检查回填脚本与历史脏数据' +FROM ( + SELECT count(*) AS v + FROM order_items + WHERE fulfillment_mode IS NOT NULL + AND fulfillment_mode NOT IN ('work_order', 'manual', 'legacy_platform', 'affiliate_dash') +) s; + +-- 05 商品目录 -------------------------------------------------------------- +INSERT INTO _launch_checks +SELECT 5, '05', '商品目录已初始化(product_catalog)', + v::text, '> 0', + CASE WHEN v > 0 THEN 'PASS' ELSE 'FAIL' END, + '商品与成本 → 标准商品 →「从接单模板初始化」/「按工单回填商品」/「归一人工商品」' +FROM (SELECT count(*) AS v FROM product_catalog) s; + +-- 06 有效成本 -------------------------------------------------------------- +INSERT INTO _launch_checks +SELECT 6, '06', '有效成本已生成(版本定价或正金额快照)', + '版本 ' || priced_versions::text || ' 个 / 正金额快照 ' || positive_snapshots::text || ' 条', + '至少一项 > 0', + CASE WHEN priced_versions > 0 OR positive_snapshots > 0 THEN 'PASS' ELSE 'FAIL' END, + '迁移只建 0 元占位版本;工单奖励/实际成本会写入快照。两项都为 0 时:先配置成本版本,或确认工单奖励已回写' +FROM ( + SELECT + (SELECT count(*) FROM cost_versions + WHERE status = 'active' AND (amount_fen > 0 OR rate_bps > 0)) AS priced_versions, + (SELECT count(*) FROM cost_snapshots + WHERE superseded_at IS NULL AND total_amount_fen > 0) AS positive_snapshots +) s; + +-- 07 客服备用金账户 -------------------------------------------------------- +INSERT INTO _launch_checks +SELECT 7, '07', '客服备用金账户已补建', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '钱包管理 → 客服账户 →「补建客服账户」(POST /admin/fund/accounts/ensure-agents)' +FROM ( + SELECT count(*) AS v + FROM admin_users au + LEFT JOIN fund_accounts fa + ON fa.owner_admin_user_id = au.id + AND fa.account_type = 'agent' + WHERE au.role = 'support' + AND fa.id IS NULL +) s; + +-- 08 上线后人工项必须有履约配置 ------------------------------------------- +INSERT INTO _launch_checks +SELECT 8, '08', '上线后人工项已匹配履约配置', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '商品与成本 → 人工 SKU 配置 →「扫描」并逐项配置;保存后会自动回填近 30 天' +FROM ( + SELECT count(*) AS v + FROM order_items oi + WHERE oi.fulfillment_mode = 'manual' + AND oi.created_at >= :'launch_at'::timestamptz + AND oi.fulfillment_config_id IS NULL +) s; + +-- 09 上线后人工项必须有成本快照(关账门槛) ------------------------------- +INSERT INTO _launch_checks +SELECT 9, '09', '上线后人工项均已确认履约(关账门槛)', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '待办量:有未确认人工单就会 FAIL,不宜当日常健康检查;订单列表/详情 →「确认履约」后写 manual_case 快照并扣备用金/积分' +FROM ( + SELECT count(*) AS v + FROM order_items oi + WHERE oi.fulfillment_mode = 'manual' + AND oi.created_at >= :'launch_at'::timestamptz + AND NOT EXISTS ( + SELECT 1 FROM cost_snapshots cs + WHERE cs.subject_type = 'manual_case' + AND cs.order_item_id = oi.id + AND cs.superseded_at IS NULL + ) +) s; + +-- 10 人工快照唯一性 -------------------------------------------------------- +INSERT INTO _launch_checks +SELECT 10, '10', '一个订单项最多一条有效人工快照', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '出现重复快照说明并发确认未被幂等拦截,需排查 manual_case 写入路径' +FROM ( + SELECT count(*) AS v + FROM ( + SELECT order_item_id + FROM cost_snapshots + WHERE subject_type = 'manual_case' AND superseded_at IS NULL + GROUP BY 1 + HAVING count(*) > 1 + ) dup +) s; + +-- 11 快照金额 = 备用金支出(现金路径) ------------------------------------ +INSERT INTO _launch_checks +SELECT 11, '11', '人工快照金额与备用金支出一致', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '金额不一致通常是确认事务部分失败;查 cost_snapshots / manual_expenses / fund_entries 三处' +FROM ( + SELECT count(*) AS v + FROM cost_snapshots cs + -- 只核对现金路径:metadata.fundExpenseId 精确定位备用金支出, + -- 避免同一订单项有多条 manual_expenses(如另外登记的人工支出)时行放大误报。 + JOIN manual_expenses me + ON me.id = NULLIF(cs.metadata_json->>'fundExpenseId', '')::bigint + WHERE cs.subject_type = 'manual_case' + AND cs.superseded_at IS NULL + AND cs.created_at >= :'launch_at'::timestamptz + AND cs.total_amount_fen <> me.amount_fen +) s; + +-- 12 总账借贷平衡 ---------------------------------------------------------- +INSERT INTO _launch_checks +SELECT 12, '12', '总账借贷平衡(全部已过账凭证)', + v::text || '(已过账凭证 ' || posted::text || ' 张)', '借贷为 0 且至少有 1 张凭证', + CASE WHEN v = 0 AND (posted > 0 OR facts = 0) THEN 'PASS' ELSE 'FAIL' END, + '财务 → 总账与对账 →「同步销售事实到总账」;有销售事实但 0 张凭证同样视为未完成(空表求和恒为 0,不能直接判 PASS)' +FROM ( + SELECT + COALESCE(abs(sum(l.debit_fen) - sum(l.credit_fen)), 0)::bigint AS v, + count(DISTINCT e.id) AS posted, + (SELECT count(*) FROM sales_daily_facts) AS facts + FROM finance_journal_lines l + JOIN finance_journal_entries e ON e.id = l.entry_id AND e.status = 'posted' +) s; + +-- 13 已关账期间不应再产生新凭证 ------------------------------------------- +INSERT INTO _launch_checks +SELECT 13, '13', '已关账期间无新增凭证', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, + '检查关账后是否有人重跑同步;以 closed_at 为基准,正常关账前生成的凭证不算违规' +FROM ( + SELECT count(*) AS v + FROM finance_journal_entries e + JOIN finance_periods p + ON p.period_key = e.period_key + AND p.status = 'closed' + WHERE p.closed_at IS NOT NULL + AND e.created_at > p.closed_at +) s; + +-- 14 销售事实已生成 -------------------------------------------------------- +INSERT INTO _launch_checks +SELECT 14, '14', '销售事实已生成(sales_daily_facts)', + v::text, '> 0', + CASE WHEN v > 0 THEN 'PASS' ELSE 'FAIL' END, + '跑 finance:sync --mode=facts(post-deploy.sh 会按窗口执行);定时任务只覆盖最近 7 天' +FROM (SELECT count(*) AS v FROM sales_daily_facts) s; + +-- 15 近 7 天成本覆盖(仅提示,不阻断上线) --------------------------------- +-- 说明:历史人工成本按既定口径不补录,这里只看非人工;自动发货/接单平台 +-- 的成本缺口会直接影响毛利,必须在上线前处理,但不用它单独卡住验收。 +INSERT INTO _launch_checks +SELECT 15, '15', '近 7 天非人工销售事实成本覆盖(仅提示)', + v::text, '0', + CASE WHEN v = 0 THEN 'PASS' ELSE 'WARN' END, + '存在成本缺失的非人工销售事实:先归一商品并配置成本版本;历史人工成本按既定口径不补录,故不计入' +FROM ( + SELECT count(*) AS v + FROM sales_daily_facts + WHERE stat_date >= current_date - 6 + AND fulfillment_mode <> 'manual' + AND cost_missing_count > 0 +) s; + +\o +\echo '【闸门清单】' +SELECT seq AS "#", + status, + id, + title AS "检查项", + actual AS "实际", + expected AS "期望" +FROM _launch_checks +ORDER BY seq; + +\echo '' +\echo '【未通过 / 提示项的处理建议】' +SELECT id, + status AS "状态", + title AS "检查项", + hint AS "处理建议" +FROM _launch_checks +WHERE status <> 'PASS' +ORDER BY seq; + +\echo '' +\echo '【汇总】' +SELECT count(*) FILTER (WHERE status = 'PASS') AS "通过", + count(*) FILTER (WHERE status = 'FAIL') AS "未通过", + count(*) FILTER (WHERE status = 'WARN') AS "提示", + count(*) AS "合计" +FROM _launch_checks; + +\echo '' +\echo '【数据规模参考】' +SELECT (SELECT count(*) FROM orders) AS "订单", + (SELECT count(*) FROM order_items) AS "订单项", + (SELECT count(*) FROM product_catalog) AS "商品", + (SELECT count(*) FROM cost_snapshots WHERE superseded_at IS NULL) AS "有效成本快照", + (SELECT count(*) FROM sales_daily_facts) AS "销售事实行", + (SELECT count(*) FROM finance_journal_entries) AS "总账凭证", + (SELECT count(*) FROM cost_exceptions WHERE status = 'open') AS "未处理成本异常", + (SELECT count(*) FROM order_items WHERE revenue_basis = 'unknown') AS "待回填收入项"; + +DO $$ +DECLARE + failed_count int; + failed_list text; +BEGIN + SELECT count(*), string_agg(id || ' ' || title, ';' ORDER BY seq) + INTO failed_count, failed_list + FROM _launch_checks + WHERE status = 'FAIL'; + + IF failed_count > 0 THEN + RAISE EXCEPTION '财务上线验收未通过(% 项):%', failed_count, failed_list; + END IF; + + RAISE NOTICE '财务上线验收全部通过'; +END $$;