门店月结 · 结算系统

SKILL效率
效率 未标注权限 许可证不明确
安装这个技能

【使用前请先下载并安装本技能】给招商合伙(总部—门店—合伙人)模式做月度结算系统时,反复踩的坑高度一致:某门店算不出数、加了开关历史数据全变 0、总额和明细对不上、改完不敢上线。本方法把这套系统的工程解法沉淀成可复制的模式,五条设计原则加一份开箱即用的回归校验脚本,涵盖公式单点源、字段三级回退、服务常驻、账单自动发送。近期补充:门店维护表的长表头固定与滚动位置保留、抽点/无人值守封顶的默认推导与可覆盖、代采费阶梯与单店优先级链、门店自动建档与防重复、导入表头同义兼容(去括号单位 + 别名子串兜底)、部署留痕脚本与 SSH 仅密钥收口。安装方法:在 SkillHub 搜索「门店月结」点「安装」,或执行 skillhub install settlement-system-dev。

一句话选型结论

按资料字段整理

门店月结 · 结算系统是效率类的 Agent Skill,安装方式未标注,权限级别未标注,许可证不明确,有可直接复制的安装命令。技能本质是给模型读的说明书,效果取决于模型与客户端;装完先用一个小任务验证,再放进正式流程。

分类效率 安装方式未标注 权限级别未标注权限 许可证许可证不明确
累计安装 未标注 本周 未标注
GitHub 星标 未标注 仓库 未标注
下载量 未标注 技能市场下载数
权限级别 未标注权限 安装前请确认风险范围
许可证 许可证不明确 未说明

安装

未标注
在支持 Skills 的客户端里执行,装完问一句「你有哪些技能」验证
skillhub install user_bb04ed0e/settlement-system-dev
  1. 1
    确认客户端支持

    确认你的客户端支持 Skills(Claude、Codex、Cursor 等),并升级到较新的版本。

  2. 2
    拿到技能文件

    执行上面的命令安装;也可以把仓库里的 SKILL.md 手动放进客户端的 skills 目录。

  3. 3
    刷新技能列表

    重新打开客户端,或刷新技能列表,让新放进来的技能被加载。

  4. 4
    验证是否生效

    问一句「你有哪些技能」,确认列表里出现它,再拿一个小任务试跑。

技能档案

效率
字段来自技能仓库与公开索引,中文说明由本站标注
作者 / 维护方user_bb04ed0e仓库 未标注 · 版本 未标注
宿主客户端未标注技能可被这些客户端加载
安装方式未标注安装包 community
权限级别未标注权限安装前请确认风险范围
许可证未说明许可证不明确,商用前请自行确认条款
仓库可用性available技能仓库当前是否可访问
最近核验2026-11-06上游最后核验时间 · 数据更新 2026-11-06
正文体量44.0 KBSKILL.md 原文大小,越大说明说明书写得越细

典型使用场景

3 类
按分类整理的常见用法,具体以 SKILL.md 为准
整理待办与日程

把零散任务收进清单,排出优先级。

批量处理重复任务

把固定动作做成模板,重复执行。

把流程写成清单

把口头流程整理成可复用的步骤。

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。

先想清楚的地方

  • 没有标注权限级别,实际能做什么要装好后确认。
  • 许可证不明确,商用或二次分发前先到仓库确认条款。
  • 没有做站内核验,内容以作者仓库为准。
  • 技能只是说明书,实际效果取决于模型与客户端,装完先用小任务试一遍。

更适合

  • 需要在效率场景里给模型补一套做法的人。
  • 愿意先读一遍 SKILL.md、确认它会让模型做什么的人。

不太适合

  • 对许可证要求很严、必须明确可商用的人。
  • 只想装完就自动变强、不打算看说明书的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
技能权限级别安装方式累计安装宿主客户端安装命令
门店月结 · 结算系统当前 未标注权限 未标注 未标注 未标注 有
Nano PDF 文档 联网访问 ClawHub 市场 424.9 万 OpenClaw 有
自动更新程序 浏览器控制 ClawHub 市场 296.4 万 OpenClaw 有
Gemini 助手 联网访问 ClawHub 市场 254.7 万 OpenClaw 有
word DOCX 文档 联网访问 ClawHub 市场 239.6 万 OpenClaw 有
Excel XLSX 前端 联网访问 ClawHub 市场 227.0 万 OpenClaw 有

SKILL.md 全文

44.0 KB
模型实际读到的说明书原文,来自技能仓库

门店月结 · 结算系统

> 【使用前必读】请先在 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"]   # 必需列

缺必需列直接抛错并提示需要哪几列,别静默跳过。

同义表头兼容的两条实战经验(减少人工改名):

  1. 先归一化再匹配:表头常带单位后缀,如「有效实付金额(元)」「订单量(个)」。匹配前用 _norm_header() 剥掉首尾空白和结尾的括号单位((元)/(个),括号内任意内容),否则别名要对上带单位的写法。
  2. 精确优先 + 子串兜底:先用精确/包含匹配,再用「别名长度 ≥ 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&gt;0)       → 用全局值
数据源「代采费金额」列                 → 最后兜底

这条链把"门店个性覆盖"和"全局规则"显式排好序,正是 [核心原则 #2 字段优先级必须显式建模] 的落地。门店行内留空即回退上级——改完自动落盘,不用每次重填。

门店自动建档与防重复

"数据源出现、但基础信息没维护的门店"如果不处理,要么被静默跳过(漏算),要么重复建店(数据算丢)。解法:

  • 三处自动建档:数据导入、手工保存记录、结算前,都会调用 ensure_stores_from_records() 把新门店建上。
  • 新建门店带 _incomplete=True:基础信息页高亮"待维护",提醒补全;不覆盖任何已有门店配置。
  • 归一化名防重复:_norm_store_name() 去掉「·」「・」「•」、空格、全角括号转半角;例如「甲店·朝阳路」与「甲店朝阳路」归一化后视为同一家,既不会重复建店,结算时也不会把同一家的数据算丢。store_alias_map() 用归一化名把数据源店名映射到已维护店名。
  • 结算结果里把所有 _incomplete 门店单列提醒,逼着有人去补全,而不是一直吃全局默认值。

⚠️ 这和"配置是白名单"原则不冲突:白名单防的是"误导入污染全月",自动建档防的是"漏算"。二者结合 = 默认登记、但显式标记待维护。

自动备份

用户数据在本地,备份是硬需求,不是附加功能。

目录选择原则

至少两处,且性质不同:

  1. 云盘目录(OneDrive / Dropbox / Nutstore / WPS Cloud Files…)→ 防硬盘损坏
  2. 非系统盘(D:/E:/F:…)→ 防系统重装
def guess_backup_dirs():
    dirs = []
    for name in (&quot;OneDrive&quot;, &quot;Dropbox&quot;, &quot;Nutstore&quot;, &quot;WPS Cloud Files&quot;):
        p = os.path.join(os.path.expanduser(&quot;~&quot;), name)
        if os.path.isdir(p):
            dirs.append(os.path.join(p, APP_NAME + &quot;备份&quot;))
    for drive in &quot;DEFGH&quot;:
        if os.path.isdir(drive + &quot;:\\&quot;):
            dirs.append(drive + &quot;:\\&quot; + APP_NAME + &quot;备份&quot;); break
    return dirs

打包内容

程序代码 + 整个数据目录。命名 backup_YYYYMMDD_HHMMSS.zip。

轮转

每个目录保留最近 N 份(默认 30),超出自动删最旧的。避免磁盘写满。

触发时机

  • 定时线程(默认 24 小时)
  • 每次结算/保存后自动补一份 ← 这个最重要,操作前的状态一定要能恢复

文件多了按日期建文件夹

用户习惯:输出文件超过两个就按日期建文件夹。这条要贯彻到导出目录结构里。

汇报要可见

保存/结算后,明确汇报四件事:备份是否成功(含目录和大小)、文档是否导出、用什么打开了、是否同步云盘。

不要只给一句"已更新"。用户需要确认到底做没做成 —— 尤其涉及数据安全时。

def doc_sync_report(rt):
    &quot;&quot;&quot;把备份、导出、打开、云盘同步四件事汇总成可读反馈。&quot;&quot;&quot;

打包成 exe

用 PyInstaller 时,可写目录不等于资源目录:

def app_dir():
    if getattr(sys, &quot;frozen&quot;, 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, &quot;_MEIPASS&quot;, app_dir())   # 打包后是只读资源目录
  • 数据放 app_dir():用户要能改能备份
  • 只读资源(如内置首页)放 res_dir(),并优先用外部同名文件覆盖,方便随时改样式而不重新打包

轻量鉴权

单机场景加个口令足够,不必引入完整用户体系。

  • 口令:sha256(salt + password),salt 存配置
  • Token:base64(username|exp|hmac_sha256),HMAC 密钥单独存
  • 有效期 72 小时
  • 登录失败按 IP 计数,防暴力破解
  • enabled: false 时完全跳过校验(默认开发态关闭)

不要在这里存明文口令。 配置文件是会被备份、被复制、被同步到云盘的。

前端约定

  • 表头冻结自动化:用一个 MutationObserver 把所有 &lt;table&gt; 自动包进可滚动容器,新表格无需特殊处理
  • 长表头固定 + 滚动位置保留(门店维护表高频坑):维护门店信息时,每次 onchange 触发整表重渲染会把滚动条重置回顶部,填一个字段就被甩回表头,极影响体验。解法:表格外层套 .tbl-wrap{overflow:auto;max-height:70vh},表头 thead th{position:sticky;top:0};渲染前记录 _tw.scrollTop,渲染后还原,避免每次重滑。

javascript const _tw = document.querySelector(&quot;#store-table .tbl-wrap&quot;); const _savedScroll = _tw ? _tw.scrollTop : 0; // ... 重渲染 innerHTML ... const _tw2 = document.querySelector(&quot;#store-table .tbl-wrap&quot;); if (_tw2) _tw2.scrollTop = _savedScroll;
- API 返回 JSON,401 统一表示登录过期 → 跳登录页
- 技术报错翻译成中文可操作提示:

function friendlyErr(e) {
  const m = String(e?.message || e || &quot;&quot;);
  if (/Failed to fetch|NetworkError/i.test(m))
    return &quot;连不上服务 —— 程序可能已停止运行,或仍在运行旧版本代码。&quot;
         + &quot;请先 Ctrl+F5 刷新重试;若仍不行,请关闭程序窗口后重新启动。&quot;;
  return m || &quot;未知错误&quot;;
}

用户不懂技术,报 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)

这套断言是"改动没有破坏既有规则"的保险。 维护它比反复手算便宜得多。

开发规矩

  1. 先读再改。结算代码动辄上千行,凭记忆改必错。
  2. 改前备份。至少复制数据目录。
  3. 公式只改一处,后端。前端不碰。
  4. 加任何布尔配置项,必须写三态测试。
  5. 加任何跨主体逻辑,必须写守恒断言。
  6. 表头固定,新列追加末尾。
  7. 改完跑校验 + 实际启动跑一遍,别只看代码就宣布完成。
  8. 逻辑复杂就加告警,别让用户猜。
  9. 改任何前端文件后必须重启服务(见「服务怎么常驻」)—— 程序只在启动时读一次代码。

服务怎么常驻(本地 Web 应用的第一大坑)

本地 Web 应用的"打不开",八成不是崩溃,是进程被回收了。

症状与病因

症状 病因
命令行窗口在,但网页打不开 只监听了 127.0.0.1,而浏览器把 localhost 解析到 ::1
服务莫名消失,进程列表里一个都没有 进程挂在某个会话树下,会话结束被整棵树回收
双击 exe 好几次,最后全崩了 没有单实例守卫,多实例抢同一端口

逐条解法

① 必须双栈监听。

def make_servers(port):
    servers = []
    for host in (&quot;127.0.0.1&quot;, &quot;::1&quot;):        # IPv4 + IPv6
        try:
            servers.append(ThreadingHTTPServer((host, port), Handler))
        except OSError:
            pass
    return servers

并且自动打开浏览器一律用 http://127.0.0.1:&lt;port&gt;,绝不用 localhost。

② 单实例守卫要做两道。

第一道:探测端点。注意别只读固定字节数 —— 响应体一长,关键字段可能排在几千字节之后,read(6000) 之类的写法会让守卫永远失效:

def already_running(port):
    try:                                  # 用一个极小的专用端点,别复用大接口
        with urllib.request.urlopen(&quot;http://127.0.0.1:%d/api/ping&quot; % port, timeout=2) as r:
            body = r.read()               # 整段读,不要 read(N)
        return &#x27;&quot;app&quot;&#x27; in body.decode(&quot;utf-8&quot;, &quot;replace&quot;)
    except Exception:
        return False

第二道:文件锁。因为 allow_reuse_address = True 时,Windows 下第二个实例能成功抢绑同一端口并正常服务,关掉它时会把正在运行的实例一起带崩:

import msvcrt
def acquire_instance_lock(path):
    f = open(path, &quot;a+&quot;)
    try:
        msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)   # 非阻塞独占锁
        return f                                          # 进程退出/被强杀时系统自动释放
    except OSError:
        f.close()
        return None

③ 常驻必须交给系统级机制,不能挂在任何会话上。

在开发环境里被反复验证过:nohup ... &amp;、cmd start /b、后台任务派生的进程,
都可能在会话轮次结束时被整棵树回收。这不是崩溃,也不是代码问题。

可靠的三种方式(推荐第一种):

# 方式一:Windows 计划任务(最可靠,不依赖任何窗口)
schtasks /run /tn &lt;TaskName&gt;

计划任务要点:LogonTrigger 登录触发 / ExecutionTimeLimit=PT0S(无时限)/
MultipleInstancesPolicy=IgnoreNew(防重复)/ 失败每 1 分钟重试。
注意:任务注册时间若晚于当前登录,LogonTrigger 等不到下一次登录,必须手动 /run 一次。

方式二:看门狗脚本(每 10 秒探活,探不到就重新拉起),配合文件锁防多开。
方式三:启动目录放 .bat(内容必须纯 ASCII + CRLF —— 中文注释在 GBK 控制台会乱码,
导致整个 bat 解析失败)。

④ 排查"打不开"的标准顺序。

  1. netstat -ano | grep &lt;port&gt; —— 有没有监听,有几个 PID
  2. tasklist | grep python —— 进程全没了 = 被回收;还在但服务不通 = 代码/端口问题
  3. 前台直连换个端口跑一次(python server.py --port 8655)证明代码本身没问题
  4. schtasks /run /tn &lt;TaskName&gt; 重新拉起

建议加的诊断端点

if path == &quot;/api/ping&quot;:
    # 不鉴权、不返回业务数据,只用于「本程序是否已在运行」的探测
    return json_ok({&quot;app&quot;: APP_NAME, &quot;app_dir&quot;: BASE, &quot;pid&quot;: os.getpid()})

首页必须禁缓存(改完代码用户看到还是旧的)

本地应用的"改了代码没生效"十有八九是浏览器缓存。 但用户说"强刷也不行"时,
先别急着怀疑缓存 —— 但也别完全不查:用响应头确认。

# 首页响应必须带禁缓存头,否则浏览器启发式缓存会一直吐旧 HTML
self.send_header(&quot;Cache-Control&quot;, &quot;no-cache, no-store, must-revalidate&quot;)

判断是不是缓存问题的可靠方法:让用户访问 http://127.0.0.1:&lt;port&gt;/?v=2
(加个没见过的查询参数)。对浏览器来说这是全新 URL,必然绕过缓存。
- 新 URL 正常 → 确认是缓存,检查服务端有没有发禁缓存头
- 新 URL 依旧 → 不是缓存,走下面的前端诊断流程

前端报错的诊断方法(用真实浏览器抓,别靠猜)

用户说「页面打开了但显示异常」,而服务端完全正常(所有接口 200)时,
问题必然在前端。不要靠读代码猜,用真实浏览器引擎抓。

用 Chrome DevTools Protocol 起无头浏览器

关键参数 --remote-allow-origins=*,不加会 WebSocket 握手 403。

proc = subprocess.Popen([CHROME, &quot;--headless=new&quot;, &quot;--disable-gpu&quot;, &quot;--no-first-run&quot;,
    &quot;--remote-debugging-port=9333&quot;, &quot;--remote-allow-origins=*&quot;,
    &quot;--user-data-dir=&lt;临时目录&gt;&quot;, &quot;about:blank&quot;])
# 取 ws_url:http://127.0.0.1:9333/json/list → 第一个 type==&quot;page&quot; 的 webSocketDebuggerUrl
ws = websocket.create_connection(ws_url, timeout=30)
ws.send(json.dumps({&quot;id&quot;:1,&quot;method&quot;:&quot;Runtime.enable&quot;}))
ws.send(json.dumps({&quot;id&quot;:2,&quot;method&quot;:&quot;Page.enable&quot;}))
# 注入比页面脚本更早的监听(抓首屏错误最有用)
ws.send(json.dumps({&quot;id&quot;:3,&quot;method&quot;:&quot;Page.addScriptToEvaluateOnNewDocument&quot;,&quot;params&quot;:{&quot;source&quot;:&quot;&quot;&quot;
  window.__errs=[];
  window.addEventListener(&quot;error&quot;,e=&gt;window.__errs.push(&quot;ERROR &quot;+e.message+&quot; @&quot;+e.filename+&quot;:&quot;+e.lineno));
  window.addEventListener(&quot;unhandledrejection&quot;,e=&gt;window.__errs.push(&quot;REJECT &quot;+e.reason));
  window.__fetchLog=[]; var _f=window.fetch;
  window.fetch=function(){var p=_f.apply(this,arguments);
    p.then(r=&gt;window.__fetchLog.push(arguments[0]+&quot; -&gt; &quot;+r.status),
           e=&gt;window.__fetchLog.push(arguments[0]+&quot; -&gt; FAIL &quot;+e)); return p;};
&quot;&quot;&quot;}}))
ws.send(json.dumps({&quot;id&quot;:4,&quot;method&quot;:&quot;Page.navigate&quot;,&quot;params&quot;:{&quot;url&quot;:TARGET}}))
# 之后用 Runtime.evaluate 取 window.__errs / __fetchLog / 元素是否存在

四段输出固定套路:①首屏错误 ②fetch 记录 ③错误横幅 + body 长度 ④关键函数逐段调用。

逐层剥离定位真凶

把可疑的 async 链逐段单独 try,直接拿到 e.name: e.message 和 e.stack 前几行 —— 比翻源码快得多:

try { await loadState(); }        catch(e){ log(&quot;loadState THROW &quot;+e.name+&quot;: &quot;+e.message); }
try { await refreshSettleTab(); } catch(e){ log(&quot;refresh THROW &quot;+e.stack.split(&quot;\n&quot;).slice(0,4).join(&quot;&lt;&quot;); }

单文件大 JS 的三个致命坑

坑 1:孤儿调用(无定义函数)。 重构删函数时漏删调用点 → 抛 ReferenceError 且无声
(只在控制台),一旦位于初始化链上就会被 .catch() 伪装成「网络错误」。

> 真实案例:loadSubsidies() 末尾残留一句 subCheckStale(saved, month),
> 而 subCheckStale 全文件从未定义(只有名字相近的 subIsStale)。
> 它抛错 → loadSubsidies 失败 → refreshSettleTab 失败 → 被 .catch() 捕获
> → 页面顶部误报「无法连接后端服务」,补贴表/结算区域渲染不完整。
> 用户强刷无效,因为这是代码 bug 不是缓存。

改完前端务必全文件搜索新函数名,确认没有"只剩调用、没有定义"的情况。

坑 2:顶层 $(&quot;id&quot;).onchange = ... 是地雷。 元素不存在时 $() 返回 null,
.onchange 抛 TypeError,整个 init 块直接崩,后续全不执行:

// ❌ 元素缺失 → 整个初始化崩溃
$(&quot;imp-edit-month&quot;).onchange = onPickEditMonth;

// ✅ 包起来
(function boot() {
  const el = $(&quot;imp-edit-month&quot;);
  if (el) el.onchange = onPickEditMonth;
  ...
})();

坑 3:.catch() 不要挂在整条链上。 这会把页面里任何前端小错误都误报成
「无法连接后端服务」—— 极具误导性,用户和你都会被带偏。要拆成两段:

loadState().then(async () =&gt; {
  try {
    await refreshSettleTab();          // 渲染错误如实报「页面渲染出错:&lt;原因&gt;」
    loadAuth();
  } catch (e) {
    console.error(&quot;首屏渲染失败:&quot;, e);
    showBanner(&quot;页面渲染出错:&quot; + e.message);
  }
}).catch(e =&gt; {
  showBanner(&quot;无法连接后端服务:&quot; + e.message);   // 只有这里才是真的连不上
});

坑 4:三元 + 字符串拼接的优先级陷阱。

// ❌ 正数分支漏了 fmt,页面显示「+ 元」没有金额
const money = v &gt;= 0 ? &quot;+&quot; : &quot;−&quot; + fmt(v);

// ✅ 三元整体加括号
const money = (v &gt;= 0 ? &quot;+&quot; : &quot;−&quot;) + fmt(Math.abs(v));

展示类字段绝不能用结算快照(高频坑)

结算结果 JSON 里,金额类字段固化是对的(那是"当时的账"),
但名称/联系方式类字段固化就是 bug —— 用户改了配置,结算页还显示旧的,
看起来像"没保存成功"。

  • 症状 A:先结算、后填联系方式 → 结算页永远显示「未填」
  • 症状 B:结算后有值、之后在配置里清空 → 结算页仍显示旧值

取值约定:统一走一个函数(配置优先、结算快照兜底),展示与发送共用它,
否则会出现「显示未填、但发送能发出去」这种口径不一致。

function contactOf(entity) {
  // 配置里最新维护的优先;配置为空时才回退到本次结算结果的快照
  const c = (state.base.entities || []).find(x =&gt; x.name === entity);
  const fromCfg = c ? String(c.contact || &quot;&quot;).trim() : &quot;&quot;;
  if (fromCfg) return fromCfg;
  const b = snapshotOf(entity);
  return (b &amp;&amp; b.contact) ? String(b.contact).trim() : &quot;&quot;;
}

改了这类字段要顺手刷新展示(只重渲染那一列,不重算数字、不重新请求接口)。

自测:改一次配置,看展示页跟不跟;再清空一次,看是否退回「未填」。

自动发送账单到 IM(微信/企微)的实战坑

这部分是"把账单自动发出去"时必然踩到的坑,与结算逻辑无关但极耗时间。

坑 1:子进程的 stdin/stdout 必须显式 utf-8

子进程默认继承控制台 GBK(cp936),而父进程传的是 utf-8 JSON ——
不重包编码就会把「文件传输助手」读成「鑷劶欢浼犺緭鍔╂墜」这种乱码,
按乱码去搜索会话必然失败。

sys.stdin  = io.TextIOWrapper(sys.stdin.buffer,  encoding=&quot;utf-8&quot;, errors=&quot;replace&quot;)
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding=&quot;utf-8&quot;, errors=&quot;replace&quot;)

症状识别:报错信息里的中文是 GBK 风格乱码(常见特征字如 鑷/浼/犺/鍔)→ 先查编码。

坑 2:Win32 API 要先确认在哪个 DLL

ctypes.windll 上取不存在的函数不报 AttributeError,而是
function &#x27;X&#x27; 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=&quot;uia&quot;).connect(handle=h).window(handle=h)
print(len(w.descendants()))     # 微信 4.x → 0

# MSAA
oleacc.AccessibleObjectFromWindow(h, 0, IID_IAccessible, &amp;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 会乱插空格,
比对前先 &quot;&quot;.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 =&gt; it.id);
await api(&quot;/api/send-bill&quot;, { ..., image_ids: pendingIds, skip_text: progress.text });

关键点:

  1. 后端失败时也要回传"已发出去的部分"。脚本边发边打机器可读标记
    ([OK_TEXT] / [OK_IMG n]),父进程用正则从日志里抠出来随错误一起返回:
    python payload = {&quot;error&quot;: str(e)} if getattr(e, &quot;partial&quot;, None): payload[&quot;partial&quot;] = e.partial self._json(400, payload) # {&quot;error&quot;:..., &quot;partial&quot;:{&quot;text&quot;:true,&quot;images&quot;:1}}
  2. skip_text 必须由后端控制,不能信任前端传来的空 text 就直接跳过文字校验——
    否则前端传个空 text 就绕过了"金额与结算结果一致"的核对。
  3. 进度只认最大值:imgs = Math.max(prev, reported)。网络的报错顺序不保证。
  4. 照片顺序固定:按 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)) &gt; 0.02: ...

陷阱 B:有些程序绝不能关——用户可能正在写文档/改代码。维护一个保护名单:

_PROTECT_PROCS = {&quot;winword.exe&quot;,&quot;excel.exe&quot;,&quot;wps.exe&quot;,&quot;et.exe&quot;,&quot;code.exe&quot;,
                  &quot;notepad.exe&quot;,&quot;explorer.exe&quot;,&quot;cmd.exe&quot;,&quot;powershell.exe&quot;,
                  &quot;wechat.exe&quot;,&quot;weixin.exe&quot;, ...}      # 微信自身也绝不关

受保护程序只报告、绝不关闭;PostMessageW(h, WM_CLOSE) 前先查进程名。

放置顺序也重要:先关遮挡 → 再置前 → 置前成功后再查一遍(防止又有窗口弹上来);
置前失败则再关一轮(提高激进程度)——最多 3 轮。

焦点守卫要能自愈:发现前台不是目标窗口时,不要立刻中止,
先自动清遮挡 + 重新置前,成功了就继续发。

安全底线:发送前三重校验

  1. 必须有该期结算结果
  2. 账单文字里抠出的金额必须与最后一次结算结果完全一致(差 >0.005 就拒发)——
    防止"改了补贴没重算就发出去了"
  3. 必须有发送目标(联系方式已填)

部署与权限收口(防误改、可审计)

系统上线后最大的风险不是算错,而是被人(或自己)随手改坏。三条收口:

  1. 谁上传谁管理员。任何部署到服务器的软件,上传者自动是总管理员;非管理员不能改逻辑、不能升级。
  2. SSH 仅密钥、禁密码、禁 root 登录。服务器 /etc/ssh/sshd_config.d/99-hardening.conf:PasswordAuthentication no / PermitRootLogin no / PubkeyAuthentication yes。员工维度用各自公钥,互不共享、可单点吊销。
  3. 部署必留痕。用 deploy.sh:待上线文件按 &lt;原名&gt;.new.&lt;后缀&gt; 传到 /tmp,执行 sudo ./deploy.sh &quot;部署人&quot; → 自动备份旧版 _bak_YYYYmmdd-HHMM/、覆盖、重启服务、往 /var/log/billing_deploy.log 写一行(时间 | 部署人 | 文件 | md5)。出事能溯源到人和版本。

关键是"改动可审计、权限可收口"——不能让任何拿到网址的人都能改系统。

上架前自查

  • [ ] 不含任何真实品牌名、企业名、人名
  • [ ] 不含真实绝对路径、IP、域名、账号、密钥
  • [ ] 示例数据全部虚构
  • [ ] 示例主体名用「甲店/乙店/丙店」这类中性名
  • [ ] 校验脚本里没有真实人员姓名
  • [ ] 描述里不含真实业务术语黑话
  • [ ] 无硬编码密钥
  • [ ] 脚本不访问网络、不写业务系统

常见需求落点

要做的事 改哪里
某项费用要封顶 主体配置加 *_cap;全局默认值;&gt;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 去「·」/空格防重复
行内参数留空回退 门店配置留空 = 走全局默认;门店填值 = 覆盖;改完自动落盘

常见问题

5 问
答案由本站依据条目字段整理
门店月结 · 结算系统怎么安装?

在支持 Skills 的客户端里执行:skillhub install user_bb04ed0e/settlement-system-dev。安装方式为未标注。装完刷新技能列表或重开客户端。

门店月结 · 结算系统支持哪些客户端?

资料没有标注宿主客户端,但只要客户端支持 Skills(Claude、Codex、Cursor 等),一般都能加载。

门店月结 · 结算系统权限高吗,安全吗?

权限级别为未标注权限。安装前先看它会读取或写入哪些内容。

门店月结 · 结算系统收费吗?

本站不代收费用,也没有标注具体价格。是否收费取决于技能本身和所用的模型或客户端,正式使用前按作者说明确认。

门店月结 · 结算系统用什么许可证?

许可证标注为 未说明(许可证不明确)。商用或二次分发前请到仓库确认条款原文。

热门专题

按场景挑技能
全部专题 →

技能内容以作者仓库为准;分类、权限级别与许可证状态的中文说明为中文整理,最近核验 2026-11-06。