Commit 422a556c authored by wenwen.tang's avatar wenwen.tang 😕

chore(memory): 重写长期记忆,去重并压回限额内

原文超限被注入层截断。合并同类条目(渲染/模板、名册、数据模型、
业务口径、部署、版本管理、验证脚本),删除已完成的一次性迁移叙述
与冗余时间线,保留全部有效操作知识(列映射、接口、坑位、铁律)。

Co-authored-by: CodeBuddy
parent 34e42964
# 租金通知系统 · 项目长期记忆 # 租金通知系统 · 项目长期记忆
## 目标 ## 目标
把三份 Excel 做成**固定模板 + 数据填充**的租金/电费通知系统,产出 **PDF 收费通知书** 三份 Excel → **固定模板 + 数据填充** 的租金/电费通知系统,产出 **PDF 收费通知书**(2026-10-12 起统一 PDF),
(2026-10-12 起统一 PDF,不再产出 Excel),并自动发企业微信 + 前端页面维护。 自动发企业微信 + Web 页面维护。
## 命名规范(2026-10-12 修订) ## 命名规范(2026-10-12)
- 文件名 = `{命名主体}-{费用类型}-收费通知书.pdf` - 文件名 = `{单元列表}-{费用类型}-收费通知书.pdf`;单元按 `B.unit_sort_key` 排序、`、` 连接
- **命名主体 = 单元列表**(`、` 连接、按 `B.unit_sort_key` 排序)—— **2026-10-12 起不再标公司名** - 例 `302室-房租&物业费-收费通知书.pdf` / `121车位、402室、404室-房租&物业费&车位费-收费通知书.pdf`
- `302室-房租&物业费-收费通知书.pdf` / `801室、802室-房租-收费通知书.pdf` - **不含公司名**(出租方/租客只出现在正文);守护测试 `test_no_company_in_name`
- `190车位-车位费-收费通知书.pdf` / `121车位、402室、404室-房租&物业费&车位费-收费通知书.pdf` - 费用类型词序固定:房租 → 物业费 → 车位费(`&` 合法,勿替换)
- 出租方 / 租客名只出现在单据**正文**,绝不进文件名(守护测试 `test_no_company_in_name`)
- 费用类型由明细归纳,词序固定 房租 → 物业费 → 车位费(`&` 合法,勿替换) ## 渲染 / 模板
- **唯一渲染层** `rentos/notice_pdf.py`:`rent_notice_pdf()` / `elec_notice_pdf()`;
## 固定模板 reportlab + platypus 程序化绘制(**不用 win32com / LibreOffice**,本机都没有)
`templates/T1-房租 / T2-房租&物业费 / T3-车位费 / T4-电费`(版式锁死 + 占位符 + logo) - 字体 `assets/fonts/simhei.ttf`(9.7MB 随仓库分发),子集嵌入,单份 10~32KB;
- `build_templates.py` 生成模板;`build_rules_doc.py` 生成规则文档;`build_notices.py` 全量生成器 回落链 simhei → NotoSansSC → 系统 simhei/msyh → Linux wqy/noto → DejaVu → CID `STSong-Light`
- 电费版式已统一表头(明细类目/本期用量/本期费用)并补「人民币大写」行 - 表格 `templates/T1-房租 / T2-房租&物业费 / T3-车位费 / T4-电费`;
`rent_render.py` / `elec_render.py` 只做「分组 + 命名 + 落盘」
## 账单渲染(2026-10-12 起统一 PDF) - 版式:A4、`MARGIN = 1.9cm`;房租 5 列、电费 7 列,多单元按 `spans` 合并首列
- **`rentos/notice_pdf.py`** 是唯一渲染层:`rent_notice_pdf()` / `elec_notice_pdf()`
- `reportlab + platypus` **程序化绘制**,**不用 win32com / LibreOffice**(本机都没有) ## 系统骨架
- 字体 **`assets/fonts/simhei.ttf`(9.7MB,随项目分发)**,reportlab 子集嵌入,单份 10~32KB; - 启动 `python -m rentos.server --port 8899`;`--no-scheduler` 只开 Web;登录 admin / 1989@rent05
回落链 simhei → NotoSansSC → 系统 simhei/msyh → Linux wqy/noto → DejaVu → PingFang → CID `STSong-Light` - 导航 5 项:概览 / 电费通知单(带待发角标)/ 房租车位费 / 公司管理 / 设置;真实 `<a href="#/...">` hash 路由
- `rent_render.py` / `elec_render.py` 只做「分组 + 命名 + 落盘」,不再写 xlsx - 实现在 `app.js` 的 `PAGES` + `applyNav()`;**新增页面必须同时在 PAGES 与 `#nav` 登记**
- 版式:A4、`MARGIN = 1.9cm`;房租表 5 列、电费表 7 列,多单元按 `spans` 合并首列 - 侧栏/顶栏状态读 `/api/overview`(含 wecom-cli,慢);CSS 表格类名 `edgrid`
## 系统骨架(rentos 包 + web 前端) ## 名册(存 `data/config.json`,唯一读写入口 `rentos/directory.py`)
- 启动 `python -m rentos.server --port 8899`(`--no-scheduler` 只开 Web);登录 admin / 1989@rent05 - `companies`:租客/往来单位(`kind`=company|person,**名称即唯一键**)
- **导航 5 项**:概览 / 电费通知单(带待发角标)/ **房租车位费**(唯一台账 + 出单 + 批次)/ **公司管理** / 设置 - `owners`:出租方/汇款公司(**键=简称**,字段 name/bank/acct/address)
- 真实 `<a href="#/...">` + hash 路由;实现在 `app.js` 的 `PAGES` + `applyNav()`; - 当前 4 家:精锐 37 单元 / 科博 24 / 瑞格嘉德 16 / 国隆 8
**新增页面必须同时在 PAGES 与 `#nav` 里登记** - 语义:`None`/缺失=未维护过 → 回落 `DEFAULT_OWNERS`;**写过(哪怕 `{}`)= 权威**,可真正增删改
- 侧栏/顶栏状态读 `/api/overview`(含 wecom-cli,慢);CSS 表格类名是 `edgrid` - 归一 `_norm_name`(去空白 + 全角括号→半角 + 忽略大小写);改名**联动台账**
(公司改 F 列「现租客」、出租方改 A 列)返回 `renamed_rows`;删除有引用返回 409,`force=true` 才真删
## 名册(2026-10-09 第四轮 · 公司管理) - 归属三级优先:**行内显式 `owner` > 台账 A 列 > 名单兜底(仅当名单恰好一家)**
**两份权威名册**,都存 `data/config.json`,唯一读写入口是 `rentos/directory.py`: - ⚠️ **写入必须 `store.set_config()`**(顶层整体替换);`patch_config` 浅合并**删不掉字典键**
- `companies`:租客 / 往来单位(`kind` = company|person,**名称即唯一键**)
- `owners`:出租方 · 汇款公司(**键 = 简称**,字段 name/bank/acct/address)
- 语义:`None`/缺失 = 未维护过 → 回落 `DEFAULT_OWNERS`;**写过(哪怕 `{}`)= 权威**,可真正增删改
- 当前 **4 家**:精锐(37 单元)/ 科博(24)/ 瑞格嘉德(16)/ **国隆(8)** —— 国隆 2026-10-11 新增;
瑞格嘉德银行账号 31600703001391851 同日补齐(来源见 `samples/legacy/` 三份历史通知书 docx)
- 名称归一 `_norm_name`(去空白 + 全角括号→半角 + 忽略大小写);改名会**联动台账**
(公司改 F 列「现租客」、出租方改 A 列),返回 `renamed_rows`;删除有引用返回 409 + 清单,`force=true` 才真删
- 出租方归属三级优先:**行内显式 `owner` > 台账 A 列 > 名单兜底(仅当名单恰好一家)**
- 楼层硬编码已拆(`owner_of(floor)` 删除)——车位横跨三家,楼层判不出来
- ⚠️ **名单写入必须用 `store.set_config()`**(顶层整体替换);`patch_config` 是浅合并,**删不掉字典键**
- 播种 `data/seed/directory.json`;`directory.ensure_seeded()` 在 `server.main()` 里调 - 播种 `data/seed/directory.json`;`directory.ensure_seeded()` 在 `server.main()` 里调
## 数据模型(2026-10-09 第二轮改造后) ## 数据模型
- **唯一台账** `rent_parking`(「房租车位费」,**85 行 × 36 列 A~AJ**):三张源 sheet 合并 + 房产税表并入 - **唯一台账** `rent_parking`(85 行 × 36 列 A~AJ)
- `data/seed/rent_parking.json` 是播种源,首次启动拷到 `data/tables/`;旧两张表退休在 `data/tables/retired/` - `BILL_COLS`:owner=A unit=C tenant=F begin=I end=J rentfree=L cycle=N fee=O rent=P
- `BILL_COLS`:owner=A unit=C tenant=F begin=I end=J rentfree=L cycle=N fee=O rent=P last_paid=AB bill_start=AC **next_bill=AJ** last_paid=AB bill_start=AC **next_bill=AJ**
- **AD~AI 是房产组(2026-10-11 并入,见 `TAX_COLS`)**:AD 产证面积 / AE 取得成本 / AF 车位对应房间 / - AD~AI **房产组**(`TAX_COLS`):产证面积/取得成本/车位对应房间/季度计税租金/从租季度房产税/从价季度房产税
AG 季度计税租金 / AH 从租季度房产税 / AI 从价季度房产税 —— **纯登记,出单逻辑一律不引用** —— **纯登记,出单逻辑一律不引用**(守护 `test_tax_columns_not_used_by_billing`)
(守护测试 `test_tax_columns_not_used_by_billing`) - AJ「下一账单日」:**只用于定时排期**(推送日 = AJ − 提前天数);出单口径仍看 AC;
- **AJ「下一账单日」(2026-10-12 新增,分组「推送」,date)**:**只用于定时推送排期** 出单成功后 AC 与 AJ **一起**推进一个付款周期;AJ 为空的行不排期、也不自动补值
(推送日 = AJ − 提前天数);出单口径仍看 AC;出单成功后 AC 与 AJ **一起**推进一个付款周期; - 默认**锁定只读**;改单元格/增行/删行必须 `confirm=true`(否则 428);备份 `data/tables/.backup/`
AJ 为空的行不排期、也不会被自动补值 - **电费批次** `data/batches/{id}.json`:**只留原始数据**(details + totals + documents + 发送状态);
- 台账默认**锁定只读**;改单元格/增行/删行必须带 `confirm=true`(否则 428);备份在 `data/tables/.backup/` 不按文件组织,只按账期月份留档统计(上传能力已下线)
- **电费批次** `data/batches/{id}.json`:**只留原始数据**(details + totals + documents + 发送状态),
不再有 `source_file` / `stored_file` / `towers`(上传能力已下线);批次台账**不按文件**组织,只按账期月份留档统计 ## 业务口径(2026-10-09 拍板)
- 电费:`用量 = 本期 − 上期`;`空调 = 用量 × 电价 × 12.25`、**`照明` = 用量 × 电价**
## 业务口径(2026-10-09 巴特菲拍板) - 明细类目统一叫 **照明**;单据表头「房间号」是单元列,保留不改;只填上期数不填本期 → `pending` 不出单
- 电费计费:`用量 = 本期 − 上期`;`空调 = 用量 × 电价 × 12.25`、**`照明` = 用量 × 电价** - 台账:**8F/11F 付款周期统一 3 个月**;**8F/11F 不收物业费**(O 列留空)
- 明细类目统一叫 **照明**(原「房间」已全局改名,含已留档批次与单据); - 免租期:窗口 `[I, I+L月)`(含起始不含结束),0.5 月 = 15 天
单据表头「房间号」是单元列,**保留不改** - 两张账单(`plans.py`):`elec` 触发日 = 缴费期限 − 提前天数;`rent_parking`(2026-10-12 起)
- 只填上期数不填本期 → `pending`,不出单 触发日 = 台账 **AJ − 提前天数**,按行判定(`rent_state()`),到点自动出单+发送(`service.auto_issue_rent()`)
- 台账:**8F/11F 付款周期统一 3 个月**(来源合同表无此列);**8F/11F 不收物业费**(O 列留空)
- 免租期:`rentfree.py` 取台账 合同期始(I) + 免租期(L),窗口 `[I, I+L月)`,含起始不含结束,0.5 月 = 15 天 ## 房租车位费出单
- 账单**两张**:`elec` 电费 + `rent_parking` 房租车位费(`plans.py`) 链路:`rent_parser.py`(台账→明细/分组)→ `rent_render.py`(→ `notice_pdf`)→ `service.ingest_rent` → 批次
- **电费**:触发日 = 缴费期限 − 提前天数(`_trigger_at()`,看批次的 `due_date`) - 计费:期间起 = **AC**;止 = 起 + N 个自然月 − 1 天;`房屋租赁费 = P × N`、`物业费 = O × N`;
- **房租车位费**(2026-10-12 起):触发日 = 台账 **AJ 下一账单日 − 提前天数**,**按行判定**(`rent_state()`); 单元号含「车位」→ 车位租赁费;N 缺值/非法回落 `DEFAULT_MONTHS = 3`
到点**自动出单并发送**(`service.auto_issue_rent()`,批次打 `auto_issued` / `auto_issue_rule`) - **不可出单三条件**:空置(F 空或含「空置」)/ 每月租金 ≤ 0 / AC 为空(各带 `reasons`)
- 口径:出单后 AC + AJ **一起** +N 月(可一键回滚);房间费与车位费**合并一张**(键 `(出租方, 租客, 期间)`)
## 电费录入(页面手工,不再上传) - 推进/回滚走 `tables.update_cells(key, updates, who)`;`rollback_rent_advance(batch)` **只回滚当前值仍等于推进后的行**
- 进页自动带入上一期「已知信息」:上期抄表数 = 上期「本期读数」,另带 类目/单位/电价/系数(`fillFromPrev`) - 批次按 `kind` 隔离(`"elec"` / `"rent_parking"`):`store.list_batches(kind)` / `batch_months(kind)`
- ⚠️ **必须 `?kind=elec`**:`/api/batches` 不带 kind 返回所有类别混排(新的在前), - 接口:`GET /api/batches?kind=`、`GET /api/rent/preview?from=&to=`(`?on=` 兼容别名 = to)、
取 `batches[0]` 会拿到「房租车位费」批次 —— 房租明细没有 meter/factor, `POST /api/rent/batches`、`DELETE /api/batches/{id}` body `{"rollback": true}`、
会变成清一色「照明」+ 系数全空 + 房间号里冒出车位(2026-10-09 修过) `POST /api/batches/{id}/receipt`、`GET /api/batches/{id}/export`
- 选批次口径:**最近一期 `pending_count === 0`(抄完表的)**;一个都没有才退到「最近一期有读数的」 - **账期区间筛选(2026-10-13 新增)**:`classify_period(rows, from, to)` —— 命中 = **账期起始日落在区间内**;
- **保留源明细每一行**(308室 真有两块表);每个房间缺哪类目补一行空占位(空行不会被提交) 账期区间**取代出单基准日**(旧 `classify(rows, on)` 保留,定时器 `due_for_push` 仍用);
- 系数/电价填**实际值**(空调 12.25 / 照明 1.0 / 电价 1.2),类目一改系数跟着走 批次记 `period_filter_from` / `period_filter_to`;`_period_label()` 生成账期标签(`2026年12月` / 跨月 `2026年12月~2027年2月`)
- `loadElecDefaults()` 是 Promise 缓存(boot 里不 await,直接用 EDEF 有竞态) - 前端:台账页三张卡「本期出单 / 批次台账 / 批次详情」;容器映射 `BV={elec,rent_parking}` + `BKIND`;
- **电费只算房间,车位不抄电表**:`P.is_park(unit)`;三处都要挡 —— `batchOf(kind)` 校验 `BATCH.kind` **防跨栏目串台**
房间号候选(`/api/units` 的 **`rooms` / `room_owners`**,`units` 是含车位的全量口径)、
上一期带入(`isParkUnit`)、出单入库(`service.ingest_rows` 丢弃并回报 `skipped_park`)
- **备注栏可挂图片附件**(`rentos/attachments.py`,落 `data/attachments/`):
- `POST /api/attachments`(base64 或 data URL)→ id;`GET /api/attachments/{id}?token=` 在线查看
- 只收 png/jpg/gif/webp/bmp,单张 ≤ 8MB;`<img src>` 用 `?token=` 鉴权(`_token()` 支持)
- 明细行 `images: [{id,name,mime,size,url}]`,批次 `totals.image_count` 计数
- 迁移脚本 `tools/migrate_round2.py`(幂等):类目改照明、8F/11F 口径、清旧批次元数据
## 房租车位费出单(2026-10-09 第五轮 · 与电费同构)
链路:`rent_parser.py`(台账→明细/分组)→ `rent_render.py`(版式,复用 `build_notices`)→ `service.ingest_rent` → 批次
- **计费口径**:期间起 = 台账 **AC 列「账单起始日」**;期间止 = 起 + 付款周期(N) 个自然月 − 1 天;
`房屋租赁费 = 每月租金(P) × N`、`物业费 = 每月物业费(O) × N`;单元号含「车位」→ 出「车位租赁费」;
N 缺值/非法回落 `DEFAULT_MONTHS = 3`
- **不可出单三条件**:空置(F 列空或含「空置」)/ 每月租金 ≤ 0 / 账单起始日为空(各带 `reasons`)
- **拍板口径**:① 出单后**自动推进 AC 列** +N 个月(`advance=True`,可一键回滚)
② 默认**只列 AC ≤ 出单基准日**的行,可逐行勾选 ③ 房间费与车位费**合并一张**(分组键 `(出租方, 租客, 期间)`)
- **推进/回滚**:写台账走 `tables.update_cells(key, updates, who)` —— 单次落盘 + 单次备份;
批次存 `advance` 清单;`service.rollback_rent_advance(batch)` **只回滚当前值仍等于「推进后」值的行**
- **批次按 `kind` 隔离**(`"elec"` / `"rent_parking"`):`store.list_batches(kind)` / `batch_months(kind)`
- **接口**:`GET /api/batches?kind=`、`GET /api/rent/preview?on=&all=1`、`POST /api/rent/batches`、
`DELETE /api/batches/{id}` body `{"rollback": true}`(先回滚账期再删批次)
- **前端**:台账页(房租车位费)顶部三张卡「本期出单 / 批次台账 / 批次详情」;
两栏目容器映射 `BV={elec,rent_parking}` + `BKIND`;`batchOf(kind)` 校验 `BATCH.kind` **防跨栏目串台**
- 干跑预览(不写台账不建批次):`tools/rent_notice_preview.py --on … --notice-date … --due-date …` → `output/rent_preview/`
- 真实台账全量试跑(基准日 2026-10-09):64 行 → 到期 30 / 未到期 13 / 不可出单 21 → **25 份 ¥319,348.00**
### 2026-10-12 增补(收款开票 + PDF + 下一账单日) ### 2026-10-12 增补(收款开票 + PDF + 下一账单日)
- **输出 PDF**:`render_notice()` 改调 `notice_pdf.rent_notice_pdf()`,`fname = sheet + ".pdf"` - 文件名去公司名:`notice_stem(units)` 只要单元列表
- **文件名去公司名**:`notice_stem(units)` 只要单元列表 - **收款/开票**挂 `batch.documents[i]`:`paid_at` / `invoiced` / `invoiced_at`
- **双列账期推进**:`ingest_rent` 推进时同时写 AC 与 AJ(AJ 为空的行不动);
`rollback_rent_advance()` 按 `a["col"]` 分别回滚
- **收款 / 开票**(挂 `batch.documents[i]`):`paid_at` / `invoiced` / `invoiced_at`
- `service.set_receipt(bid, index, paid_at, invoiced, invoiced_at)`:勾选开票自动记今天,取消则清空 - `service.set_receipt(bid, index, paid_at, invoiced, invoiced_at)`:勾选开票自动记今天,取消则清空
- `service.receipt_summary(batch)`:份数 + 金额 + `last_paid_at` / `last_invoiced_at` - `service.receipt_summary(batch)`:份数 + 金额 + `last_paid_at` / `last_invoiced_at`
- **只加房租车位费批次**,电费批次不带这两列 - **只加房租车位费批次**,电费批次不带这两列
- **批次数据导出**:`service.export_batch_xlsx(bid)` → 三个 Sheet(单据 / 明细 / 汇总), - **批次导出** `service.export_batch_xlsx(bid)` → 三个 Sheet(单据/明细/汇总),落 `output/exports/`
落 `output/exports/{bid}-{费用}批次数据.xlsx` - ⚠️ `GET /api/batches/{id}` 的 `receipt` 在**响应顶层**,前端须显式接(`if (d.receipt) BATCH.receipt = d.receipt`)
- **定时自动出单**:`auto_issue_rent()` + `scheduler.rent_state()` / `_auto_pending()` - 迁移(已跑完):`tools/import_park_notes.py --apply` 写 31 行车位备注(AA);`migrate_next_bill.py --apply` 写 64 行 AJ
(**优先重试**上期 auto_issued 未发完的批次,防重复出单)
- **新接口**:`POST /api/batches/{id}/receipt`、`GET /api/batches/{id}/export`
⚠️ `GET /api/batches/{id}` 的 `receipt` 在**响应顶层**,前端须显式接(`if (d.receipt) BATCH.receipt = d.receipt`)
- **数据迁移**(已跑完):`tools/import_park_notes.py --apply` 写 **31 行**车位备注(AA 列);
`tools/migrate_next_bill.py --apply` 写 **64 行** AJ(21 行因 AC 空留空)
## 环境注意 ## 环境
- Python `C:/Users/fate/.workbuddy/binaries/python/versions/3.13.12/python.exe` - Python `C:/Users/fate/.workbuddy/binaries/python/versions/3.13.12/python.exe`(openpyxl / reportlab / pillow;**无 pypdfium2**)
(**已装 openpyxl / reportlab 5.0.1 / pillow 12.3.0**;**无 pypdfium2**)
- venv `~/.workbuddy/binaries/python/envs/default/Scripts/python.exe`:**Playwright + reportlab + pypdfium2** - venv `~/.workbuddy/binaries/python/envs/default/Scripts/python.exe`:**Playwright + reportlab + pypdfium2**
→ **跑单元测试与 UI 回归都用它**(读 PDF 文本核对版式需要 pypdfium2;缺了就自动 skip 文本断言) → **单测与 UI 回归都用它**(读 PDF 文本核对版式需 pypdfium2,缺了自动 skip 文本断言)
- `start.bat` / `stop.bat` 是 **GBK** 编码,改它必须用 `io.open(..., encoding="gbk")` - Bash 先 `export PATH=…/PortableGit/versions/1.2.0/usr/bin:…/bin:$PATH`
- Bash 需先 `export PATH=…/PortableGit/versions/1.2.0/usr/bin:…/bin:$PATH` - `.bat` 必须写 **cp936 + CRLF**(UTF-8 会被 cmd 按 GBK 解析崩掉);分支用 `goto` 标签;echo 文案不放半角括号/`!`
- 写 xlsx 前确认没在 Excel 打开(有 `~$` 即被占用);公式不重算 → 汇总数字构建期算好写静态值 - 写 xlsx 前确认没在 Excel 打开(有 `~$` 即被占用);公式不重算 → 汇总写静态值
- **改完后端代码必须 kill 所有在跑的实例再重启**(旧进程会按旧逻辑写同一个 `data/`,甚至自动发消息) - **改完后端代码必须 kill 所有在跑实例再重启**(旧进程会按旧逻辑写同一 `data/`,甚至自动发消息)
## 启动服务(2026-10-12 新增) ## 启动服务
- `start.bat [safe] [端口] [check]` —— 一键启动:定位 Python(托管 3.13.12 → 系统 3.11 → PATH)、 - `start.bat [safe] [端口] [check]`:定位 Python(托管 3.13.12 → 系统 3.11 → PATH)、缺 openpyxl 自动装、
缺 `openpyxl` 自动 pip 装、清理端口旧实例(**只杀 python 进程**,非 python 占用则报错退出)、 清端口旧实例(**只杀 python 进程**)、等就绪、开浏览器;服务跑独立窗口
等端口就绪、自动开浏览器。服务跑在独立窗口「通知单系统 - 服务」。 - `stop.bat [端口]`:停服务;非 python 占用不处理
- `stop.bat [端口]` —— 停服务;非 python 占用不处理。 - `config.json` 的 `notify.enabled=True` → 双击 `start.bat` **会开定时发送**;
- **`.data/config.json` 的 `notify.enabled = True`** → 双击 `start.bat` **会开启定时发送**。 只看页面用 `start.bat safe`(`--no-scheduler`);**会话内启动一律走 safe**
只看页面时用 `start.bat safe`(= `--no-scheduler`)。会话内启动一律走 safe。
- **.bat 必须写 cp936 + CRLF**。UTF-8(哪怕加 `chcp 65001`)会被 cmd.exe 按 GBK 逐行解析, ## 云端部署(192.168.68.121)
中文注释把语句结构撑坏,报一堆「不是内部或外部命令」。转换: - **地址 `http://192.168.68.121:8899/`**(admin / 1989@rent05),容器名 `rentos`,`--restart unless-stopped`;CentOS 7,root / 12345678
```python - **主用 Git 同步**:`RENTOS_SSH_PASSWORD=... python tools/deploy_git.py sync`(另有 setup/pull/build/run/status/logs)
raw = io.open("start.bat", encoding="utf-8").read() - 代码落 `/opt/rentos/repo`,与数据同级独立 → `git reset --hard` 不碰线上数据
raw = raw.replace("\r\n","\n").replace("\n","\r\n") - 服务器**可直连** git 站点(走 IPv4 0.2s;IPv6 不通);凭据由 setup 经 SFTP 直写 `~/.git-credentials`(600)
io.open("start.bat","wb").write(raw.encode("cp936")) - 降级(服务器访问不到 Git 时):`python tools/deploy_docker.py deploy`(本机打包 SFTP 上传,解包进同一 `repo/`)
``` - 构建 `docker build -f deploy/Dockerfile -t rentos:latest .`,**上下文 = 仓库根**;构建期零联网
写 .bat 的额外禁忌:分支用 `goto` 标签而非嵌套 `if/else` 括号;echo 文案里不放半角括号; (`deploy/wheels/` + `deploy/wecom/*.tgz` + `assets/fonts/simhei.ttf` 随仓库走)
`enabledelayedexpansion` 下 echo 文案里不放 `!`。 - **必须用 `-f`**(Dockerfile 在 deploy/ 下);**切勿 `docker build … | tail`**(管道吞掉失败,会用旧镜像重启)
- 服务器布局 `/opt/rentos/`:`repo/`(代码)· `data/` `output/` `logs/` `uploads/` `wecom-config/`(bind volume)
## 部署目标服务器(192.168.68.121 · 2026-10-12 首次接触) - **服务器无 Python 3**(yum 仅 3.6.8)→ 迁移只能 `docker exec rentos python /app/tools/xxx.py`
- 登录 `root` / `12345678`,**CentOS 7**。本机无 `sshpass` → 用 venv 的 **paramiko 5.0.0** 走密码 SSH/SFTP: - **`docker -v` 的 SELinux 选项是冒号 `:z`** 不是逗号 `,z`(写错症状:容器正常页面 200 但台账 0 行)
`~/.workbuddy/binaries/python/envs/default/Scripts/python.exe tools/deploy_server.py probe`,密码经 - 云端**已能直发企业微信**:`~/.config/wecom/`(`.encryption_key` 磁盘明文 + `credentials.enc`)可移植;
环境变量 `RENTOS_SSH_PASSWORD` 传入(不写进代码/命令行)。部署脚本 `tools/deploy_server.py`。 二进制放镜像 `/usr/local/bin/wecom-cli`(`shutil.which` 自动命中),凭据放宿主 `/opt/rentos/wecom-config/`(700)
- **无 Python 3**:`yum` 只有 3.6.8,而项目用到 `subprocess.run(capture_output=, text=)` 是 **3.7+** 语法 → 装系统 Python 不可行。 - ⚠️ **线上是唯一生产方,本机只作预览/开发**;台账以线上为准。**本机与云端不要同时开定时器**(会重复发送)
- 有 Docker 26.1.4 + systemd,8899 端口空闲,firewalld 开启。 - **本机 git 网络坑**:沙箱 `https_proxy=127.0.0.1:12934` 致 push `CONNECT tunnel failed 502` →
- **外网出口很窄**:`registry-1.docker.io` 超时、`github.com` 超时、`pypi.org` 要 12s。 需绕过沙箱并 `env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY git push`
- **Docker 代理已清除**(原 `proxy.conf` 指向死掉的 `192.168.68.164:61624`):
备份在 `/root/docker-proxy-backup-20261012/`,`docker.service.d/` 现为空目录。 ## 版本管理(Git)
- **原阿里云加速器已失效**(403)。可达候选:`docker.m.daocloud.io`(401/0.15s,最快)、 - 远端 `https://git.thiztech.com/accounting/rentos.git`(自建 Gitea;**http 会 301 跳 https**);分支 `main`
`docker.1ms.run`、`docker.xuanyuan.me`、`dockerproxy.net`、`hub.rat.dev`。 - **纳入**:`rentos/` `web/` `tools/` `templates/` `samples/` `assets/` `data/tables/` `data/seed/` `data/config.json`
- 本机已有镜像含 `python:3.11.3-slim`、`mysql:8.0`、`postgres:latest` → **Docker 部署可直接复用,无需拉取**。 `deploy/`(Dockerfile + wheels + 企微 tgz)、`output/*.md`、`.workbuddy/memory/`
- **镜像源已修**:`daemon.json` 的 `registry-mirrors` 换成 `docker.m.daocloud.io` → `docker.1ms.run` - **排除**:`data/sessions.json`(**含活跃登录 token**)、`scheduler_state.json`、`send_log.json`、`data/batches/`、
→ `docker.xuanyuan.me`(原阿里云个人版已停服 403)。`docker pull` 已实测可用。 `data/attachments/`、`data/_snap/`、`data/tables/.backup/`、`output/`(仅放行 `*.md`)、`uploads/`、`logs/`
- `.gitattributes`:`* text=auto eol=lf`;`*.bat -text`(保持 GBK+CRLF);二进制 `binary`;**Dockerfile 必须 LF**
## 云端部署(2026-10-13 改为 Git 同步 · Docker) (`\` 续行带 `\r` 会让 Linux 构建解析错误)
- **地址 `http://192.168.68.121:8899/`**(admin / 1989@rent05),容器名 `rentos`,`--restart unless-stopped`。
- **主用(Git 同步)**:`RENTOS_SSH_PASSWORD=... python tools/deploy_git.py sync`
(另有 setup / pull / build / run / status / logs)。服务器**可直连** git 站点(走 IPv4,0.2s;
服务器侧 IPv6 不通),代码落在 `/opt/rentos/repo`,与 `data/` 等同级独立 →
`git reset --hard` 不会碰线上数据。凭据由 setup 经 **SFTP 直写** `~/.git-credentials`(600)。
- **降级(服务器访问不到 Git 时)**:`python tools/deploy_docker.py deploy`(本机打包 SFTP 上传,
解包进同一个 `repo/`)。两条通道共用一份 `deploy/Dockerfile`,可互相接管。
- 构建 `docker build -f deploy/Dockerfile -t rentos:latest .`,**上下文 = 仓库根**;
`deploy/wheels/`(openpyxl / reportlab / pillow / charset-normalizer)+ `deploy/wecom/*.tgz`
+ `assets/fonts/simhei.ttf` 全随仓库走,构建期**零联网**(底座 python:3.11.3-slim 服务器本地已有)。
- **Dockerfile 必须用 `-f` 指定**(它在 deploy/ 下);**切勿写 `docker build … | tail`** ——
管道退出码来自 tail,会吞掉构建失败并让你用旧镜像重启容器。
- 服务器布局 `/opt/rentos/`:`repo/`(代码)· `data/` `output/` `logs/` `uploads/` `wecom-config/`(**bind volume**)。
- 镜像内含 `tools/`:**服务器无 Python 3**(yum 仅 3.6.8),台账迁移只能
`docker exec rentos python /app/tools/xxx.py`。
- **`docker -v` 的 SELinux 选项是冒号 `:z` 不是逗号 `,z`**。写成逗号 docker 会把 `,z` 并入容器路径
(挂到 `/app/data,z`),症状是「容器正常、页面 200、但台账 0 行」。
排查:`docker inspect rentos --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'`
- 云端**已能直发企业微信**;**本机与云端不要同时开定时器**,会重复发送。
- ⚠️ **线上账期领先本机**(线上 2026-10-09、10-12 三次出单共 44 行,AC 已到 2027-01/02/03)→
**本机不应再出单**,只作预览/开发,云端为唯一生产方。台账以线上为准
(2026-10-13 已双向对齐,两侧逐格一致)。
- **本机 git 网络坑**:沙箱 `https_proxy=http://127.0.0.1:12934` 会让 push 报
`CONNECT tunnel failed, response 502` → 需绕过沙箱并
`env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY git push`。
## 企业微信发送(2026-10-12 · 打通云端直发)
- `rentos/wecom.py` 调的 `@wecom/cli` 是**壳**,真身是平台原生二进制;官方有
**`@wecom/cli-linux-x64`**(Rust 静态链接,无 glibc 版本要求,任意 Linux 可跑)。
下载:`curl -sL https://registry.npmjs.org/@wecom/cli-linux-x64/-/cli-linux-x64-1.3.4.tgz`
- **凭据可移植**:`~/.config/wecom/`(`.encryption_key` 是**磁盘上的明文密钥** +
`credentials.enc`)。拷这两个文件到别的机器即可复用授权,实测容器内有效。
- 云端落地:二进制放镜像 `/usr/local/bin/wecom-cli`(`shutil.which` 自动命中,**无需改 `wecom.py`**);
凭据放宿主 `/opt/rentos/wecom-config/`(700)挂到容器 `/root/.config/wecom`(容器 HOME=/root)。
- 脚本:`tools/setup_wecom_cloud.py`(push / probe / all,密码走 `RENTOS_SSH_PASSWORD`)。
- 发送目标:`config.json` 的 `wecom.chat_id` 为空 → 回退**授权人本人**;`sessions_count` 长期为 0
(机器人未加入任何群),属正常。
- **注意**:本机和云端各有独立定时器,两边同时跑会重复发送 → 云端当主力,本机用 `start.bat safe`。
## 版本管理(2026-10-13 · Git)
- 远端 `https://git.thiztech.com/accounting/rentos.git`(自建 Gitea;**用 http 会被 301 跳 https**)。
凭据 `wenwen.tang@thizgroup.com` 已存 `credential.helper=store`;分支 `main`;首次提交 `d635a33`
- **纳入**:源码 `rentos/` `web/` `tools/`、模板 `templates/`、样张 `samples/`、字体 `assets/`、
台账 `data/tables/` + 播种 `data/seed/` + `data/config.json`、部署件 `deploy/`(Dockerfile + wheels + 企微 tgz)、
`output/*.md` 验收报告、`.workbuddy/memory/`
- **必须排除**:`data/sessions.json`(**含活跃登录 token**)、`scheduler_state.json`、`send_log.json`、
`data/batches/`、`data/attachments/`、`data/_snap/`、`data/tables/.backup/`、
`output/`(仅放行 `*.md`)、`uploads/`、`logs/`、`deploy/_stage/`、`deploy/wecom/x/`、`tools/shots*/`
- **换行**(`.gitattributes`):`* text=auto eol=lf` —— 文本统一 LF 存储;`*.bat -text` 保持 GBK+CRLF;二进制 `binary`
- 本机系统级 `core.autocrlf=true`(来自 WorkBuddy vendored PortableGit),项目原生文件**全是 CRLF**;
`.gitattributes` 优先级高于 autocrlf,故跨机行为一致
- **Dockerfile 必须 LF** —— 续行符 `\` 带 `\r` 会让 Linux 构建解析错误
- 台账索引 LF / 工作区 CRLF,靠归一化保证 `git status` 干净,**不会假 diff**(程序频繁写台账也不受影响)
- **本机 git 坑(已修)**:系统级 `credential.helper=helper-selector`(WorkBuddy 定制)在每次认证后会去写
系统 gitconfig,失败则留下 0 字节 `gitconfig.lock` 并反复报错。已在仓库本地绕过:
`git config --local credential.helper ""` + `--add credential.helper store`(只影响本仓库)
## 验证脚本(tools/) ## 验证脚本(tools/)
`test_rules.py` `test_scheduler.py` `test_directory.py` `test_rent.py` `test_round5.py`(单元测试,全量 **147 项**)/ - 单测:`test_rules.py` `test_scheduler.py` `test_directory.py` `test_rent.py` `test_round5.py`(全量 **212 项**)
`ui_verify.py`(台账+电费录入)/`ui_verify2.py`(设置页+锁定+撤销)/`ui_verify3.py`(自动带入/照明/图片/月度留档)/ - UI 回归:`ui_verify.py`(台账+电费录入) `ui_verify2.py`(设置+锁定+撤销) `ui_verify3.py`(带入/照明/图片/留档)
`ui_verify4.py`(公司管理/名册候选)/`nav_verify.py`/ `ui_verify4.py`(公司管理/名册) `ui_verify5.py`(房租出单全链 43 项) `ui_verify6.py`(收款开票+导出 29 项)
`ui_verify5.py`(**房租车位费出单全链**:预览/勾选/出单+账期推进/批次详情/打包/类别隔离/删除+回滚,40 项)/ `ui_verify8.py`(账期区间端到端 30 项) `nav_verify.py`
`ui_verify6.py`(**收款开票两列 + 批次导出**:两列渲染/日期登记/勾选记开票日期/统计条/导出真下载/电费隔离,29 项)/ - 端到端:`e2e_round5.py`(PDF 出单 → 下载验 `%PDF-` → 收款开票 → 导出 xlsx → zip → 排期 → 删除并回滚还原)
`e2e_round5.py`(**第五轮全链端到端**:PDF 出单 → 下载验 `%PDF-` → 收款开票登记 → 导出 xlsx → zip → 排期 → 删除并回滚还原)/ - 迁移:`import_park_notes.py`(车位备注→AA) `migrate_next_bill.py`(AJ=AC) `import_property_tax.py`(AD~AI,两段式)
`import_park_notes.py`(车位「使用分类」→ 台账 AA 备注,**只填空备注**,干跑 / `--apply`)/ - 都支持 `RENTOS_BASE` 换端口;跑完还原现场设置
`migrate_next_bill.py`(给台账补 AJ = AC,幂等,干跑 / `--apply`)/ - **断言不要写死行数/列数/家数** —— 读接口现取;表格**按表头文字定位**,不按第 N 列
`verify_elec_prev.py`(电费「重新带入上期」定点验证,21 项,纯前端只读不落盘)/
`import_property_tax.py`(房产税表 → 台账 AD~AI + 补录新单元,干跑 / `--apply` 两段式;三张 sheet 排版有瑕疵,按实测坐标读)
- 都支持 `RENTOS_BASE` 换端口;跑完会把现场设置还原
- **断言不要写死行数/列数/家数** —— 一律读接口(`/api/tables/{key}`、`/api/owners`、`/api/companies`)现取;
表格列一律**按表头文字定位**,不按第 N 列(备注列、出租方列、房产列都是后来加的,硬编码必假失败)
- `restore_batches.py`:从 `uploads/` 源表 + `send_log.json` + 成品 xlsx 反推重建批次存档
(默认预演落 `data/_restore_tmp`,`--apply` 才写盘;比对用**逐格语义比对**,字节 sha 会因 xlsx 时间戳假阳性)
## 工作规矩(巴特菲) ## 工作规矩(巴特菲)
- **UI 回归(`ui_verify*.py` / `nav_verify.py`)不要每次小改动都跑**,等他发话再跑;小改动只跑单元测试 - **UI 回归不要每次小改都跑**,等他发话;小改动只跑单元测试
- 跑 UI 回归前先给 `data/batches/`、`data/tables/rent_parking.json`、`data/config.json` 拍快照到 `data/_snap/` - 跑 UI 回归前先快照 `data/batches/`、`data/tables/rent_parking.json`、`data/config.json` 到 `data/_snap/`
- **回归脚本删行必须按行 id 定位**(`data-delrow` 就是 `_id`),绝不可取「DOM 最后一行」—— - 回归脚本删行**必须按行 id 定位**(`data-delrow` = `_id`),绝不可取「DOM 最后一行」
曾因一次「新增没生效」而误删真实数据行(504室)。新增后先断言恰好多 1 行,找不到就跳过删除 - `output/rent/<批次号>` 是**目录**;台账确认框「行」标签 = 房间号优先 + 现租客(C 列)
- `output/rent/<批次号>` 是**目录**不是文件,清理产物要能处理目录(`shutil.rmtree`) - **数据一致性铁律**:线上台账权威,禁用本机数据覆盖线上
- 台账确认框的「行」标签 = **房间号优先 + 现租客**(`C` 列优先,不是 `D` 面积列)
## 待巴特菲拍板(见规则文档 08-待确认事项) ## 待巴特菲拍板
多办公地是否带公司名、车位是否算「办公地」、同名单据加期间、电费 0 用量是否出单、 多办公地是否带公司名、车位是否算「办公地」、同名单据加期间、电费 0 用量是否出单、
4F/5F 与爱种草渠道口径、通知日期/缴费期限口径、是否做收款核销(1934 行流水)。 4F/5F 与爱种草渠道口径、通知日期/缴费期限口径、是否做收款核销(1934 行流水)。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment