Commit 0b01156f authored by wenwen.tang's avatar wenwen.tang 😕

docs(memory): 加「合同原件」章节;精简 MEMORY.md(17.6KB→13.6KB 并保持口径完整)

parent bcbfeeba
# 2026-10-15 · 房租车位费台账「合同原件」列
## 需求(巴特菲)
> @项目合约/ 房租车位费数据表需要支持上传合同留档并可查阅合同,增加合同原件的列。
## 交付
**新列 AL「合同原件」**(`type=files`,排在 AJ 之后、最右),一格可挂多份
(租赁合同 + 补充协议 + 车位合同)。单元格存紧凑 JSON `[{"id","name"}]`,文件本体独立存放。
**新增 `rentos/contracts.py`**(对照 `attachments.py`,但白名单与上限不同)
- `data/contracts/{id}.{ext}` + `index.json`;id = uuid4 hex;`clean_id` 挡路径穿越 + `_safe_name` 去目录
- 白名单 pdf/doc/docx/xls/xlsx/png/jpg/gif/webp/bmp,单份 **≤ 20MB**(合同扫描件比图片大)
- `save / find / path_of / list_all / remove / parse / dump / describe`
- `parse` 容错:JSON 数组 / 纯 id 串 / 脏项 / 去重;`dump` 空列表 → `""`
- `describe` 给前端补 url / size / at / exists(文件被删也留位置提示)
- `_decode` 兜底规则:data URL 带的类型不认识时**回退按扩展名**猜(否则 octet-stream 会被拒)
**`rentos/config.py`**:加 `CONTRACT_DIR = data/contracts`,进 `ensure_dirs()`
**`rentos/tables.py`**
- `TYPES` 加 `files`;`_c("AL","合同原件","","files",168)` 排在 `AJ` 之后
- `coerce(ctype='files')` → `_files_value()`:list → 紧凑 JSON;`""`/`"[]"` → `None`
- 新增 `files_text()`:导出 xlsx 时该列输出**文件名文本**(多份换行 + `wrap_text`)
- 列定义以代码为准 → **加列不需要数据迁移**(实测线上/本地都无需 migrate)
**`rentos/server.py`**
- `GET /api/contracts`(列表)· `GET /api/contracts/{id}`(内联预览 PDF/图片,`?dl=1` 下载,支持 `?token=`)
· `POST /api/contracts`(上传)· `DELETE /api/contracts/{id}`
- `_CTYPES` 补 doc/docx/xls/xlsx/png/jpg/jpeg;**权限**:合同写接口不匹配任何 `CAP_ELEC` 规则 →
落到 `_write_cap` 兜底 `"*"` = **仅管理员**;访客只读能查阅、不能增删
**前端 `web/app.js` / `admin.html` / `style.css`**
- `filesOf / fileUrl / rowLabelOf / filesValueOf / ensureFileIndex / setFiles / openFilesDlg /
renderFilesDlg / pickContract / removeContract`(`FDLG` 当前行、`FINDEX` 索引缓存,进页重置)
- `td.files` 渲染按钮:有合同 → `📎 N 份`;编辑态空 → `+ 合同`;只读空 → `—`
- 浮层:文件名 / 大小 / 上传时间 + 查阅 / 下载 / 移除,底部「上传合同」;
**增删走 `stageCell(rowId,'AL','files',…)` 暂存**,与台账「保存 N 处改动」同一口径;
只读态浮层无上传/移除,文案改为「需要增删请先勾选编辑模式」
- `startEdit` 对 `files` 列直接 return;`delRow`/`startEdit` 的行标签统一走 `rowLabelOf()`
- `fmtVal` 加 `files` 分支(历史/确认框里显示文件名列表)
**批量导入 `tools/import_contracts.py`**(默认干跑)
- 解析文件名**开头**的单元号串:`1101、1115` / `124.155号` / `132&227车位` / `501-502室` / `601A&702` / `169`
- 每个号按台账实际存在的 `n室` / `n车位` 定夺(两者都在时看「车位/号」线索)
- 干跑结果:**29 个文件 → 35 个单元,零未匹配、零歧义**(详见下方清单要点)
- `--apply` 写入(追加;`--replace` 覆盖)
**`.gitignore`**:加 `data/contracts/`(运行时数据)与 `项目合约/`(93MB + 商业条款,不入库)
## 验证
- 单测 **328 项全绿**:新增 `test_contracts.py` **20 项**(解码/存取/穿越/列值容错/coerce/files_text/
台账写读+导出 xlsx/目录隔离);`test_rules` 3 处列数断言 31→32(含「合同列排最后」)
- API 冒烟 **`tools/smoke_verify_contracts.py` 24 项全过**(经 runner 沙箱):
上传→查阅 200+application/pdf+字节一致→`?dl=1` 是 attachment→写 AL→导出 xlsx 是文件名→
访客上传/删 403 但可查阅→非法类型 400→穿越 404→清空 AL→删文件后 404
- UI 回归 **`tools/ui_verify12.py` 30 项全过**(自带沙箱,真浏览器):
表头「合同原件」在最后一列 / 只读无上传入口 / 编辑态 `+ 合同` → 浮层 → 真实文件选择器上传 →
浮层出现文件与三个操作 → 查阅链接带 token 且返回原字节 → 关浮层格子变 `📎 1 份` 且 pending →
保存后待保存条消失 + AL 落盘 → 只读浮层只有查阅/下载 → 移除 → AL 清空但**服务器文件仍在** → 无 console 错误
- **修掉一个真 bug**:`renderGrid` 的 `files` 分支原先读台账原值 `raw`,
上传(暂存未保存)后格子仍显示「+ 合同」→ 改为优先读 `TB_PEND` 暂存值
## 代码提交
- 待提交:`rentos/contracts.py`(新) `rentos/config.py` `rentos/tables.py` `rentos/server.py`
`web/app.js` `web/admin.html` `web/style.css` `.gitignore`
`tools/test_contracts.py`(新) `tools/smoke_verify_contracts.py`(新) `tools/ui_verify12.py`(新)
`tools/import_contracts.py`(新) `tools/test_rules.py` `tools/ui_verify_runner.py`
`tools/ui_verify7/8/9/10/11.py` `tools/check_scheduler.py`
## 待办
- **29 份合同要不要真导入**(本地 / 线上)——工具已就绪,干跑零误判,等巴特菲发话
- 9 月电费批次错计(10 行 ¥167,947.41 / 少计 601B ¥299.67 / 出租方错记精锐)仍未处置
- `ui_verify11` 里「保存修改」按钮的断言需同步改写(10-14 删了该按钮)
# 租金通知系统 · 项目长期记忆 # 租金通知系统 · 项目长期记忆
## 目标 & 命名 ## 目标 & 命名
三份 Excel → 固定模板 + 数据填充 → **PDF 收费通知书**(2026-10-12 起),自动发企微 + Web 维护。 三份 Excel → 固定模板 + 数据填充 → **PDF 收费通知书**,自动发企微 + Web 维护。
- 文件名 = `{单元列表}-{费用类型}-收费通知书.pdf`;单元按 `B.unit_sort_key` 排序、`、` 连接;**不含公司名** - 文件名 = `{单元列表}-{费用类型}-收费通知书.pdf`;单元按 `B.unit_sort_key` 排序、`、` 连接;**不含公司名**
- 费用类型词序固定:房租 → 物业费 → 车位费(`&` 合法) - 费用类型词序固定:房租 → 物业费 → 车位费
## 数据模型 ## 数据模型
- **唯一台账** `rent_parking`(85 行 × **31 列** = A~AC + **AK** + AJ) - **唯一台账** `rent_parking`(85 行 × **32 列**)
- `BILL_COLS`:owner=A unit=C tenant=F begin=I end=J rentfree=L **free_type=AK** cycle=N fee=O rent=P - `BILL_COLS`:owner=A unit=C tenant=F begin=I end=J rentfree=L **free_type=AK** cycle=N fee=O rent=P
last_paid=AB bill_start=AC next_bill=AJ last_paid=AB bill_start=AC next_bill=AJ;另 **AL「合同原件」**(type=`files`,排最右)
- **AK「免租类型」**(2026-10-13 新增,**显示在 L 之后**):`首N月免租` / `每年8-9月免租`,空=无免租 - **AK「免租类型」**(显示在 L 之后):`首N月免租` / `每年8-9月免租`,空=无免租
- AJ「下一账单日」**只用于定时排期**(推送日 = AJ − 提前天数);出单口径看 AC;出单后 AC 与 AJ 一起推进 - AJ「下一账单日」**只用于定时排期**(推送日 = AJ − 提前天数);出单口径看 AC;出单后 AC/AJ 一起推进
- **AD~AI 房产/房产税六列已删**(`RETIRED_TAX_COLS`,守护 `test_retired_cols_not_used_by_billing`) - **AD~AI 房产/房产税六列已删**(`RETIRED_TAX_COLS`,有守护测试);列定义**以代码为准**
(`load_table` 用 spec 覆盖存档列)→ **加列不需要数据迁移**
- 默认锁定只读;改单元格/增行/删行必须 `confirm=true`(否则 428);备份 `data/tables/.backup/` - 默认锁定只读;改单元格/增行/删行必须 `confirm=true`(否则 428);备份 `data/tables/.backup/`
- **电费批次** `data/batches/{id}.json`:只留原始数据(details/totals/documents/发送状态),按账期月份留档; - **电费批次** `data/batches/{id}.json`:只留原始数据,按账期留档;测试残留移入
测试残留移入 `data/batches/_trash/YYYYMMDD/`(store 只 listdir 顶层 `*.json`,子目录隐身、可恢复) `data/batches/_trash/YYYYMMDD/`(store 只 listdir 顶层 `*.json`,子目录隐身、可恢复)
- 已有回收站:`_trash/20261013/`(17 张)、`_trash/20261014/`(14 张,output 同批移入 `output/elec/_trash/`) - **账期口径**:**批次 = 点一次「生成通知书」的产物**(`YYYYMMDD-HHMMSS`);账期 `period_label`
- **账期口径**(2026-10-14 确认):**批次 = 点一次「生成通知书」的产物**(号 `YYYYMMDD-HHMMSS` · 只是批次上的**文本字段**,`list_periods()` 按它归组 = 页面「账期汇总」(组内 `created_at` 倒序)
`make_batch_id` 纯 ASCII 时间戳);**账期 `period_label` 只是批次上的一个文本字段,不是独立实体**, - 来源:① 录入页「所属期间」(默认今天年月;带入上期时 +1 月)② `elec_parser._extract_period`
`list_periods()` 按它归组即为页面上的「账期汇总」(组内按 `created_at` 倒序) ③ 后端兜底用**通知日期**年月
- 来源:① 录入页「所属期间」(默认今天年月;点「带入上一期」时 `nextPeriod()` +1 月)—— 主要 - ⚠️ **自由文本、无归一化**:`strip()` 后精确比较 →「2026-11」与「2026年11月」会分成两组
② Excel 解析 `elec_parser._extract_period`(先正则抓 A1 标题「YYYY年M月」,再退抄表日期年月) - 同账期多张批次正常(分批录 / 按公司分批 / 补录)→ 台账按账期汇总并默认折叠
③ 后端兜底 `ingest_rows`:未传时用**通知日期**的年月
- ⚠️ **自由文本、无格式归一化**:匹配是 `strip()` 后精确比较 → 「2026-11」与「2026年11月」会分成两组; ## 免租(`rentos/rentfree.py`)
空值归「(未标账期)」
- 同账期多张批次是正常的(分批录 / 按公司分批 / 补录),故台账按账期汇总并默认折叠
## 免租(`rentos/rentfree.py`,2026-10-13 重写为两种类型)
- `首N月免租`:窗口 `[I, I+L月)`,含起始不含结束;0.5 月 = 15 天 - `首N月免租`:窗口 `[I, I+L月)`,含起始不含结束;0.5 月 = 15 天
- `每年8-9月免租`:合约期 `[I, J]` 内每个自然年 `[max(该年08-01, I), min(该年10-01, J+1天))`(跨年裁剪) - `每年8-9月免租`:合约期 `[I, J]` 内每个自然年 `[max(该年08-01,I), min(该年10-01, J+1天))`(跨年裁剪)
- `hit()` 按时点;`overlaps()` 按账单期间交集(「每年8-9月」必需,通知日通常早于该期) - `hit()` 按时点;`overlaps()` 按账单期间交集(「每年8-9月」必需)
- ⚠️ `windows()` 返回键是 **`begin`/`end`**(非 from/to),另有 `type`/`months`/`year`/`key` - ⚠️ `windows()` 返回键是 **`begin`/`end`**(非 from/to),另有 `type`/`months`/`year`/`key`
- 另有 `types()` / `windows()` / `active()` / `describe()` / `strip()`
## 名册(`data/config.json`,唯一入口 `rentos/directory.py`) ## 名册(`data/config.json`,唯一入口 `rentos/directory.py`)
- `companies` 租客/往来单位(`kind`=company|person,**名称即唯一键**);`owners` 出租方(**键=简称**) - `companies` 租客(名称即唯一键,`kind`=company|person);`owners` 出租方(**键=简称**)
- 当前 4 家:精锐 37 / 科博 24 / 瑞格嘉德 16 / 国隆 8 - 当前 4 家:精锐 37 / 科博 24 / 瑞格嘉德 16 / 国隆 8
- `None`/缺失 = 未维护过 → 回落 `DEFAULT_OWNERS`;**写过(哪怕 `{}`)= 权威**,可真增删改 - `None`/缺失 = 未维护过 → 回落 `DEFAULT_OWNERS`;**写过(哪怕 `{}`)= 权威**
- 归一 `_norm_name`;改名**联动台账**(F 列/A 列);删除有引用 → 409,`force=true` 才真删 - 改名**联动台账**(F 列/A 列);删除有引用 → 409,`force=true` 才真删
- 归属三级优先:**行内 `owner` > 台账 A 列 > 名单兜底(仅当名单恰好一家)** - 归属三级优先:**行内 `owner` > 台账 A 列 > 名单兜底(仅当名单恰好一家)**
- ⚠️ 写入必须 `store.set_config()`(顶层整体替换);`patch_config` 浅合并**删不掉字典键** - ⚠️ 写入必须 `store.set_config()`(顶层整体替换);`patch_config` 浅合并**删不掉字典键**
## 业务口径 ## 业务口径
- 电费:`用量 = 本期 − 上期`;`空调 = 用量 × 电价 × 12.25`、**`照明` = 用量 × 电价**(类目统一叫「照明」) - 电费:`用量 = 本期 − 上期`;`空调 = 用量 × 电价 × 12.25`、**`照明` = 用量 × 电价**
- 只填上期不填本期 → `pending` 不出单;**车位不抄电表**(`P.is_park`,候选/带入/入库三处都要挡) - 只填上期不填本期 → `pending` 不出单;**车位不抄电表**(`P.is_park`,候选/带入/入库三处都要挡)
- 8F/11F **付款周期统一 3 个月**、**不收物业费**(O 列留空) - 8F/11F **付款周期统一 3 个月**、**不收物业费**(O 列留空)
- 两张账单(`plans.py`):`elec` 触发日 = 缴费期限 − 提前天数;`rent_parking` 触发日 = 台账 **AJ − 提前天数**, - 两张账单(`plans.py`):`elec` 触发日 = 缴费期限 − 提前天数;`rent_parking` = **AJ − 提前天数**,
按行判定(`rent_state()`),到点自动出单+发送(`service.auto_issue_rent()`) 按行判定(`rent_state()`),到点自动出单+发送(`service.auto_issue_rent()`)
## 电费核对(2026-10-13 改「行」粒度) ## 电费核对(行粒度)
- 行键 = `单元|类目`(如 `302室|照明`);`verify = {units:[], rows:[], done, at, by, scope}` - 行键 = `单元|类目`(如 `302室|照明`);`verify = {units:[], rows:[], done, at, by, scope}`
(旧 `units` 按整室自动展开,向后兼容;归一后只留 `rows`,否则取消某行会被 units 抵消) - `verify_summary()["ok"] = done`(**确认即可,允许部分核对**);`partial` = done 且有未勾行
- `verify_summary()["ok"] = done`(**已点确认即可,允许部分核对**);`partial` = done 且有未勾行 - **自动撤销**:确认时把勾选快照存 `scope`;之后勾选少于 scope → 撤销确认
- **自动撤销**:确认时把勾选快照存 `scope`;之后进度保存若勾选少于 scope → 撤销确认
- 发送闸门:未确认 → 409;部分核对发送前前端 `partialVerifyWarn()` 二次提醒 - 发送闸门:未确认 → 409;部分核对发送前前端 `partialVerifyWarn()` 二次提醒
- 账期级:`period_summary` 行并集,一行算已核对 ⇔ **含它的每张批次都勾了它**; - 账期级:一行算已核对 ⇔ **含它的每张批次都勾了它**;`POST /api/periods/verify` 一次写全账期
`POST /api/periods/verify` 一次写全账期 - 核对表**可直接编辑**,**改数据不影响核对状态**;`confirmVerify()` 两步:
- 前端:核对模式按行勾选 + 列显示菜单(`localStorage['rentos_vcols']`,「核对」列不可隐藏); ① `saveVerifyData()` 按 `_bid` 分组逐张 `PUT /api/batches/{id}`(有改动才发)
**重新编辑/核对入口只在批次详情**;台账列表按账期汇总、默认折叠(`tbody.pgrp.collapsed`) ② `POST /api/periods/verify {rows, done:true}` 整期写成「已核对」→ 要不要重核**由用户勾选决定**,服务端不猜
- 列显示菜单(`setVColsOpen`):开关状态存模块级 `VCOLS_OPEN`(模式条重渲染不丢), - 已删 `keep_verify` 那套(与新口径矛盾);`reingest` = **一律重置核对**
收起方式 = 再点按钮 / `×` / 点外部 / Esc;空间不足自动 `.up` 向上弹 - 脏基线:`ELEC_CMP_FIELDS` + `ESNAP`/`takeVerifySnapshot()`/`dirtyRows()`/`editSummary()`
- ⚠️ **HTML `hidden` 属性不是 `!important`**:组件类上只要写了 `display:flex/grid`, - ⚠️ 基线键用稳定 `_rid`,**不能用数组下标**;⚠️ 改动提示只改文字、绝不重渲染模式条(否则输入框失焦)
`hidden=true` 就**完全失效** → 凡用 `hidden` 控制显隐,必须补 `[hidden]{display:none}` - ⚠️ `PUT` 是**全量覆盖**:每组必须带该批次**全部行**;退出核对有未保存改动要**明确警告会丢**
- ⚠️ **会 `innerHTML` 重渲染的绑定函数里不要挂 document 级监听**:每渲染一次堆一个, - 前端:核对模式按行勾选 + 列显示菜单(`localStorage['rentos_vcols']`);**重新编辑/核对入口只在批次详情**
旧闭包持有已移除的节点 → 用一次性模块标志(`VCOLS_DOC_BOUND`)或事件委托 - 列显示菜单:开关状态存模块级 `VCOLS_OPEN`;收起 = 再点按钮 / `×` / 点外部 / Esc;空间不足自动 `.up`
- ⚠️ **HTML `hidden` 不是 `!important`**:类里写了 `display:flex/grid` 就完全失效 → 必须补 `[hidden]{display:none}`
## 电费「核对中改数据」(2026-10-14 · 当日晚改口径) - ⚠️ **会 `innerHTML` 重渲染的绑定函数里别挂 document 级监听**(用一次性模块标志或事件委托)
核对表**可直接编辑**,**改数据不影响核对状态**;点「确认核对」才把改动**一并存盘**。
前端 `confirmVerify()` 两步:①`saveVerifyData()` 按 `_bid` 分组逐张 `PUT /api/batches/{id}` ## 电费录入「临时保存」草稿
(有改动才发请求)→ ②`POST /api/periods/verify {rows, done:true}` 把勾选整期写成「已核对」。 - 「临时保存」把明细整份落盘 `data/drafts/elec__{账号}.json`(**按账号隔离**);下次进页**自动恢复**;
于是「改过的行要不要重新核」由**用户勾了没勾**决定,服务端不猜。 生成通知书 / 清空 / 手动丢弃时删除。
- **已删** `keep_verify` / `_kept_verify` / `_detail_fp` / `_same_detail`(旧「改过的行退回待核对」 - 接口 `GET|PUT|DELETE /api/drafts/elec`(`_write_cap` 记 `CAP_ELEC`,访客可用)
口径被上面两步取代,留着会与新口径矛盾);`reingest` 恢复为**一律重置核对** - 前端 `saveElecDraft/clearElecDraft/restoreElecDraft/elecBootFill`;`applyNav` 改调 `elecBootFill()`
- 前端脏数据基线:`ELEC_CMP_FIELDS` + `ESNAP`/`takeVerifySnapshot()`/`dirtyRows()`/`editSummary()` —— **有草稿就不「带入上期」**
- ⚠️ 基线键必须用稳定 `_rid`,**不能用数组下标**(删中间行会让基线整体错位)
- ⚠️ 改动提示**只改文字、绝不重渲染模式条**(`markVerifyDirty` 300ms 节流),否则输入框失焦 ## 合同原件(台账 AL 列,2026-10-15 新增)
- ⚠️ `PUT` 是**全量覆盖**:每组必须带上该批次的**全部行**,只是「哪些批次要发」按有无改动筛 一格可挂多份(租赁合同 + 补充协议 + 车位合同)。
- ⚠️ 退出核对若有未保存改动要**明确警告会丢失**(数据只在确认时落盘) - 单元格值 = 紧凑 JSON `[{"id","name"}]`;本体在 `data/contracts/{id}.{ext}` + `index.json`
- `rentos/contracts.py`:save/find/path_of/list_all/remove/parse/dump/describe;
## 电费录入「临时保存」草稿(2026-10-14) 白名单 pdf/doc/docx/xls/xlsx/png/jpg/gif/webp/bmp,单份 **≤ 20MB**;id=uuid4 hex,`clean_id` 挡路径穿越
录入页「临时保存」把当前明细整份落盘 `data/drafts/elec__{账号}.json`(**按账号隔离**), - 接口:`POST|GET /api/contracts` · `GET /api/contracts/{id}`(内联预览;`?dl=1` 下载;支持 `?token=`)·
下次进电费页自动恢复、直接接着编辑;生成通知书 / 清空 / 手动丢弃时删除草稿。 `DELETE /api/contracts/{id}` —— **增删仅管理员**(访客可查阅、不能增删)
- 接口 `GET|PUT|DELETE /api/drafts/elec`;`_write_cap` 里 PUT/DELETE 记 `CAP_ELEC`(访客可用) - 前端:`td.files` 渲染按钮(`📎 N 份` / 编辑态 `+ 合同`)→ `openFilesDlg()` 浮层;
- `store.draft_path/save_draft/load_draft/delete_draft`;账号名净化 `[^0-9A-Za-z_\-] → _` 增删走 `stageCell(rowId,'AL','files',…)` 暂存,**与台账「保存 N 处改动」同一口径**
- 前端 `saveElecDraft/clearElecDraft/restoreElecDraft/elecBootFill`;`applyNav` 里电费页改调 - ⚠️ **从某行移除只解关联、不删文件**(多行可共享同一份)
`elecBootFill()` —— **有草稿就不「带入上期」**(否则互相覆盖) - ⚠️ `renderGrid` 渲染 `files` 列**必须读 `TB_PEND` 暂存值**,否则上传后格子仍显示旧的「+ 合同」
- ⚠️ **沙箱白名单必须加 `DRAFT_DIR`**:`ui_verify_runner` 与自带沙箱的 `7/8/9/10/11`、 - 导出 xlsx 该列输出**文件名文本**(`tables.files_text`),不是 JSON
`check_scheduler` 都逐一补过 —— 漏了就会把草稿写进**真实** `data/drafts/`(与批次泄漏同类坑) - 批量导入:`tools/import_contracts.py`(默认干跑,`--apply` 写入,`--replace` 覆盖);
解析文件名**开头**的单元号串(`1101、1115` / `124.155号` / `132&227车位` / `501-502室` / `601A&702`),
再按台账实际存在的 `n室` / `n车位` 定夺;`项目合约/`(93MB)**不入库**
## 房租车位费出单 ## 房租车位费出单
链路:`rent_parser.py` → `rent_render.py`(→ `notice_pdf`)→ `service.ingest_rent` → 批次 链路:`rent_parser.py` → `rent_render.py`(→ `notice_pdf`)→ `service.ingest_rent` → 批次
- 计费:期间起 = **AC**,止 = 起 + N 个自然月 − 1 天;`房屋租赁费 = P × N`、`物业费 = O × N`; - 计费:期间起 = **AC**,止 = 起 + N 个自然月 − 1 天;`房屋租赁费 = P × N`、`物业费 = O × N`;
单元号含「车位」→ 车位租赁费;N 缺值/非法回落 `DEFAULT_MONTHS = 3` 单元号含「车位」→ 车位租赁费;N 缺值回落 `DEFAULT_MONTHS = 3`
- **不可出单三条件**:空置(F 空或含「空置」)/ 每月租金 ≤ 0 / AC 空(各带 `reasons`) - **不可出单三条件**:空置(F 空或含「空置」)/ 每月租金 ≤ 0 / AC 空(各带 `reasons`)
- **按免租数据跳过**(2026-10-13):`rent_preview` 对每行与每份单据标 `rentfree`,汇总给 `rentfree_count/rows` - **按免租数据跳过**:`rent_preview` 对每行与每份单据标 `rentfree`
- 房间费与车位费**合并一张**(键 `(出租方, 租客, 期间)`);批次按 `kind` 隔离 - 房间费与车位费**合并一张**(键 `(出租方, 租客, 期间)`);批次按 `kind` 隔离
- 推进/回滚走 `tables.update_cells`;`rollback_rent_advance` **只回滚当前值仍等于推进后的行** - 推进/回滚走 `tables.update_cells`;`rollback_rent_advance` **只回滚当前值仍等于推进后的行**
- **账期区间筛选**:`classify_period(rows, from, to)` 命中 = 账期起始日落在区间内,**取代出单基准日** - **账期区间筛选**:`classify_period(from, to)` 命中 = 账期起始日落在区间内(旧 `?on=` 是 `to` 别名)
(旧 `?on=` 保留为 `to` 别名);批次记 `period_filter_from/to`
- 接口:`GET /api/batches?kind=` · `GET /api/rent/preview?from=&to=&all=1` · `POST /api/rent/batches` · - 接口:`GET /api/batches?kind=` · `GET /api/rent/preview?from=&to=&all=1` · `POST /api/rent/batches` ·
`DELETE /api/batches/{id}` `{rollback:true}` · `POST /api/batches/{id}/receipt`(收款/开票,**只房租批次**, `DELETE /api/batches/{id}` `{rollback:true}` · `POST /api/batches/{id}/receipt`(收款/开票,**只房租批次**)·
挂 `batch.documents[i]`)· `GET /api/batches/{id}/export`(3 Sheet 落 `output/exports/`)· `GET /api/batches/{id}/export`
`PUT /api/tables/{key}/cells`(见下节) - ⚠️ `GET /api/batches/{id}` 的 `receipt` 在**响应顶层**
- ⚠️ `GET /api/batches/{id}` 的 `receipt` 在**响应顶层**,前端须显式接
## 台账编辑(2026-10-13 改批量暂存) ## 台账编辑(批量暂存)
- 改格**不再逐格弹确认**:`stageCell()` 暂存 + 黄底高亮(`TB_PEND`),顶条显示「N 处改动待保存」 - 改格**不逐格弹确认**:`stageCell()` 暂存 + 黄底高亮(`TB_PEND`),顶条显示「N 处改动待保存」
- 「保存全部」→ 一次 `confirmBox` → 单次 `PUT /api/tables/{key}/cells` - 「保存全部」→ 一次 `confirmBox` → 单次 `PUT /api/tables/{key}/cells`
(`{changes:[{row_id,col,value}], confirm:true, who}`)→ `pushHist({t:'batch'})` 可**整批撤销** (`{changes:[{row_id,col,value}], confirm:true, who}`)→ `pushHist({t:'batch'})` 可**整批撤销**
- 「放弃改动」全部还原;`navTo()` / `beforeunload` 拦截未保存改动 - `navTo()` / `beforeunload` 拦截未保存改动
- ⚠️ `renderGrid()` 末尾必须同时调 `paintPending()` **和** `renderPendBar()`(漏后者 → 保存后待保存条不消失) - ⚠️ `renderGrid()` 末尾必须同时调 `paintPending()` **和** `renderPendBar()`
- 确认框是 `.modal-mask.show`(**不是 `.open`**) - 确认框是 `.modal-mask.show`(**不是 `.open`**)
## 系统骨架 / 渲染 ## 系统骨架 / 渲染
- 启动 `python -m rentos.server --port 8899`;`--no-scheduler` 只开 Web;admin / 1989@rent05; - 启动 `python -m rentos.server --port 8899`;`--no-scheduler` 只开 Web;admin / 1989@rent05;
访客 **guest / guest@2026**(`role=viewer` + `perms=['elec']`,只放开「电费通知单」页的写操作) 访客 **guest / guest@2026**(`role=viewer` + `perms=['elec']`)
- **权限模型(2026-10-13)**:`role`(admin|viewer)+ 账号/会话的 `perms` 能力清单(目前只有 `elec`) - **权限模型**:`role`(admin|viewer)+ `perms` 能力清单(目前只有 `elec`)
- 管理员隐含 `{'*'}`;`viewer` 按 `perms` **逐接口**放行,`perms=[]` 即纯只读 - 管理员隐含 `{'*'}`;`viewer` 按 `perms` **逐接口**放行,`perms=[]` 即纯只读
- `auth.caps()/can()/normalize_perms()`;`server._write_cap(method,path)` 解析接口所需能力, - `auth.caps()/can()`;`server._write_cap(method,path)` 解析所需能力,`_guard_write()` 判定
`_guard_write(user,cap)` 判定(403 文案「账号 X 只能操作:电费通知单」) - **批次类接口按 `kind` 复核**:房租车位费的删/重出/发送/收款**仍只有管理员**
- **批次类接口按批次 `kind` 复核**:房租车位费的删/重出/发送/收款登记**仍只有管理员**能动 - 前端:`body.cap-elec` 才显示 `[data-cap="elec"]`;`data-ro` = 仅管理员;**主账号不可降级**
- 老配置一次性回填:`auth.guest_perms_seeded` 标记 + `DEFAULT_CONFIG` 同步加该键 - 导航 5 项:概览 / 电费通知单 / 房租车位费 / 公司管理 / 设置;hash 路由在 `app.js` 的
- 前端:`body.cap-elec` 才显示 `[data-cap="elec"]`;`data-ro` 仍是「仅管理员」; `PAGES` + `applyNav()`;**新增页面必须同时登记 PAGES 与 `#nav`**
账号管理新增「权限」列 + 一键开通/关闭(`PUT /api/accounts/{name}`);**主账号不可降级**
- 导航 5 项:概览 / 电费通知单 / 房租车位费 / 公司管理 / 设置;真实 `<a href="#/...">` + hash 路由,
实现在 `app.js` 的 `PAGES` + `applyNav()`;**新增页面必须同时在 PAGES 与 `#nav` 登记**
- CSS 表格类名 `edgrid`;侧栏/顶栏读 `/api/overview`(含 wecom-cli,慢)
- **唯一渲染层** `rentos/notice_pdf.py`:reportlab + platypus 程序化绘制(**不用 win32com / LibreOffice**); - **唯一渲染层** `rentos/notice_pdf.py`:reportlab + platypus 程序化绘制(**不用 win32com / LibreOffice**);
字体 `simhei.ttf` 子集嵌入(单份 10~32KB);A4、`MARGIN = 1.9cm`;房租 5 列 / 电费 7 列,多单元按 `spans` 合并首列 字体 `simhei.ttf` 子集嵌入;A4、`MARGIN = 1.9cm`
## 环境 ## 环境
- Python `…/python/versions/3.13.12/python.exe`(openpyxl / reportlab / pillow;无 pypdfium2) - Python `…/python/versions/3.13.12/python.exe`(openpyxl / reportlab / pillow;无 pypdfium2)
- venv `…/envs/default/Scripts/python.exe`:**Playwright + reportlab + pypdfium2** → **单测与 UI 回归都用它** - venv `…/envs/default/Scripts/python.exe`:**Playwright + reportlab + pypdfium2** → **单测与 UI 回归都用它**
- 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**;分支用 `goto` 标签;echo 不放半角括号/`!` - `.bat` 必须 **cp936 + CRLF**;分支用 `goto` 标签;echo 不放半角括号/`!`
- **改完后端代码必须 kill 所有在跑实例再重启**(旧进程按旧逻辑写同一 `data/`,甚至自动发消息) - **改完后端代码必须 kill 所有在跑实例再重启**(旧进程会按旧逻辑写同一 `data/`)
- ⚠️ 本工具链会把命令里的 `\n` 字面化 → 写多行脚本用 heredoc + `textwrap.dedent`,别用 `"\n".join`
## 启动 / 部署(192.168.68.121 · CentOS 7) ## 启动 / 部署(192.168.68.121 · CentOS 7)
- `start.bat [safe] [端口]` / `stop.bat [端口]`;`notify.enabled=True` 时双击 `start.bat` **会开定时发送** - `start.bat [safe] [端口]` / `stop.bat`;`notify.enabled=True` 时双击 `start.bat` **会开定时发送**
→ **会话内启动一律 `start.bat safe`** → **会话内一律 `start.bat safe`**;会话里它内层 `start` 会被拦 → 直接后台起
- ⚠️ 会话里跑 `start.bat safe` 时,其内层 `start` 开新控制台会被环境拦(SIGTERM、无输出)→ `python -m rentos.server --port 8899 --no-scheduler`
改用后台任务直起等价命令:`python -m rentos.server --port 8899 --no-scheduler`(`run_in_background`)
- 云端 `http://192.168.68.121:8899/`(admin / 1989@rent05),容器 `rentos`,root / 12345678 - 云端 `http://192.168.68.121:8899/`(admin / 1989@rent05),容器 `rentos`,root / 12345678
- **主用 Git 同步**(⚠️ **必须用 venv python** `…/envs/default/Scripts/python.exe`,3.13.12 无 paramiko): - **主用 Git 同步**(⚠️ **必须用 venv python**,3.13.12 无 paramiko):
`RENTOS_SSH_PASSWORD=12345678 …python tools/deploy_git.py sync`(setup/pull/build/run/status/logs) `RENTOS_SSH_PASSWORD=12345678 …python tools/deploy_git.py sync`
- push 需绕代理:`env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY git push` - push 绕代理:`env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY git push`
- 代码落 `/opt/rentos/repo`,与数据同级独立 → `git reset --hard` **不碰线上数据** - 代码落 `/opt/rentos/repo`,与数据同级独立 → `git reset --hard` **不碰线上数据**
- ⚠️ **sync 只更代码、不动数据** → 凡改列模型/加列的迁移,**部署后必须在容器里手动再跑一次** - ⚠️ **sync 只更代码、不动数据** → 改列模型/加列的迁移**部署后要在容器里手动再跑**
(如 `docker exec rentos python /app/tools/migrate_rentfree_type.py` 先干跑再加 `--apply`) (`docker exec rentos python /app/tools/xxx.py`,服务器**无 Python 3**)
- 构建必须在**仓库根**:`docker build -f deploy/Dockerfile -t rentos:latest .`,构建期零联网; - 构建必须在**仓库根**:`docker build -f deploy/Dockerfile -t rentos:latest .`,零联网;
**切勿 `docker build … | tail`**(吞掉失败会拿旧镜像重启) **切勿 `docker build … | tail`**(吞掉失败会拿旧镜像重启)
- 服务器**无 Python 3** → 迁移只能 `docker exec rentos python /app/tools/xxx.py`
- `docker -v` 的 SELinux 选项是**冒号 `:z`**(写成 `,z` 的症状:页面 200 但台账 0 行) - `docker -v` 的 SELinux 选项是**冒号 `:z`**(写成 `,z` 的症状:页面 200 但台账 0 行)
- 云端已能直发企微:`/usr/local/bin/wecom-cli` + 凭据 `/opt/rentos/wecom-config/`(700) - 云端已能直发企微:`/usr/local/bin/wecom-cli` + 凭据 `/opt/rentos/wecom-config/`(700)
- ⚠️ **线上是唯一生产方,本机只作预览;台账以线上为准**;**本机与云端不要同时开定时器** - ⚠️ **线上是唯一生产方,本机只作预览**;**本机与云端不要同时开定时器**
- 远端 `https://git.thiztech.com/accounting/rentos.git`(自建 Gitea;http 301 跳 https),分支 `main`; - 远端 `https://git.thiztech.com/accounting/rentos.git`(Gitea),分支 `main`;
纳入/排除见 `.gitignore`(`data/sessions.json` 含活跃 token 务必排除);`.gitattributes`: `.gitignore` 排除 `data/sessions.json`(含活跃 token)/`batches`/`drafts`/`contracts`/`项目合约/`;
`* text=auto eol=lf`、`*.bat -text`、**Dockerfile 必须 LF** `.gitattributes`:`* text=auto eol=lf`、`*.bat -text`、**Dockerfile 必须 LF**
## 验证脚本(tools/) ## 验证脚本(tools/)
- 单测 **303 项**:`test_rules` 45 · `test_directory` 33 · `test_rent` 53 · `test_round5` 54 · - 单测 **328 项**:`test_rules` 45 · `test_directory` 33 · `test_rent` 53 · `test_round5` 54 ·
`test_accounts` 60 · `test_round6` 39 · `test_masters` 19 `test_accounts` 60 · `test_round6` 44 · `test_masters` 19 · **`test_contracts` 20**
(`test_scheduler.py` 需活跃 8899 服务,无服务时失败属预期) (`test_scheduler.py` 需活跃 8899 服务,无服务时失败属预期)
- UI 回归:**自带沙箱** `ui_verify7/8/9/10/11.py`;**需连服务** `ui_verify`·`2`·`3`·`4`·`5`·`6.py` - UI 回归:**自带沙箱** `ui_verify7~12.py`;**需连服务** `ui_verify`·`2`~`6.py`
→ 依次覆盖 台账+电费录入 / 设置+锁定+批量暂存 / 带入+照明+图片 / 公司管理 / 房租出单全链 / → 台账+电费录入 / 设置+锁定+批量暂存 / 带入+照明+图片 / 公司管理 / 房租出单全链 /
收款开票+导出 / 访客(纯只读 + guest 仅电费)+收款同步 / 账期区间 / 删房产列+免租两类型+批量编辑 / 收款开票+导出 / 访客能力 / 账期区间 / 批量编辑 / 行粒度核对+账期汇总 /
访客能力三方对照 / **行粒度核对+部分确认+账期汇总+列筛选(ui_verify11 85 项)** **合同原件(ui_verify12,30 项)**
- ⚠️ `ui_verify7` 的只读样本是脚本自建的 `ro_verify`(无能力)—— guest 已改成「电费通知单操作员」, - API 冒烟(沙箱):`smoke_verify_edit.py`(核对态改数据 + 草稿)·
不能再当纯只读样本用 **`smoke_verify_contracts.py`(合同 24 项)**
- **`tools/ui_verify_runner.py <脚本…>`**:每个脚本起**全新沙箱**(复制 `data/` + 改写 config 常量 + - ⚠️ `ui_verify7` 的只读样本是脚本自建 `ro_verify`(guest 已是「电费操作员」,不能当纯只读样本)
另起端口 + 注入 `RENTOS_BASE`),真实 `data/` 零改动 —— 跑 1~6 必用 - **`tools/ui_verify_runner.py <脚本…>`**:每个脚本起**全新沙箱**(复制 data/ + 改写 config 常量 +
- ⚠️ **1~6 号脚本已加硬闸门**:未设 `RENTOS_BASE` 且未设 `RENTOS_ALLOW_REAL=1` 直接退出 另起端口 + 注入 `RENTOS_BASE`);⚠️ **1~6 号有硬闸门**(未设 `RENTOS_BASE`/`RENTOS_ALLOW_REAL=1` 直接退出)
(2026-10-13 直连 8899 曾误写 17 个测试批次进真实 `data/batches/`) - ⚠️ **沙箱常量清单要同步补**(`BATCH_DIR`/`OUTPUT_DIR`/`DRAFT_DIR`/**`CONTRACT_DIR`**)——
- 端到端 `e2e_round5.py`(PDF 出单 → 收款开票 → 导出 xlsx → 排期 → 删除并回滚) 共 7 处:`ui_verify_runner` + `ui_verify7/8/9/10/11` + `check_scheduler`;漏了就会写进**真实** `data/`
- 迁移:`migrate_rentfree_type.py`(删 AD~AI + 加 AK + 回填) · `import_park_notes.py`(车位备注→AA) · `migrate_next_bill.py`(AJ=AC) (2026-10-13 直连 8899 曾误写 17 个测试批次)
- **断言不要写死行数/列数/日期** —— 读接口现取;表格**按列 id / 表头文字定位**,不按第 N 列 - 迁移:`migrate_rentfree_type.py` · `import_park_notes.py` · `migrate_next_bill.py`
(2026-10-13 新增列使 DOM 位置整体后移,`ui_verify3` 的 `tds[14]` 曾误读「付款周期」) - 端到端 `e2e_round5.py`(PDF 出单 → 收款开票 → 导出 xlsx → 排期 → 删除回滚)
- **断言不要写死行数/列数/日期**,**按列 id / 表头文字定位**,不按第 N 列
(新增列使 DOM 位置后移,`ui_verify3` 的 `tds[14]` 曾误读「付款周期」)
## 工作规矩(巴特菲) ## 工作规矩(巴特菲)
- **UI 回归不要每次小改都跑**,等他发话;小改只跑单元测试 - **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 最后一行」**
- `output/rent/<批次号>` 是**目录**;台账确认框「行」标签 = 房间号优先 + 现租客(C 列) - 台账确认框「行」标签 = 房间号优先 + 现租客(C 列)
- **数据一致性铁律**:线上台账权威,禁用本机数据覆盖线上 - **数据一致性铁律**:线上台账权威,禁用本机数据覆盖线上
## 待巴特菲拍板 ## 待巴特菲拍板
多办公地是否带公司名、车位是否算「办公地」、同名单据加期间、电费 0 用量是否出单、 多办公地是否带公司名、车位是否算「办公地」、同名单据加期间、电费 0 用量是否出单、
4F/5F 与爱种草渠道口径、通知日期/缴费期限口径、是否做收款核销(1934 行流水)。 4F/5F 与爱种草渠道口径、通知日期/缴费期限口径、是否做收款核销。
**未处置**:9 月电费批次多计 10 行 ¥167,947.41 / 少计 601B ¥299.67 / 出租方错记「精锐」。 **未处置**:9 月电费批次多计 10 行 ¥167,947.41 / 少计 601B ¥299.67 / 出租方错记「精锐」。
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