门店月结 · 结算系统
> 【使用前必读】请先在 SkillHub 下载并安装本技能,装完才能在对话里触发:
> 搜索「门店月结」点「安装」,或执行 skillhub install settlement-system-dev
给招商合伙(总部—门店—合伙人)模式做月度结算系统的一套工程方法:公式怎么写对、怎么验对、怎么让表能跨月对比。
适用什么场景
- 要新建或改造「总部—门店—合伙人」三向的月度费用结算系统
- 多主体(门店 / 工厂 / 供应商 / 骑手)的周期性结算,费用项 + 扣减项混合
- 需要导出 Excel 对账表,且要跨月对比
- 纯本地运行、浏览器内操作、数据落本地文件
- 遇到过「某个门店算不出数」「加了个开关历史数据全变 0」「总额和明细不平」
不适用:实时计费、分布式账务、需要金融级审计的场景。
解决什么问题
- 某个门店算不出数:「默认值 vs 覆盖值」的优先级没显式建模,散落在各处的 if 里
- 加了个开关,历史数据全变 0:布尔开关当成两态处理,老数据没这个字段就被判成「关」
- 总额和明细对不上:同一套公式前端写一遍后端写一遍,迟早不一致
- 改完不敢上线:没有回归校验,只能靠肉眼核对几十家店
- 服务莫名消失、页面打不开:本地 Web 应用的服务常驻没做对
这些坑不解决,系统每个月都要出事一次。
优势点
| 对比项 | 想到哪写到哪 | 用本方法 |
|---|---|---|
| 业务公式 | 前后端各写一遍 | 单点计算源,只在一处实现 |
| 参数来源 | 散落的 if | 三级优先级显式建模(主体配置 > 行内值 > 默认值) |
| 布尔开关 | 两态,老数据被判成关 | 三态,只有显式 false 才关闭 |
| 导出表 | 每改一次列就错位 | 表头固定不变,跨月可对比 |
| 上线验证 | 肉眼核对 | 65 项断言一键跑 |
| 服务常驻 | 命令行窗口一关就没了 | 计划任务 + 看门狗 + 单实例守卫 |
独有的一条:把「一处改动六处联动」列成清单(存取 / 公式 / UI / 导出 / 账单文字 / 测试),加字段时照着对,不用靠记忆。
怎么调用 / 触发
第一步:先拿到它
在 SkillHub 搜「门店月结」,点「安装」;或命令行:
skillhub install settlement-system-dev
装完常驻在你的技能目录,以后在对话里说话就能触发,不用每次重装。
第二步:在对话里直接说
- 「门店结算的应补公式怎么设计」
- 「加了个费用开关,历史数据全变 0 了」
- 「结算总额和明细对不上」
- 「本地网页的服务老是自动消失」
- 「账单能不能自动发到微信」
AI 会加载本技能,先给五条设计原则,再给架构骨架与关键代码模式,最后附回归校验脚本与故障速查。
第三步:装了和没装差在哪
不装,AI 也能给你搭个结算页面,但你这套系统里已经踩过的坑它一个都预判不到——加费用开关时没同步「存取 / 公式 / UI / 导出 / 账单文字 / 测试」这六处,历史数据会集体变 0;本地服务会莫名消失;账单发出去才发现金额是旧的。
装了,这些坑都变成了可查的清单和可跑的脚本:改一个字段照着六处联动清单对;上线前跑回归校验;结算完自动备份并导出两份账单文档。
边界
- 只提供工程实现方法,不含财务口径(抽多少、封不封顶、扣哪些项由你的业务规则决定)
- 面向本地单机 Web 应用(Python 标准库 HTTP 服务 + 单文件前端),不是分布式架构
- 回归校验脚本需按自己的数据结构改造后使用
- 不涉及税务、开票、审计留痕等合规场景
实施细节(给 AI 与开发者)
核心原则(这五条比任何细节都重要)
1. 计算逻辑单点源
整个系统只能有一处实现业务公式。 前端只做展示和录入,不重复计算。
❌ 前端算一遍应补,后端也算一遍 → 迟早不一致
✅ 后端算完,前端只渲染结果
落地做法:一个 settle(period) 函数,输入期间,返回全量结果。所有 API、导出、报表都读它。
2. 字段优先级必须显式建模
结算系统的本质是同一个参数有多个来源。必须把优先级写成显式代码,不能靠散落的 if。
标准三级:主体级配置 > 数据源行内值 > 全局默认值
def pick(entity_cfg, key, global_val):
"""主体级配置优先;未设置(None/空)回退全局默认。"""
if entity_cfg is not None and entity_cfg.get(key) not in (None, ""):
return float(entity_cfg[key])
return global_val
为什么不能用 or:or 会把 0 当成"未设置"。而抽成比例恰好可以是 0(免抽成主体),费用封顶 0 表示"不封顶"。用 or 会让这些语义全部错乱。
每个字段的优先级链都要写进文档,并单独出测试用例。这是这类系统最高频的 bug 来源。
3. 布尔开关是三态,不是两态
「某主体是否启用某费用项」这类字段,必须区分三种状态:
| 状态 | 含义 |
|---|---|
| 字段不存在(老数据) | 视为启用,走正常计算 |
false(显式关闭) |
不计算 |
true(显式开启) |
计算 |
# ✅ 正确:只有显式 false 才关闭
enabled = cfg.get("fee_enabled") is not False
# ❌ 错误:老数据没这个字段 → 全部变成 0,账单看着"正常"但全错
enabled = cfg.get("fee_enabled") == True
⚠️ 这类 bug 极其隐蔽 —— 结果是 0 而不是报错,很难被发现。加任何布尔配置项都必须做三态测试。
4. 导出表头固定不变
新增列一律追加到末尾,绝不插入中间。 用户要跨期对比数据,插入会让所有历史对比失效。
配套规范:
- 金额列:右对齐 + #,##0.00
- 比例列:0.00%
- 表头行冻结
- 合计行样式统一
- 正负金额分色(应补/应退方向不同)
5. 改完必须跑回归校验,不能靠肉眼
在写业务逻辑的同一天,就写下校验脚本。 不要等出问题再补 —— 那时候你已经不记得当初为什么这么算了。
校验脚本必须 import 真实的业务模块,而不是复制一份逻辑。复制品会跟着代码一起漂移,失去意义。
架构骨架
项目根目录/
├── server.py # 后端(标准库 http.server + openpyxl)
├── index.html # 前端单文件(含 CSS/JS)
└── webdata/ # 运行时数据,纯 JSON
├── base_info.json # 主体配置与全局默认值
├── imports/<期>/ # 导入的原始数据
├── settlements/<期>/ # 结算结果快照
├── adjustments/<期>/ # 手工调整项
└── backup_config.json
为什么用 JSON 而不是数据库:单机单用户场景,JSON 便于直接查看、直接备份、零依赖、可用文本编辑器修复。可迁移。
为什么后端只用标准库:http.server + openpyxl 就够了。少一个依赖就少一处版本冲突和打包问题。需要打包成 exe 时(PyInstaller)尤其明显。
数据导入
列名别名映射
上游数据源的表头不可能每次都一致,必须支持别名:
COLUMN_ALIASES = {
"entity_name": ["主体名称", "主体", "名称"],
"amount": ["结算金额", "实付金额", "金额"],
"rate": ["抽成比例", "抽成", "比例"],
}
REQUIRED = ["entity_name", "amount"] # 必需列
缺必需列直接抛错并提示需要哪几列,别静默跳过。
同义表头兼容的两条实战经验(减少人工改名):
- 先归一化再匹配:表头常带单位后缀,如「有效实付金额(元)」「订单量(个)」。匹配前用
_norm_header()剥掉首尾空白和结尾的括号单位((元)/(个),括号内任意内容),否则别名要对上带单位的写法。 - 精确优先 + 子串兜底:先用精确/包含匹配,再用「别名长度 ≥ 3 字且为表头子串」兜底。这样「订单量」能匹配「月订单量」「有效订单数」,「有效实付金额」能匹配「月度实付金额」——含义一致即可,不必强求文字完全相同。
python
def _norm_header(h):
h = h.strip()
# 去掉结尾括号单位,如「有效实付金额(元)」→「有效实付金额」
while h and h[-1] in "))" and (("(" in h) or ("(" in h)):
h = h[: max(h.rfind("("), h.rfind("("))]
return h.strip()
金额解析必须容错
上游表格里的"无数值"占位符五花八门。裸 float() 会让整个导入崩掉。
NON_NUMERIC = {"", "-", "--", "---", "—", "–", "-", "/", "\\",
"n/a", "na", "无", "none", "null", "nan", "nil", "不适用"}
def parse_amount(v):
"""返回 (数值, 是否可识别)。占位符视为合法的 0。"""
if v is None: return 0.0, True
if isinstance(v, bool): return float(v), True
if isinstance(v, (int, float)): return float(v), True
s = str(v).strip()
if s.lower() in NON_NUMERIC: return 0.0, True
try:
return float(_clean(s)), True
except ValueError:
return 0.0, False # 无法识别 → 记 warning
关键:返回二元组,把"合法为 0"和"无法识别"区分开。两者都按 0 参与计算,但后者要产生告警,否则数据错误会被静默吞掉。
清洗时记得剥掉:¥ ¥ , % 元 空格 \u00a0(不换行空格)。
比例归一
0.05 和 5 都应理解为 5%:
def normalize_rate(v):
r = to_num(v)
return r / 100.0 if r > 1 else r
配置是白名单
只在配置里维护的主体才参与结算。
- 数据源有、配置没有 → 自动建档(带
_incomplete=True待维护标记,下一轮即参与计算),按归一化名去重不重复建店。这是"漏算"与"静默跳过"之间的折中:既不丢数据,也能在基础信息页显式审查未完善门店(详见「门店自动建档与防重复」) - 配置有、数据源没有 → 列入
no_data提醒
这个方向很重要:默认"进来就算"会让一次误导入污染全月总额。默认"必须登记"更安全——但为不漏算,系统在导入 / 保存 / 结算三处自动建档并标记待维护,而不是静默跳过。
用户抱怨"有的主体没算"时,先查这个,不要先去查公式。
跨主体扣减的设计(容易出错)
场景:主体 A 产生的扣减,可能要扣到主体 B(抵 A 的账动 B 的数)。
必须保证扣减守恒:所有主体的扣减总额 == 所有主体产生的扣减总额。 一分不能多,一分不能少。
三步走:
# 1. 解析目标,目标无效时回退到主体自身(绝不丢扣减)+ warning
target = {}
for e in entities:
tgt = cfg.get(e).get("deduct_target") or ""
if tgt != e and tgt not in valid_names:
warnings.append(...)
tgt = e
target[e] = tgt
# 2. 汇总:每个目标累计收到多少
from_others = {}
for e in entities:
t = target[e]
if t != e:
from_others[t] = from_others.get(t, 0.0) + generated[e]
# 3. 统一计算
self_deduct = generated[e] if target[e] == e else 0.0
others = from_others.get(e, 0.0)
deducted_total = round(self_deduct + others, 2)
"指定扣到别人"和"代扣别人"是两件事,别混:
- A 指定扣 B → A 的自扣 = 0,B 的「代扣他方」增加 A 的份额
- B 自己的扣减,仍然扣 B 自己
把守恒断言写进测试:
check("扣减守恒", sum(e.deducted_total for e in all), sum(e.generated for e in all))
调整项(手工补贴/减免)
这类字段需要考虑一处改动,六处联动,漏一处就前后对不上:
- [ ] 数据存取层(
<期>.json的 load/save) - [ ] 结算函数里读取并计入公式
- [ ] 前端录入 UI
- [ ] 导出表加列(金额 + 备注两列)
- [ ] 对账说明文字里拼接进去 ← 最容易漏
- [ ] 回归校验加用例
为什么"说明文字"最容易被漏:它不影响计算,但直接发给对账方。缺了会让对方看不懂这笔钱怎么来的。
对账说明用自然语言动态拼接,把所有特殊处理都讲清楚:
{期}月结算:各项费用合计与已扣金额存在差额,需补款;差额 +1,234.00 元(需补款);
抽成已封顶;已扣减本店分佣 500.00 元;另按设置代扣他店分佣 200.00 元:某某主体(300.00 元);
专项补贴 -300.00 元(备注:装修补贴)
封顶的默认推导(可编辑定值)
抽点封顶、无人值守封顶这类"派生值"不要做成只读/置灰,否则用户没法针对某店特例微调;也不要每次都让用户手填两个关联值。
默认推导关系(非默认时只需维护一个上游输入):
| 封顶项 | 默认推导 | 触发输入 |
|---|---|---|
抽点封顶 kickback_cap |
60000 × 抽点比例 |
改抽点比例 rate 时自动算 |
无人值守封顶 unattended_cap |
无人值守单价 × 400 |
改单价 unattended_unit_price 时自动算 |
落地:改比例/单价的 onchange 里顺手算出封顶默认值填入输入框;但封顶框必须可编辑——用户手动输入的值直接落盘,不强制重算。这样"默认给一个合理值、特例可覆盖"两全。
⚠️ 不要在读取/初始化阶段静默归一化档案:早期在 loadState 里把缺省字段回填成派生值,会导致历史数据被悄悄改写、对账数字漂移且难以察觉。派生只在"用户改了上游输入"时发生,档案里缺省就保持 null,计算时走全局默认。
代采费阶梯与优先级链
代采费是"按门店实付规模计费"的费用项,核心是阶梯 + 显式优先级链。
阶梯按"最终实付金额"分档(最终实付 = 月度实付金额 − 扣除金额,与抽点基数一致)。默认阶梯:
| 最终实付金额 | 代采费 |
|---|---|
| < 2 万 | 100 元/月 |
| < 3 万 | 200 元/月 |
| < 4 万 | 300 元/月 |
| < 5 万 | 400 元/月 |
| ≥ 5 万(兜底档) | 500 元/月 |
- 计费方式
purchase_fee_mode:tiered(走阶梯,推荐)/fixed(用全局固定default_purchase_fee)。 - 阶梯结构
purchase_fee_tiers:每档{lt: 上限(不含), fee},按上限升序;最后一档lt=null为兜底,强制排最后。 - 金额 ≤ 0(没做生意 / 被扣完)不收代采费——避免给零营业门店记 100 元。
clean_tiers()会丢弃非法档(fee 负数 / lt≤0),保证阶梯永远合法。
单店代采费的取值优先级链(这条能看出系统设计的核心思路):
门店未开通(purchase_fee_enabled=false) → 0,不收
门店固定金额(purchase_fee 非空) → 用门店值
全局阶梯(tiered + 有档位) → 按最终实付查阶梯
全局固定(default_purchase_fee>0) → 用全局值
数据源「代采费金额」列 → 最后兜底
这条链把"门店个性覆盖"和"全局规则"显式排好序,正是 [核心原则 #2 字段优先级必须显式建模] 的落地。门店行内留空即回退上级——改完自动落盘,不用每次重填。
门店自动建档与防重复
"数据源出现、但基础信息没维护的门店"如果不处理,要么被静默跳过(漏算),要么重复建店(数据算丢)。解法:
- 三处自动建档:数据导入、手工保存记录、结算前,都会调用
ensure_stores_from_records()把新门店建上。 - 新建门店带
_incomplete=True:基础信息页高亮"待维护",提醒补全;不覆盖任何已有门店配置。 - 归一化名防重复:
_norm_store_name()去掉「·」「・」「•」、空格、全角括号转半角;例如「甲店·朝阳路」与「甲店朝阳路」归一化后视为同一家,既不会重复建店,结算时也不会把同一家的数据算丢。store_alias_map()用归一化名把数据源店名映射到已维护店名。 - 结算结果里把所有
_incomplete门店单列提醒,逼着有人去补全,而不是一直吃全局默认值。
⚠️ 这和"配置是白名单"原则不冲突:白名单防的是"误导入污染全月",自动建档防的是"漏算"。二者结合 = 默认登记、但显式标记待维护。
自动备份
用户数据在本地,备份是硬需求,不是附加功能。
目录选择原则
至少两处,且性质不同:
- 云盘目录(OneDrive / Dropbox / Nutstore / WPS Cloud Files…)→ 防硬盘损坏
- 非系统盘(D:/E:/F:…)→ 防系统重装
def guess_backup_dirs():
dirs = []
for name in ("OneDrive", "Dropbox", "Nutstore", "WPS Cloud Files"):
p = os.path.join(os.path.expanduser("~"), name)
if os.path.isdir(p):
dirs.append(os.path.join(p, APP_NAME + "备份"))
for drive in "DEFGH":
if os.path.isdir(drive + ":\\"):
dirs.append(drive + ":\\" + APP_NAME + "备份"); break
return dirs
打包内容
程序代码 + 整个数据目录。命名 backup_YYYYMMDD_HHMMSS.zip。
轮转
每个目录保留最近 N 份(默认 30),超出自动删最旧的。避免磁盘写满。
触发时机
- 定时线程(默认 24 小时)
- 每次结算/保存后自动补一份 ← 这个最重要,操作前的状态一定要能恢复
文件多了按日期建文件夹
用户习惯:输出文件超过两个就按日期建文件夹。这条要贯彻到导出目录结构里。
汇报要可见
保存/结算后,明确汇报四件事:备份是否成功(含目录和大小)、文档是否导出、用什么打开了、是否同步云盘。
不要只给一句"已更新"。用户需要确认到底做没做成 —— 尤其涉及数据安全时。
def doc_sync_report(rt):
"""把备份、导出、打开、云盘同步四件事汇总成可读反馈。"""
打包成 exe
用 PyInstaller 时,可写目录不等于资源目录:
def app_dir():
if getattr(sys, "frozen", False):
return os.path.dirname(os.path.abspath(sys.executable)) # exe 所在目录,可写
return os.path.dirname(os.path.abspath(__file__))
def res_dir():
return getattr(sys, "_MEIPASS", app_dir()) # 打包后是只读资源目录
- 数据放
app_dir():用户要能改能备份 - 只读资源(如内置首页)放
res_dir(),并优先用外部同名文件覆盖,方便随时改样式而不重新打包
轻量鉴权
单机场景加个口令足够,不必引入完整用户体系。
- 口令:
sha256(salt + password),salt 存配置 - Token:
base64(username|exp|hmac_sha256),HMAC 密钥单独存 - 有效期 72 小时
- 登录失败按 IP 计数,防暴力破解
enabled: false时完全跳过校验(默认开发态关闭)
不要在这里存明文口令。 配置文件是会被备份、被复制、被同步到云盘的。
前端约定
- 表头冻结自动化:用一个
MutationObserver把所有<table>自动包进可滚动容器,新表格无需特殊处理 - 长表头固定 + 滚动位置保留(门店维护表高频坑):维护门店信息时,每次 onchange 触发整表重渲染会把滚动条重置回顶部,填一个字段就被甩回表头,极影响体验。解法:表格外层套
.tbl-wrap{overflow:auto;max-height:70vh},表头thead th{position:sticky;top:0};渲染前记录_tw.scrollTop,渲染后还原,避免每次重滑。
javascript
const _tw = document.querySelector("#store-table .tbl-wrap");
const _savedScroll = _tw ? _tw.scrollTop : 0;
// ... 重渲染 innerHTML ...
const _tw2 = document.querySelector("#store-table .tbl-wrap");
if (_tw2) _tw2.scrollTop = _savedScroll;
- API 返回 JSON,401 统一表示登录过期 → 跳登录页
- 技术报错翻译成中文可操作提示:
function friendlyErr(e) {
const m = String(e?.message || e || "");
if (/Failed to fetch|NetworkError/i.test(m))
return "连不上服务 —— 程序可能已停止运行,或仍在运行旧版本代码。"
+ "请先 Ctrl+F5 刷新重试;若仍不行,请关闭程序窗口后重新启动。";
return m || "未知错误";
}
用户不懂技术,报 Failed to fetch 给他毫无意义,要告诉他下一步做什么。
回归校验脚本
scripts/verify_settle.py 是模板,直接复制改造。
它 import 真实业务模块,用构造数据覆盖关键规则:
| 用例组 | 验什么 |
|---|---|
| 基础公式 | 公式整体正确 |
| 封顶生效 | 超限被截断、标记置位 |
| 封顶 = 0 | 不触发封顶 |
| 费用项正常 | 单价 × 数量 |
| 费用项关闭 | 不计算、不影响其他项 |
| 布尔三态 | 缺字段视为启用 |
| 三级回退 | 主体级 > 行内 > 全局 |
| 跨主体扣减 | 目标指定、代扣他方 |
| 扣减守恒 | 总额不丢不重 |
| 目标无效 | 回退自身 + 告警 |
| 调整项 | 冲抵金额、备注透传 |
| 白名单 | 未登记主体不参与 |
| 多行合并 | 同主体多行求和后计算 |
| 解析容错 | 占位符、¥、千分位、百分号 |
| 比例归一 | 0.05 与 5 等价 |
| 表头别名 | 多别名识别、缺列报错 |
运行:
python scripts/verify_settle.py
# 或指定被测模块路径
TARGET_MODULE=/path/to/server.py python scripts/verify_settle.py
脚本用临时沙盒跑,不碰真实数据:
sandbox = tempfile.mkdtemp()
orig = mod.BASE, mod.WEBDATA # 备份真实路径
try:
mod.BASE = sandbox # 指向沙盒
... # 跑结算
finally:
mod.BASE, mod.WEBDATA = orig # 务必还原
shutil.rmtree(sandbox, ignore_errors=True)
这套断言是"改动没有破坏既有规则"的保险。 维护它比反复手算便宜得多。
开发规矩
- 先读再改。结算代码动辄上千行,凭记忆改必错。
- 改前备份。至少复制数据目录。
- 公式只改一处,后端。前端不碰。
- 加任何布尔配置项,必须写三态测试。
- 加任何跨主体逻辑,必须写守恒断言。
- 表头固定,新列追加末尾。
- 改完跑校验 + 实际启动跑一遍,别只看代码就宣布完成。
- 逻辑复杂就加告警,别让用户猜。
- 改任何前端文件后必须重启服务(见「服务怎么常驻」)—— 程序只在启动时读一次代码。
服务怎么常驻(本地 Web 应用的第一大坑)
本地 Web 应用的"打不开",八成不是崩溃,是进程被回收了。
症状与病因
| 症状 | 病因 |
|---|---|
| 命令行窗口在,但网页打不开 | 只监听了 127.0.0.1,而浏览器把 localhost 解析到 ::1 |
| 服务莫名消失,进程列表里一个都没有 | 进程挂在某个会话树下,会话结束被整棵树回收 |
| 双击 exe 好几次,最后全崩了 | 没有单实例守卫,多实例抢同一端口 |
逐条解法
① 必须双栈监听。
def make_servers(port):
servers = []
for host in ("127.0.0.1", "::1"): # IPv4 + IPv6
try:
servers.append(ThreadingHTTPServer((host, port), Handler))
except OSError:
pass
return servers
并且自动打开浏览器一律用 http://127.0.0.1:<port>,绝不用 localhost。
② 单实例守卫要做两道。
第一道:探测端点。注意别只读固定字节数 —— 响应体一长,关键字段可能排在几千字节之后,read(6000) 之类的写法会让守卫永远失效:
def already_running(port):
try: # 用一个极小的专用端点,别复用大接口
with urllib.request.urlopen("http://127.0.0.1:%d/api/ping" % port, timeout=2) as r:
body = r.read() # 整段读,不要 read(N)
return '"app"' in body.decode("utf-8", "replace")
except Exception:
return False
第二道:文件锁。因为 allow_reuse_address = True 时,Windows 下第二个实例能成功抢绑同一端口并正常服务,关掉它时会把正在运行的实例一起带崩:
import msvcrt
def acquire_instance_lock(path):
f = open(path, "a+")
try:
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1) # 非阻塞独占锁
return f # 进程退出/被强杀时系统自动释放
except OSError:
f.close()
return None
③ 常驻必须交给系统级机制,不能挂在任何会话上。
在开发环境里被反复验证过:nohup ... &、cmd start /b、后台任务派生的进程,
都可能在会话轮次结束时被整棵树回收。这不是崩溃,也不是代码问题。
可靠的三种方式(推荐第一种):
# 方式一:Windows 计划任务(最可靠,不依赖任何窗口)
schtasks /run /tn <TaskName>
计划任务要点:LogonTrigger 登录触发 / ExecutionTimeLimit=PT0S(无时限)/
MultipleInstancesPolicy=IgnoreNew(防重复)/ 失败每 1 分钟重试。
注意:任务注册时间若晚于当前登录,LogonTrigger 等不到下一次登录,必须手动 /run 一次。
方式二:看门狗脚本(每 10 秒探活,探不到就重新拉起),配合文件锁防多开。
方式三:启动目录放 .bat(内容必须纯 ASCII + CRLF —— 中文注释在 GBK 控制台会乱码,
导致整个 bat 解析失败)。
④ 排查"打不开"的标准顺序。
netstat -ano | grep <port>—— 有没有监听,有几个 PIDtasklist | grep python—— 进程全没了 = 被回收;还在但服务不通 = 代码/端口问题- 前台直连换个端口跑一次(
python server.py --port 8655)证明代码本身没问题 schtasks /run /tn <TaskName>重新拉起
建议加的诊断端点
if path == "/api/ping":
# 不鉴权、不返回业务数据,只用于「本程序是否已在运行」的探测
return json_ok({"app": APP_NAME, "app_dir": BASE, "pid": os.getpid()})
首页必须禁缓存(改完代码用户看到还是旧的)
本地应用的"改了代码没生效"十有八九是浏览器缓存。 但用户说"强刷也不行"时,
先别急着怀疑缓存 —— 但也别完全不查:用响应头确认。
# 首页响应必须带禁缓存头,否则浏览器启发式缓存会一直吐旧 HTML
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
判断是不是缓存问题的可靠方法:让用户访问 http://127.0.0.1:<port>/?v=2
(加个没见过的查询参数)。对浏览器来说这是全新 URL,必然绕过缓存。
- 新 URL 正常 → 确认是缓存,检查服务端有没有发禁缓存头
- 新 URL 依旧 → 不是缓存,走下面的前端诊断流程
前端报错的诊断方法(用真实浏览器抓,别靠猜)
用户说「页面打开了但显示异常」,而服务端完全正常(所有接口 200)时,
问题必然在前端。不要靠读代码猜,用真实浏览器引擎抓。
用 Chrome DevTools Protocol 起无头浏览器
关键参数 --remote-allow-origins=*,不加会 WebSocket 握手 403。
proc = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu", "--no-first-run",
"--remote-debugging-port=9333", "--remote-allow-origins=*",
"--user-data-dir=<临时目录>", "about:blank"])
# 取 ws_url:http://127.0.0.1:9333/json/list → 第一个 type=="page" 的 webSocketDebuggerUrl
ws = websocket.create_connection(ws_url, timeout=30)
ws.send(json.dumps({"id":1,"method":"Runtime.enable"}))
ws.send(json.dumps({"id":2,"method":"Page.enable"}))
# 注入比页面脚本更早的监听(抓首屏错误最有用)
ws.send(json.dumps({"id":3,"method":"Page.addScriptToEvaluateOnNewDocument","params":{"source":"""
window.__errs=[];
window.addEventListener("error",e=>window.__errs.push("ERROR "+e.message+" @"+e.filename+":"+e.lineno));
window.addEventListener("unhandledrejection",e=>window.__errs.push("REJECT "+e.reason));
window.__fetchLog=[]; var _f=window.fetch;
window.fetch=function(){var p=_f.apply(this,arguments);
p.then(r=>window.__fetchLog.push(arguments[0]+" -> "+r.status),
e=>window.__fetchLog.push(arguments[0]+" -> FAIL "+e)); return p;};
"""}}))
ws.send(json.dumps({"id":4,"method":"Page.navigate","params":{"url":TARGET}}))
# 之后用 Runtime.evaluate 取 window.__errs / __fetchLog / 元素是否存在
四段输出固定套路:①首屏错误 ②fetch 记录 ③错误横幅 + body 长度 ④关键函数逐段调用。
逐层剥离定位真凶
把可疑的 async 链逐段单独 try,直接拿到 e.name: e.message 和 e.stack 前几行 —— 比翻源码快得多:
try { await loadState(); } catch(e){ log("loadState THROW "+e.name+": "+e.message); }
try { await refreshSettleTab(); } catch(e){ log("refresh THROW "+e.stack.split("\n").slice(0,4).join("<"); }
单文件大 JS 的三个致命坑
坑 1:孤儿调用(无定义函数)。 重构删函数时漏删调用点 → 抛 ReferenceError 且无声
(只在控制台),一旦位于初始化链上就会被 .catch() 伪装成「网络错误」。
> 真实案例:loadSubsidies() 末尾残留一句 subCheckStale(saved, month),
> 而 subCheckStale 全文件从未定义(只有名字相近的 subIsStale)。
> 它抛错 → loadSubsidies 失败 → refreshSettleTab 失败 → 被 .catch() 捕获
> → 页面顶部误报「无法连接后端服务」,补贴表/结算区域渲染不完整。
> 用户强刷无效,因为这是代码 bug 不是缓存。
改完前端务必全文件搜索新函数名,确认没有"只剩调用、没有定义"的情况。
坑 2:顶层 $("id").onchange = ... 是地雷。 元素不存在时 $() 返回 null,
.onchange 抛 TypeError,整个 init 块直接崩,后续全不执行:
// ❌ 元素缺失 → 整个初始化崩溃
$("imp-edit-month").onchange = onPickEditMonth;
// ✅ 包起来
(function boot() {
const el = $("imp-edit-month");
if (el) el.onchange = onPickEditMonth;
...
})();
坑 3:.catch() 不要挂在整条链上。 这会把页面里任何前端小错误都误报成
「无法连接后端服务」—— 极具误导性,用户和你都会被带偏。要拆成两段:
loadState().then(async () => {
try {
await refreshSettleTab(); // 渲染错误如实报「页面渲染出错:<原因>」
loadAuth();
} catch (e) {
console.error("首屏渲染失败:", e);
showBanner("页面渲染出错:" + e.message);
}
}).catch(e => {
showBanner("无法连接后端服务:" + e.message); // 只有这里才是真的连不上
});
坑 4:三元 + 字符串拼接的优先级陷阱。
// ❌ 正数分支漏了 fmt,页面显示「+ 元」没有金额
const money = v >= 0 ? "+" : "−" + fmt(v);
// ✅ 三元整体加括号
const money = (v >= 0 ? "+" : "−") + fmt(Math.abs(v));
展示类字段绝不能用结算快照(高频坑)
结算结果 JSON 里,金额类字段固化是对的(那是"当时的账"),
但名称/联系方式类字段固化就是 bug —— 用户改了配置,结算页还显示旧的,
看起来像"没保存成功"。
- 症状 A:先结算、后填联系方式 → 结算页永远显示「未填」
- 症状 B:结算后有值、之后在配置里清空 → 结算页仍显示旧值
取值约定:统一走一个函数(配置优先、结算快照兜底),展示与发送共用它,
否则会出现「显示未填、但发送能发出去」这种口径不一致。
function contactOf(entity) {
// 配置里最新维护的优先;配置为空时才回退到本次结算结果的快照
const c = (state.base.entities || []).find(x => x.name === entity);
const fromCfg = c ? String(c.contact || "").trim() : "";
if (fromCfg) return fromCfg;
const b = snapshotOf(entity);
return (b && b.contact) ? String(b.contact).trim() : "";
}
改了这类字段要顺手刷新展示(只重渲染那一列,不重算数字、不重新请求接口)。
自测:改一次配置,看展示页跟不跟;再清空一次,看是否退回「未填」。
自动发送账单到 IM(微信/企微)的实战坑
这部分是"把账单自动发出去"时必然踩到的坑,与结算逻辑无关但极耗时间。
坑 1:子进程的 stdin/stdout 必须显式 utf-8
子进程默认继承控制台 GBK(cp936),而父进程传的是 utf-8 JSON ——
不重包编码就会把「文件传输助手」读成「鑷劶欢浼犺緭鍔╂墜」这种乱码,
按乱码去搜索会话必然失败。
sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
症状识别:报错信息里的中文是 GBK 风格乱码(常见特征字如 鑷/浼/犺/鍔)→ 先查编码。
坑 2:Win32 API 要先确认在哪个 DLL
ctypes.windll 上取不存在的函数不报 AttributeError,而是
function 'X' not found。用之前先冒烟一遍:
u = ctypes.windll.user32
k32 = ctypes.windll.kernel32
# GetCurrentThreadId 在 kernel32(不在 user32)
GetCurrentThreadId = k32.GetCurrentThreadId
GetCurrentThreadId.restype = wintypes.DWORD
坑 3:窗口置前不能只试一次
Windows 不允许后台进程直接 SetForegroundWindow。必须组合三招 + 多次重试:
# ① 模拟按一下 ALT —— 解除系统的前台锁
u.keybd_event(0x12, 0, 0, 0); u.keybd_event(0x12, 0, 2, 0)
# ② AttachThreadInput —— 借用当前前台线程的输入权限
u.AttachThreadInput(cur_thread, fg_thread, True)
try:
u.BringWindowToTop(h)
u.SetWindowPos(h, -1, 0, 0, 0, 0, F) # TOPMOST
u.SetWindowPos(h, -2, 0, 0, 0, 0, F) # NOTOPMOST
u.SetForegroundWindow(h)
finally:
u.AttachThreadInput(cur_thread, fg_thread, False)
# ③ 重试 8 次,等待逐次放宽,给抢焦点的程序让路
焦点校验也要留缓冲(反复确认 2 秒),瞬时焦点闪动会误判中止。
坑 4:新版 IM 客户端关闭了无障碍接口
微信 4.x(Qt 框架):UIA 元素树是空的,MSAA 只有根节点,窗口标题恒为「微信」
不随会话变化。任何读 UI 元素的校验方案都不可行。
实测确认方式(只读,不点击):
# UIA
from pywinauto import Application
w = Application(backend="uia").connect(handle=h).window(handle=h)
print(len(w.descendants())) # 微信 4.x → 0
# MSAA
oleacc.AccessibleObjectFromWindow(h, 0, IID_IAccessible, &pacc) # 成功但只有根
pacc.accChildCount # → 0
解法:截图 + OCR 校验。 Windows 11 自带 OCR(WinRT),无需安装:
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
$result = Await ($engine.RecognizeAsync($bmp)) ([Windows.Media.Ocr.OcrResult])
要点:① 截聊天头部横条并放大 2 倍提高识别率;② OCR 会乱插空格,
比对前先 "".join(text.split());③ PowerShell 输出中文必须
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
④ 像素直喂(base64 → SoftwareBitmap.CreateCopyFromBuffer)比走文件更稳。
坑 5:自动化只能串行 + 失败后要能「断点续发」
批量发送逐家调接口,每家之间歇 3 秒(给客户端喘口气,避免连切会话粘包)。
但「失败即停」是错的——卡在一家会导致后面的全没发。正确做法是
记录每家的发送进度 + 只补发缺失部分:
进度分两级,前端本地维护:
progress = { text: false, imgs: 0 } // 文字是否已发、凭证已发出几张
每次调接口只传还没发出去的那部分,后端返回/报错时回传实际进度:
// 只补第 N 张之后的凭证图 —— 已发出的绝不重发
const pendingIds = imgs.slice(progress.imgs).map(it => it.id);
await api("/api/send-bill", { ..., image_ids: pendingIds, skip_text: progress.text });
关键点:
- 后端失败时也要回传"已发出去的部分"。脚本边发边打机器可读标记
([OK_TEXT]/[OK_IMG n]),父进程用正则从日志里抠出来随错误一起返回:
python payload = {"error": str(e)} if getattr(e, "partial", None): payload["partial"] = e.partial self._json(400, payload) # {"error":..., "partial":{"text":true,"images":1}} skip_text必须由后端控制,不能信任前端传来的空 text 就直接跳过文字校验——
否则前端传个空 text 就绕过了"金额与结算结果一致"的核对。- 进度只认最大值:
imgs = Math.max(prev, reported)。网络的报错顺序不保证。 - 照片顺序固定:按
imgs数组顺序发,第 N 张失败就补 N 之后的,不能用 id 集合。
坑 6:窗口被遮挡 → 点击会落到别的程序上
实测症状:明明已经切到目标会话了,文字却发不出去 / 发到别处。
根因是目标窗口被别的程序盖住,mouse.click(x, y) 的坐标落在遮挡窗口上。
解法是发送前先关掉遮挡窗口,但必须解决两个陷阱:
陷阱 A:EnumWindows 返回的是"所有"窗口,不是"压在上面的"窗口。
直接按"有重叠就关"会把一堆在目标下面的窗口(被压住的浏览器等)也关掉。
正确做法是按 Z 序判断——EnumWindows 本身就是从顶到底,
seq = zorder() # EnumWindows 顺序 = Z 序
i = seq.index(target)
for h in seq[:i]: # 只有排在 target 之前 = 真正压在它上面
if cover_ratio(rect(h), rect(target)) > 0.02: ...
陷阱 B:有些程序绝不能关——用户可能正在写文档/改代码。维护一个保护名单:
_PROTECT_PROCS = {"winword.exe","excel.exe","wps.exe","et.exe","code.exe",
"notepad.exe","explorer.exe","cmd.exe","powershell.exe",
"wechat.exe","weixin.exe", ...} # 微信自身也绝不关
受保护程序只报告、绝不关闭;PostMessageW(h, WM_CLOSE) 前先查进程名。
放置顺序也重要:先关遮挡 → 再置前 → 置前成功后再查一遍(防止又有窗口弹上来);
置前失败则再关一轮(提高激进程度)——最多 3 轮。
焦点守卫要能自愈:发现前台不是目标窗口时,不要立刻中止,
先自动清遮挡 + 重新置前,成功了就继续发。
安全底线:发送前三重校验
- 必须有该期结算结果
- 账单文字里抠出的金额必须与最后一次结算结果完全一致(差 >0.005 就拒发)——
防止"改了补贴没重算就发出去了" - 必须有发送目标(联系方式已填)
部署与权限收口(防误改、可审计)
系统上线后最大的风险不是算错,而是被人(或自己)随手改坏。三条收口:
- 谁上传谁管理员。任何部署到服务器的软件,上传者自动是总管理员;非管理员不能改逻辑、不能升级。
- SSH 仅密钥、禁密码、禁 root 登录。服务器
/etc/ssh/sshd_config.d/99-hardening.conf:PasswordAuthentication no/PermitRootLogin no/PubkeyAuthentication yes。员工维度用各自公钥,互不共享、可单点吊销。 - 部署必留痕。用
deploy.sh:待上线文件按<原名>.new.<后缀>传到/tmp,执行sudo ./deploy.sh "部署人"→ 自动备份旧版_bak_YYYYmmdd-HHMM/、覆盖、重启服务、往/var/log/billing_deploy.log写一行(时间 | 部署人 | 文件 | md5)。出事能溯源到人和版本。
关键是"改动可审计、权限可收口"——不能让任何拿到网址的人都能改系统。
上架前自查
- [ ] 不含任何真实品牌名、企业名、人名
- [ ] 不含真实绝对路径、IP、域名、账号、密钥
- [ ] 示例数据全部虚构
- [ ] 示例主体名用「甲店/乙店/丙店」这类中性名
- [ ] 校验脚本里没有真实人员姓名
- [ ] 描述里不含真实业务术语黑话
- [ ] 无硬编码密钥
- [ ] 脚本不访问网络、不写业务系统
常见需求落点
| 要做的事 | 改哪里 |
|---|---|
| 某项费用要封顶 | 主体配置加 *_cap;全局默认值;>0 才生效 |
| 某主体不收某费用 | 该主体配置 *_enabled = false(不是 == True 判断) |
| 扣减记到别的主体 | 主体配置 deduct_target |
| 加一类调整项 | 六处联动清单(见上文) |
| 改对账说明模板 | 全局配置里放带 {period} 占位的模板 |
| 改导出样式 | 抽出的 _write_sheet / _color_row 工具函数 |
| 新增一个数据页签 | 前端加 data-tab + section;后端加对应 API |
| 支持新的表头写法 | COLUMN_ALIASES 加别名 |
| 代采费走阶梯计费 | 全局 purchase_fee_mode=tiered + purchase_fee_tiers(每档 {lt:上限, fee},末档 lt:null 兜底) |
| 某门店免代采费 | 该门店 purchase_fee_enabled=false |
| 新门店自动建档 | 导入 / 保存 / 结算三处 ensure_stores_from_records,带 _incomplete=True;_norm_store_name 去「·」/空格防重复 |
| 行内参数留空回退 | 门店配置留空 = 走全局默认;门店填值 = 覆盖;改完自动落盘 |