Commit e21a96c3 authored by wenwen.tang's avatar wenwen.tang 😕

feat(台账): 删房产/房产税六列 + 免租分两种类型 + 台账改批量暂存编辑

列模型 36 → 31:删 AD~AI(房产六列,RETIRED_TAX_COLS),新增 AK「免租类型」(显示位置插在 L 之后)。

rentfree 重写为两类型:首N月免租 = [I, I+L月);每年8-9月免租 = 合约期 [I,J] 内每个自然年 8-9 月整月(跨年裁剪,J 按含当日)。hit() 按时点、overlaps() 按账单期间交集(年度类型必需)。

账单按免租数据跳过:rent_preview 对每行与每份单据标 rentfree,汇总新增 rentfree_count / rentfree_rows。

台账编辑改批量暂存:改格先高亮暂存(不再逐格弹窗),「保存全部」一次确认后单次 PUT /api/tables/{key}/cells 落盘(带 428 闸门),可在修改记录里整批撤销。

迁移 tools/migrate_rentfree_type.py(幂等):删 6 列 / 165 存量值 → 按代码重建 31 列 → 回填 302/303/315「首N月免租」。

修渲染真 bug:renderGrid() 末尾补 renderPendBar()(否则保存成功后待保存条不消失)。

验证:单测 219 项全绿;UI 回归 ui_verify 1~9 全绿(新增 ui_verify9.py 39 项 + ui_verify_runner.py 沙箱跑需连服务的脚本)。
parent cae0872d
...@@ -339,3 +339,56 @@ env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY git push origin ma ...@@ -339,3 +339,56 @@ env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY git push origin ma
- 注:`deploy_git.py` 的默认口令自检与登录探针在线上必报 **401**(线上 admin 密码已人工改过),**属预期**, - 注:`deploy_git.py` 的默认口令自检与登录探针在线上必报 **401**(线上 admin 密码已人工改过),**属预期**,
故核验改走 `docker exec` 直调服务函数,绕开 HTTP 登录。 故核验改走 `docker exec` 直调服务函数,绕开 HTTP 登录。
- **未处理**:9 月电费批次的多计/少计/出租方错记问题(见上节)**仍待巴特菲拍板**,本轮只部署代码、未改批次数据。 - **未处理**:9 月电费批次的多计/少计/出租方错记问题(见上节)**仍待巴特菲拍板**,本轮只部署代码、未改批次数据。
---
## 第八轮:删房产/房产税六列 + 免租两种类型 + 台账批量编辑(2026-10-13 14:50)
### 需求与口径(经 AskUserQuestion 拍板)
原话:*「删除数据表中房产和房产税栏位。免租期分为两种首n月免租和每年8月9月免租,在表中修改类型和免租数据。
账单按照数据需要执行跳过免租。数据表修改时不要每改一个就确定一下,太繁琐了。」*
1. 免租类型存储 = **新增「免租类型」列**,固定两项(`首N月免租` / `每年8-9月免租`),空 = 无免租
2. 8-9 月判定 = **合约期内每年 8、9 月整月免租**(跨年按合约边界裁剪)
3. 现有 6 行归类 = 302/303/315 都标「首N月免租」
4. 编辑确认流程 = **批量编辑,最后一次性确认**
### 实现要点
- **列模型 36 → 31 列**:删 AD~AI(房产六列,`RETIRED_TAX_COLS`),新增 **AK「免租类型」**
(`BILL_COLS["rentfree_type"] = "AK"`,**显示位置插在 L 之后**,实际存键仍是 AK)。
`tax_columns` 相关 API/前端全部摘除。
- **`rentos/rentfree.py` 重写**:`FIRST_N` / `ANNUAL` 两类型;
`_first_n_window()` = `[I, I+L月)`;`_annual_windows()` = 合约期 `[I,J]` 内每个自然年
`[max(该年08-01, I), min(该年10-01, J+1天))`(跨年裁剪);
**两种判定**:`hit()` 按时点、`overlaps(units, pf, pt)` 按账单期间交集
(「每年8-9月」必需 —— 通知日通常早于该期,按时点会漏判)。
- **账单按免租跳过**:`service.rent_preview` 对每行 `r["rentfree"]` + 每份单据 `doc["rentfree"]` 打标,
汇总新增 `rentfree_count` / `rentfree_rows`;前端明细行与单据单元列挂 `.tag.skip`。
- **台账编辑改批量暂存**:`stageCell()` 暂存(`TB_PEND`)+ 黄底高亮 + 顶部「N 处改动待保存」条;
「保存全部」→ **一次 `confirmBox`** → 单次 `PUT /api/tables/{key}/cells`(新增路由,带 428 闸门)
→ `pushHist({t:'batch'})` 可**整批撤销**;「放弃改动」全部还原;`navTo()`/`beforeunload` 拦截未保存改动。
旧的逐格 `PUT /api/tables/{key}/cell` + 逐格弹窗流程在台账页已不再使用。
- 迁移 `tools/migrate_rentfree_type.py`(幂等,两段式):删 6 列 / 165 个存量值 → 按代码重建 31 列 → 新增 AK
→ 回填 L>0 的行 = 302/303/315 室为「首N月免租」。
### 踩坑(本轮 4 处,均已修)
1. **真 bug**:`renderGrid()` 末尾只调 `paintPending()`,漏了 `renderPendBar()` →
保存成功后「N 处改动待保存」条不消失(DOM 未重绘)。补 `renderPendBar()` 后正常。
2. 确认框的 open 判定是 **`.modal-mask.show`(不是 `.open`)**;且正文里的 "N 处改动" 不含「列」字,
`ui_verify9` 的断言与 `cfm_open()` 一度误判。
3. **`ui_verify3` 按下标取 DOM**(`tds[14]`=N 付款周期)—— 新增 AK 列后位置整体后移 1,
误读成「付款周期为空 / 物业费 = 3」。改为**按 `data-col` 取值**。
4. **`ui_verify6` 写死 `on=2026-12-01`** —— 权威台账 AC 已全部推进到 2027 → 筛出 0 行。
改为**取全部可出单行、用选中行自己的账期起**作出单日。
### 新增:`tools/ui_verify_runner.py`
`ui_verify`/`2`/`3`/`4`/`5`/`6` 会改真实台账(改格/增行/删行)。新 runner 为**每个脚本起一份全新沙箱**
(复制 `data/` + 改写 config 路径常量 + 另起端口 + 注入 `RENTOS_BASE`),真实 `data/` 零改动。
用法:`python tools/ui_verify_runner.py tools/ui_verify3.py …`
### 回归结果(全绿)
- 单测 **219 项**:rules 45 · directory 33 · rent 53 · round5 54 · accounts 34
- UI:`ui_verify` ✓ · `2` 72/0 · `3` 42/0 · `4` 41/0 · `5` 43/0 · `6` 29/0 · `7` 59/0 · `8` 30/0 · `9` **39/0**(本轮专属)
- `ui_verify9` 覆盖:列结构(无房产六列 / 31 列 / AK 在位且紧跟 L)· 免租两类型与候选 ·
账单按免租跳过(8-9 月命中 / 挪到 11 月不命中)· 表头与 datalist · 批量暂存(不弹框)· 放弃还原 ·
一次确认落盘 · 整批撤销 · 服务端 428 闸门 · 无 JS 报错
- 真实 `data/` 除迁移那一次写入外零改动(所有 UI 回归均走沙箱)
# 租金通知系统 · 项目长期记忆 # 租金通知系统 · 项目长期记忆
## 目标 ## 目标 & 命名
三份 Excel → **固定模板 + 数据填充** 的租金/电费通知系统,产出 **PDF 收费通知书**(2026-10-12 起统一 PDF), 三份 Excel → 固定模板 + 数据填充 → **PDF 收费通知书**(2026-10-12 起),自动发企微 + Web 维护。
自动发企业微信 + Web 页面维护。 - 文件名 = `{单元列表}-{费用类型}-收费通知书.pdf`;单元按 `B.unit_sort_key` 排序、`、` 连接;**不含公司名**
- 费用类型词序固定:房租 → 物业费 → 车位费(`&` 合法)
## 命名规范(2026-10-12)
- 文件名 = `{单元列表}-{费用类型}-收费通知书.pdf`;单元按 `B.unit_sort_key` 排序、`、` 连接
- 例 `302室-房租&物业费-收费通知书.pdf` / `121车位、402室、404室-房租&物业费&车位费-收费通知书.pdf`
- **不含公司名**(出租方/租客只出现在正文);守护测试 `test_no_company_in_name`
- 费用类型词序固定:房租 → 物业费 → 车位费(`&` 合法,勿替换)
## 渲染 / 模板
- **唯一渲染层** `rentos/notice_pdf.py`:`rent_notice_pdf()` / `elec_notice_pdf()`;
reportlab + platypus 程序化绘制(**不用 win32com / LibreOffice**,本机都没有)
- 字体 `assets/fonts/simhei.ttf`(9.7MB 随仓库分发),子集嵌入,单份 10~32KB;
回落链 simhei → NotoSansSC → 系统 simhei/msyh → Linux wqy/noto → DejaVu → CID `STSong-Light`
- 表格 `templates/T1-房租 / T2-房租&物业费 / T3-车位费 / T4-电费`;
`rent_render.py` / `elec_render.py` 只做「分组 + 命名 + 落盘」
- 版式:A4、`MARGIN = 1.9cm`;房租 5 列、电费 7 列,多单元按 `spans` 合并首列
## 系统骨架
- 启动 `python -m rentos.server --port 8899`;`--no-scheduler` 只开 Web;登录 admin / 1989@rent05
- 导航 5 项:概览 / 电费通知单(带待发角标)/ 房租车位费 / 公司管理 / 设置;真实 `<a href="#/...">` hash 路由
- 实现在 `app.js` 的 `PAGES` + `applyNav()`;**新增页面必须同时在 PAGES 与 `#nav` 登记**
- 侧栏/顶栏状态读 `/api/overview`(含 wecom-cli,慢);CSS 表格类名 `edgrid`
## 名册(存 `data/config.json`,唯一读写入口 `rentos/directory.py`)
- `companies`:租客/往来单位(`kind`=company|person,**名称即唯一键**)
- `owners`:出租方/汇款公司(**键=简称**,字段 name/bank/acct/address)
- 当前 4 家:精锐 37 单元 / 科博 24 / 瑞格嘉德 16 / 国隆 8
- 语义:`None`/缺失=未维护过 → 回落 `DEFAULT_OWNERS`;**写过(哪怕 `{}`)= 权威**,可真正增删改
- 归一 `_norm_name`(去空白 + 全角括号→半角 + 忽略大小写);改名**联动台账**
(公司改 F 列「现租客」、出租方改 A 列)返回 `renamed_rows`;删除有引用返回 409,`force=true` 才真删
- 归属三级优先:**行内显式 `owner` > 台账 A 列 > 名单兜底(仅当名单恰好一家)**
- ⚠️ **写入必须 `store.set_config()`**(顶层整体替换);`patch_config` 浅合并**删不掉字典键**
- 播种 `data/seed/directory.json`;`directory.ensure_seeded()` 在 `server.main()` 里调
## 数据模型 ## 数据模型
- **唯一台账** `rent_parking`(85 行 × 36 列 A~AJ) - **唯一台账** `rent_parking`(85 行 × **31 列** = A~AC + **AK** + AJ)
- `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 **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
- AD~AI **房产组**(`TAX_COLS`):产证面积/取得成本/车位对应房间/季度计税租金/从租季度房产税/从价季度房产税 - **AK「免租类型」**(2026-10-13 新增,**显示在 L 之后**):`首N月免租` / `每年8-9月免租`,空=无免租
—— **纯登记,出单逻辑一律不引用**(守护 `test_tax_columns_not_used_by_billing`) - AJ「下一账单日」**只用于定时排期**(推送日 = AJ − 提前天数);出单口径看 AC;出单后 AC 与 AJ 一起推进
- AJ「下一账单日」:**只用于定时排期**(推送日 = AJ − 提前天数);出单口径仍看 AC; - **AD~AI 房产/房产税六列已删**(`RETIRED_TAX_COLS`,守护 `test_retired_cols_not_used_by_billing`)
出单成功后 AC 与 AJ **一起**推进一个付款周期;AJ 为空的行不排期、也不自动补值 - 默认锁定只读;改单元格/增行/删行必须 `confirm=true`(否则 428);备份 `data/tables/.backup/`
- 默认**锁定只读**;改单元格/增行/删行必须 `confirm=true`(否则 428);备份 `data/tables/.backup/` - **电费批次** `data/batches/{id}.json`:只留原始数据(details/totals/documents/发送状态),按账期月份留档
- **电费批次** `data/batches/{id}.json`:**只留原始数据**(details + totals + documents + 发送状态);
不按文件组织,只按账期月份留档统计(上传能力已下线) ## 免租(`rentos/rentfree.py`,2026-10-13 重写为两种类型)
- `首N月免租`:窗口 `[I, I+L月)`,含起始不含结束;0.5 月 = 15 天
## 业务口径(2026-10-09 拍板) - `每年8-9月免租`:合约期 `[I, J]` 内每个自然年 `[max(该年08-01, I), min(该年10-01, J+1天))`(跨年裁剪)
- 电费:`用量 = 本期 − 上期`;`空调 = 用量 × 电价 × 12.25`、**`照明` = 用量 × 电价** - `hit()` 按时点;`overlaps()` 按账单期间交集(「每年8-9月」必需,通知日通常早于该期)
- 明细类目统一叫 **照明**;单据表头「房间号」是单元列,保留不改;只填上期数不填本期 → `pending` 不出单 - 另有 `types()` / `windows()` / `active()` / `describe()` / `strip()`
- 台账:**8F/11F 付款周期统一 3 个月**;**8F/11F 不收物业费**(O 列留空)
- 免租期:窗口 `[I, I+L月)`(含起始不含结束),0.5 月 = 15 天 ## 名册(`data/config.json`,唯一入口 `rentos/directory.py`)
- 两张账单(`plans.py`):`elec` 触发日 = 缴费期限 − 提前天数;`rent_parking`(2026-10-12 起) - `companies` 租客/往来单位(`kind`=company|person,**名称即唯一键**);`owners` 出租方(**键=简称**)
触发日 = 台账 **AJ − 提前天数**,按行判定(`rent_state()`),到点自动出单+发送(`service.auto_issue_rent()`) - 当前 4 家:精锐 37 / 科博 24 / 瑞格嘉德 16 / 国隆 8
- `None`/缺失 = 未维护过 → 回落 `DEFAULT_OWNERS`;**写过(哪怕 `{}`)= 权威**,可真增删改
- 归一 `_norm_name`;改名**联动台账**(F 列/A 列);删除有引用 → 409,`force=true` 才真删
- 归属三级优先:**行内 `owner` > 台账 A 列 > 名单兜底(仅当名单恰好一家)**
- ⚠️ 写入必须 `store.set_config()`(顶层整体替换);`patch_config` 浅合并**删不掉字典键**
## 业务口径
- 电费:`用量 = 本期 − 上期`;`空调 = 用量 × 电价 × 12.25`、**`照明` = 用量 × 电价**(类目统一叫「照明」)
- 只填上期不填本期 → `pending` 不出单;**车位不抄电表**(`P.is_park`,候选/带入/入库三处都要挡)
- 8F/11F **付款周期统一 3 个月**、**不收物业费**(O 列留空)
- 两张账单(`plans.py`):`elec` 触发日 = 缴费期限 − 提前天数;`rent_parking` 触发日 = 台账 **AJ − 提前天数**,
按行判定(`rent_state()`),到点自动出单+发送(`service.auto_issue_rent()`)
## 房租车位费出单 ## 房租车位费出单
链路:`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`)
- 口径:出单后 AC + AJ **一起** +N 月(可一键回滚);房间费与车位费**合并一张**(键 `(出租方, 租客, 期间)`) - **按免租数据跳过**(2026-10-13):`rent_preview` 对每行与每份单据标 `rentfree`,汇总给 `rentfree_count/rows`
- 推进/回滚走 `tables.update_cells(key, updates, who)`;`rollback_rent_advance(batch)` **只回滚当前值仍等于推进后的行** - 房间费与车位费**合并一张**(键 `(出租方, 租客, 期间)`);批次按 `kind` 隔离
- 批次按 `kind` 隔离(`"elec"` / `"rent_parking"`):`store.list_batches(kind)` / `batch_months(kind)` - 推进/回滚走 `tables.update_cells`;`rollback_rent_advance` **只回滚当前值仍等于推进后的行**
- 接口:`GET /api/batches?kind=`、`GET /api/rent/preview?from=&to=`(`?on=` 兼容别名 = to)、 - **账期区间筛选**:`classify_period(rows, from, to)` 命中 = 账期起始日落在区间内,**取代出单基准日**
`POST /api/rent/batches`、`DELETE /api/batches/{id}` body `{"rollback": true}`、 (旧 `?on=` 保留为 `to` 别名);批次记 `period_filter_from/to`
`POST /api/batches/{id}/receipt`、`GET /api/batches/{id}/export` - 接口:`GET /api/batches?kind=` · `GET /api/rent/preview?from=&to=&all=1` · `POST /api/rent/batches` ·
- **账期区间筛选(2026-10-13 新增)**:`classify_period(rows, from, to)` —— 命中 = **账期起始日落在区间内**; `DELETE /api/batches/{id}` `{rollback:true}` · `POST /api/batches/{id}/receipt`(收款/开票,**只房租批次**,
账期区间**取代出单基准日**(旧 `classify(rows, on)` 保留,定时器 `due_for_push` 仍用); 挂 `batch.documents[i]`)· `GET /api/batches/{id}/export`(3 Sheet 落 `output/exports/`)·
批次记 `period_filter_from` / `period_filter_to`;`_period_label()` 生成账期标签(`2026年12月` / 跨月 `2026年12月~2027年2月`) `PUT /api/tables/{key}/cells`(见下节)
- 前端:台账页三张卡「本期出单 / 批次台账 / 批次详情」;容器映射 `BV={elec,rent_parking}` + `BKIND`; - ⚠️ `GET /api/batches/{id}` 的 `receipt` 在**响应顶层**,前端须显式接
`batchOf(kind)` 校验 `BATCH.kind` **防跨栏目串台**
## 台账编辑(2026-10-13 改批量暂存)
### 2026-10-12 增补(收款开票 + PDF + 下一账单日) - 改格**不再逐格弹确认**:`stageCell()` 暂存 + 黄底高亮(`TB_PEND`),顶条显示「N 处改动待保存」
- 文件名去公司名:`notice_stem(units)` 只要单元列表 - 「保存全部」→ 一次 `confirmBox` → 单次 `PUT /api/tables/{key}/cells`
- **收款/开票**挂 `batch.documents[i]`:`paid_at` / `invoiced` / `invoiced_at` (`{changes:[{row_id,col,value}], confirm:true, who}`)→ `pushHist({t:'batch'})` 可**整批撤销**
- `service.set_receipt(bid, index, paid_at, invoiced, invoiced_at)`:勾选开票自动记今天,取消则清空 - 「放弃改动」全部还原;`navTo()` / `beforeunload` 拦截未保存改动
- `service.receipt_summary(batch)`:份数 + 金额 + `last_paid_at` / `last_invoiced_at` - ⚠️ `renderGrid()` 末尾必须同时调 `paintPending()` **和** `renderPendBar()`(漏后者 → 保存后待保存条不消失)
- **只加房租车位费批次**,电费批次不带这两列 - 确认框是 `.modal-mask.show`(**不是 `.open`**)
- **批次导出** `service.export_batch_xlsx(bid)` → 三个 Sheet(单据/明细/汇总),落 `output/exports/`
- ⚠️ `GET /api/batches/{id}` 的 `receipt` 在**响应顶层**,前端须显式接(`if (d.receipt) BATCH.receipt = d.receipt`) ## 系统骨架 / 渲染
- 迁移(已跑完):`tools/import_park_notes.py --apply` 写 31 行车位备注(AA);`migrate_next_bill.py --apply` 写 64 行 AJ - 启动 `python -m rentos.server --port 8899`;`--no-scheduler` 只开 Web;admin / 1989@rent05;另设只读访客 guest
- 导航 5 项:概览 / 电费通知单 / 房租车位费 / 公司管理 / 设置;真实 `<a href="#/...">` + hash 路由,
实现在 `app.js` 的 `PAGES` + `applyNav()`;**新增页面必须同时在 PAGES 与 `#nav` 登记**
- CSS 表格类名 `edgrid`;侧栏/顶栏读 `/api/overview`(含 wecom-cli,慢)
- **唯一渲染层** `rentos/notice_pdf.py`:reportlab + platypus 程序化绘制(**不用 win32com / LibreOffice**);
字体 `simhei.ttf` 子集嵌入(单份 10~32KB);A4、`MARGIN = 1.9cm`;房租 5 列 / 电费 7 列,多单元按 `spans` 合并首列
## 环境 ## 环境
- Python `C:/Users/fate/.workbuddy/binaries/python/versions/3.13.12/python.exe`(openpyxl / reportlab / pillow;**无 pypdfium2**) - Python `…/python/versions/3.13.12/python.exe`(openpyxl / reportlab / pillow;无 pypdfium2)
- venv `~/.workbuddy/binaries/python/envs/default/Scripts/python.exe`:**Playwright + reportlab + pypdfium2** - venv `…/envs/default/Scripts/python.exe`:**Playwright + reportlab + pypdfium2** → **单测与 UI 回归都用它**
→ **单测与 UI 回归都用它**(读 PDF 文本核对版式需 pypdfium2,缺了自动 skip 文本断言)
- 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 文案不放半角括号/`!` - `.bat` 必须 **cp936 + CRLF**;分支用 `goto` 标签;echo 不放半角括号/`!`
- 写 xlsx 前确认没在 Excel 打开(有 `~$` 即被占用);公式不重算 → 汇总写静态值 - **改完后端代码必须 kill 所有在跑实例再重启**(旧进程按旧逻辑写同一 `data/`,甚至自动发消息)
- **改完后端代码必须 kill 所有在跑实例再重启**(旧进程会按旧逻辑写同一 `data/`,甚至自动发消息)
## 启动 / 部署(192.168.68.121 · CentOS 7)
## 启动服务 - `start.bat [safe] [端口]` / `stop.bat [端口]`;`notify.enabled=True` 时双击 `start.bat` **会开定时发送**
- `start.bat [safe] [端口] [check]`:定位 Python(托管 3.13.12 → 系统 3.11 → PATH)、缺 openpyxl 自动装、 → **会话内启动一律 `start.bat safe`**
清端口旧实例(**只杀 python 进程**)、等就绪、开浏览器;服务跑独立窗口 - 云端 `http://192.168.68.121:8899/`(admin / 1989@rent05),容器 `rentos`,root / 12345678
- `stop.bat [端口]`:停服务;非 python 占用不处理 - **主用 Git 同步**:`RENTOS_SSH_PASSWORD=... python tools/deploy_git.py sync`(setup/pull/build/run/status/logs)
- `config.json` 的 `notify.enabled=True` → 双击 `start.bat` **会开定时发送**; - 代码落 `/opt/rentos/repo`,与数据同级独立 → `git reset --hard` **不碰线上数据**
只看页面用 `start.bat safe`(`--no-scheduler`);**会话内启动一律走 safe** - 构建必须在**仓库根**:`docker build -f deploy/Dockerfile -t rentos:latest .`,构建期零联网;
**切勿 `docker build … | tail`**(吞掉失败会拿旧镜像重启)
## 云端部署(192.168.68.121) - 服务器**无 Python 3** → 迁移只能 `docker exec rentos python /app/tools/xxx.py`
- **地址 `http://192.168.68.121:8899/`**(admin / 1989@rent05),容器名 `rentos`,`--restart unless-stopped`;CentOS 7,root / 12345678 - `docker -v` 的 SELinux 选项是**冒号 `:z`**(写成 `,z` 的症状:页面 200 但台账 0 行)
- **主用 Git 同步**:`RENTOS_SSH_PASSWORD=... python tools/deploy_git.py sync`(另有 setup/pull/build/run/status/logs) - 云端已能直发企微:`/usr/local/bin/wecom-cli` + 凭据 `/opt/rentos/wecom-config/`(700)
- 代码落 `/opt/rentos/repo`,与数据同级独立 → `git reset --hard` 不碰线上数据 - ⚠️ **线上是唯一生产方,本机只作预览;台账以线上为准**;**本机与云端不要同时开定时器**
- 服务器**可直连** git 站点(走 IPv4 0.2s;IPv6 不通);凭据由 setup 经 SFTP 直写 `~/.git-credentials`(600) - 远端 `https://git.thiztech.com/accounting/rentos.git`(自建 Gitea;http 301 跳 https),分支 `main`;
- 降级(服务器访问不到 Git 时):`python tools/deploy_docker.py deploy`(本机打包 SFTP 上传,解包进同一 `repo/`) 纳入/排除见 `.gitignore`(`data/sessions.json` 含活跃 token 务必排除);`.gitattributes`:
- 构建 `docker build -f deploy/Dockerfile -t rentos:latest .`,**上下文 = 仓库根**;构建期零联网 `* text=auto eol=lf`、`*.bat -text`、**Dockerfile 必须 LF**
(`deploy/wheels/` + `deploy/wecom/*.tgz` + `assets/fonts/simhei.ttf` 随仓库走)
- **必须用 `-f`**(Dockerfile 在 deploy/ 下);**切勿 `docker build … | tail`**(管道吞掉失败,会用旧镜像重启)
- 服务器布局 `/opt/rentos/`:`repo/`(代码)· `data/` `output/` `logs/` `uploads/` `wecom-config/`(bind volume)
- **服务器无 Python 3**(yum 仅 3.6.8)→ 迁移只能 `docker exec rentos python /app/tools/xxx.py`
- **`docker -v` 的 SELinux 选项是冒号 `:z`** 不是逗号 `,z`(写错症状:容器正常页面 200 但台账 0 行)
- 云端**已能直发企业微信**:`~/.config/wecom/`(`.encryption_key` 磁盘明文 + `credentials.enc`)可移植;
二进制放镜像 `/usr/local/bin/wecom-cli`(`shutil.which` 自动命中),凭据放宿主 `/opt/rentos/wecom-config/`(700)
- ⚠️ **线上是唯一生产方,本机只作预览/开发**;台账以线上为准。**本机与云端不要同时开定时器**(会重复发送)
- **本机 git 网络坑**:沙箱 `https_proxy=127.0.0.1:12934` 致 push `CONNECT tunnel failed 502` →
需绕过沙箱并 `env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY git push`
## 版本管理(Git)
- 远端 `https://git.thiztech.com/accounting/rentos.git`(自建 Gitea;**http 会 301 跳 https**);分支 `main`
- **纳入**:`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/`
- `.gitattributes`:`* text=auto eol=lf`;`*.bat -text`(保持 GBK+CRLF);二进制 `binary`;**Dockerfile 必须 LF**
(`\` 续行带 `\r` 会让 Linux 构建解析错误)
## 验证脚本(tools/) ## 验证脚本(tools/)
- 单测:`test_rules.py` `test_scheduler.py` `test_directory.py` `test_rent.py` `test_round5.py`(全量 **212 项**) - 单测 **219 项**:`test_rules` 45 · `test_directory` 33 · `test_rent` 53 · `test_round5` 54 · `test_accounts` 34
- UI 回归:`ui_verify.py`(台账+电费录入) `ui_verify2.py`(设置+锁定+撤销) `ui_verify3.py`(带入/照明/图片/留档) (`test_scheduler.py` 需活跃 8899 服务,无服务时失败属预期)
`ui_verify4.py`(公司管理/名册) `ui_verify5.py`(房租出单全链 43 项) `ui_verify6.py`(收款开票+导出 29 项) - UI 回归:**自带沙箱** `ui_verify7/8/9.py`;**需连服务** `ui_verify`·`2`·`3`·`4`·`5`·`6.py`
`ui_verify8.py`(账期区间端到端 30 项) `nav_verify.py` → 依次覆盖 台账+电费录入 / 设置+锁定+批量暂存 / 带入+照明+图片 / 公司管理 / 房租出单全链 /
- 端到端:`e2e_round5.py`(PDF 出单 → 下载验 `%PDF-` → 收款开票 → 导出 xlsx → zip → 排期 → 删除并回滚还原) 收款开票+导出 / 访客+收款同步 / 账期区间 / 删房产列+免租两类型+批量编辑
- 迁移:`import_park_notes.py`(车位备注→AA) `migrate_next_bill.py`(AJ=AC) `import_property_tax.py`(AD~AI,两段式) - **`tools/ui_verify_runner.py <脚本…>`**:每个脚本起**全新沙箱**(复制 `data/` + 改写 config 常量 +
- 都支持 `RENTOS_BASE` 换端口;跑完还原现场设置 另起端口 + 注入 `RENTOS_BASE`),真实 `data/` 零改动 —— 跑本次的 3/4/5/6 必用
- **断言不要写死行数/列数/家数** —— 读接口现取;表格**按表头文字定位**,不按第 N 列 - 端到端 `e2e_round5.py`(PDF 出单 → 收款开票 → 导出 xlsx → 排期 → 删除并回滚)
- 迁移:`migrate_rentfree_type.py`(删 AD~AI + 加 AK + 回填) · `import_park_notes.py`(车位备注→AA) · `migrate_next_bill.py`(AJ=AC)
- **断言不要写死行数/列数/日期** —— 读接口现取;表格**按列 id / 表头文字定位**,不按第 N 列
(2026-10-13 新增列使 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 列) - `output/rent/<批次号>` 是**目录**;台账确认框「行」标签 = 房间号优先 + 现租客(C 列)
- **数据一致性铁律**:线上台账权威,禁用本机数据覆盖线上 - **数据一致性铁律**:线上台账权威,禁用本机数据覆盖线上
## 待巴特菲拍板 ## 待巴特菲拍板
多办公地是否带公司名、车位是否算「办公地」、同名单据加期间、电费 0 用量是否出单、 多办公地是否带公司名、车位是否算「办公地」、同名单据加期间、电费 0 用量是否出单、
4F/5F 与爱种草渠道口径、通知日期/缴费期限口径、是否做收款核销(1934 行流水)。 4F/5F 与爱种草渠道口径、通知日期/缴费期限口径、是否做收款核销(1934 行流水)。
**未处置**:9 月电费批次多计 10 行 ¥167,947.41 / 少计 601B ¥299.67 / 出租方错记「精锐」。
...@@ -123,6 +123,15 @@ ...@@ -123,6 +123,15 @@
"sum": false, "sum": false,
"hide": false "hide": false
}, },
{
"id": "AK",
"label": "免租类型",
"group": "免租",
"type": "text",
"width": 110,
"sum": false,
"hide": false
},
{ {
"id": "M", "id": "M",
"label": "免租车位(月)", "label": "免租车位(月)",
...@@ -276,60 +285,6 @@ ...@@ -276,60 +285,6 @@
"sum": false, "sum": false,
"hide": false "hide": false
}, },
{
"id": "AD",
"label": "产证面积(㎡)",
"group": "房产",
"type": "num",
"width": 108,
"sum": false,
"hide": false
},
{
"id": "AE",
"label": "取得成本(元)",
"group": "房产",
"type": "money",
"width": 120,
"sum": true,
"hide": false
},
{
"id": "AF",
"label": "车位对应房间",
"group": "房产",
"type": "text",
"width": 100,
"sum": false,
"hide": false
},
{
"id": "AG",
"label": "季度计税租金",
"group": "房产税",
"type": "money",
"width": 112,
"sum": true,
"hide": false
},
{
"id": "AH",
"label": "从租季度房产税",
"group": "房产税",
"type": "money",
"width": 118,
"sum": true,
"hide": false
},
{
"id": "AI",
"label": "从价季度房产税",
"group": "房产税",
"type": "money",
"width": 118,
"sum": true,
"hide": false
},
{ {
"id": "AJ", "id": "AJ",
"label": "下一账单日", "label": "下一账单日",
...@@ -372,11 +327,6 @@ ...@@ -372,11 +327,6 @@
"AA": null, "AA": null,
"AB": "2026-09", "AB": "2026-09",
"AC": "2026-10-01", "AC": "2026-10-01",
"AD": 234.37,
"AE": 5735610.34,
"AG": 180000.0,
"AH": 20571.4286,
"AI": 88318.6826,
"AJ": "2026-10-01" "AJ": "2026-10-01"
}, },
{ {
...@@ -410,13 +360,8 @@ ...@@ -410,13 +360,8 @@
"AA": null, "AA": null,
"AB": "2026-11", "AB": "2026-11",
"AC": "2027-03-01", "AC": "2027-03-01",
"AD": 444.43, "AJ": "2027-03-01",
"AE": 10875687.84, "AK": "首N月免租"
"AF": null,
"AG": null,
"AH": null,
"AI": 167476.52,
"AJ": "2027-03-01"
}, },
{ {
"_id": "rent_parking-0003", "_id": "rent_parking-0003",
...@@ -449,10 +394,8 @@ ...@@ -449,10 +394,8 @@
"AA": null, "AA": null,
"AB": "2026-11", "AB": "2026-11",
"AC": "2027-03-01", "AC": "2027-03-01",
"AD": 401.49, "AJ": "2027-03-01",
"AE": 9824837.28, "AK": "首N月免租"
"AI": 151295.2506,
"AJ": "2027-03-01"
}, },
{ {
"_id": "rent_parking-0004", "_id": "rent_parking-0004",
...@@ -749,7 +692,8 @@ ...@@ -749,7 +692,8 @@
"AA": null, "AA": null,
"AB": "2026-10", "AB": "2026-10",
"AC": "2027-01-01", "AC": "2027-01-01",
"AJ": "2027-01-01" "AJ": "2027-01-01",
"AK": "首N月免租"
}, },
{ {
"_id": "rent_parking-0013", "_id": "rent_parking-0013",
...@@ -881,9 +825,6 @@ ...@@ -881,9 +825,6 @@
"AA": null, "AA": null,
"AB": null, "AB": null,
"AC": "2026-07-01", "AC": "2026-07-01",
"AD": 230.86,
"AE": 4147426.51,
"AI": 30403.6691,
"AJ": "2026-07-01" "AJ": "2026-07-01"
}, },
{ {
...@@ -917,9 +858,6 @@ ...@@ -917,9 +858,6 @@
"AA": null, "AA": null,
"AB": "2026-11", "AB": "2026-11",
"AC": "2027-02-01", "AC": "2027-02-01",
"AD": 180.7,
"AE": 3246430.2,
"AI": 23797.7259,
"AJ": "2027-02-01" "AJ": "2027-02-01"
}, },
{ {
...@@ -1382,9 +1320,6 @@ ...@@ -1382,9 +1320,6 @@
"AA": null, "AA": null,
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 250.27,
"AE": 4496077.69,
"AI": 94310.3498,
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1418,9 +1353,6 @@ ...@@ -1418,9 +1353,6 @@
"AA": null, "AA": null,
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 252.4,
"AE": 4534337.74,
"AI": 95113.0071,
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1454,9 +1386,6 @@ ...@@ -1454,9 +1386,6 @@
"AA": "外部99-1006", "AA": "外部99-1006",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207564.37,
"AF": "301",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1490,9 +1419,6 @@ ...@@ -1490,9 +1419,6 @@
"AA": "爱种草", "AA": "爱种草",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207564.37,
"AF": "302",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1526,9 +1452,6 @@ ...@@ -1526,9 +1452,6 @@
"AA": "外部个人", "AA": "外部个人",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207564.37,
"AF": "303",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1562,9 +1485,6 @@ ...@@ -1562,9 +1485,6 @@
"AA": "607", "AA": "607",
"AB": null, "AB": null,
"AC": "2026-10-01", "AC": "2026-10-01",
"AD": 32.03,
"AE": 207564.37,
"AF": "304",
"AJ": "2026-10-01" "AJ": "2026-10-01"
}, },
{ {
...@@ -1598,9 +1518,6 @@ ...@@ -1598,9 +1518,6 @@
"AA": "702&601A", "AA": "702&601A",
"AB": null, "AB": null,
"AC": "2026-10-01", "AC": "2026-10-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "402",
"AJ": "2026-10-01" "AJ": "2026-10-01"
}, },
{ {
...@@ -1634,9 +1551,6 @@ ...@@ -1634,9 +1551,6 @@
"AA": "607", "AA": "607",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "401",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1670,9 +1584,6 @@ ...@@ -1670,9 +1584,6 @@
"AA": "903-904", "AA": "903-904",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "403",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1706,9 +1617,6 @@ ...@@ -1706,9 +1617,6 @@
"AA": "310", "AA": "310",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "404",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1742,9 +1650,6 @@ ...@@ -1742,9 +1650,6 @@
"AA": "402-404", "AA": "402-404",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "804",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1778,9 +1683,6 @@ ...@@ -1778,9 +1683,6 @@
"AA": "402-404", "AA": "402-404",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "803",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1814,9 +1716,6 @@ ...@@ -1814,9 +1716,6 @@
"AA": "706B", "AA": "706B",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "801",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1850,9 +1749,6 @@ ...@@ -1850,9 +1749,6 @@
"AA": "501-502", "AA": "501-502",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "802",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1886,9 +1782,6 @@ ...@@ -1886,9 +1782,6 @@
"AA": "501-502", "AA": "501-502",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "501",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1922,9 +1815,6 @@ ...@@ -1922,9 +1815,6 @@
"AA": "402-404", "AA": "402-404",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "502",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1958,9 +1848,6 @@ ...@@ -1958,9 +1848,6 @@
"AA": "801-802", "AA": "801-802",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "503",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -1994,9 +1881,6 @@ ...@@ -1994,9 +1881,6 @@
"AA": "310", "AA": "310",
"AB": null, "AB": null,
"AC": "2026-10-01", "AC": "2026-10-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "504",
"AJ": "2026-10-01" "AJ": "2026-10-01"
}, },
{ {
...@@ -2030,9 +1914,6 @@ ...@@ -2030,9 +1914,6 @@
"AA": "801-802", "AA": "801-802",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "601",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -2066,9 +1947,6 @@ ...@@ -2066,9 +1947,6 @@
"AA": "706A", "AA": "706A",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "602",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -2102,9 +1980,6 @@ ...@@ -2102,9 +1980,6 @@
"AA": "501-502", "AA": "501-502",
"AB": null, "AB": null,
"AC": "2026-10-01", "AC": "2026-10-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "603",
"AJ": "2026-10-01" "AJ": "2026-10-01"
}, },
{ {
...@@ -2138,9 +2013,6 @@ ...@@ -2138,9 +2013,6 @@
"AA": "801-802", "AA": "801-802",
"AB": null, "AB": null,
"AC": "2027-01-01", "AC": "2027-01-01",
"AD": 32.03,
"AE": 207484.37,
"AF": "604",
"AJ": "2027-01-01" "AJ": "2027-01-01"
}, },
{ {
...@@ -2547,12 +2419,7 @@ ...@@ -2547,12 +2419,7 @@
"D": 251.335, "D": 251.335,
"E": 251.335, "E": 251.335,
"F": "(空置)", "F": "(空置)",
"N": 3, "N": 3
"AD": 251.335,
"AE": 4496077.69,
"AG": 2000.0,
"AH": 120.0,
"AI": 4720.88
}, },
{ {
"_id": "rent_parking-0070", "_id": "rent_parking-0070",
...@@ -2562,11 +2429,7 @@ ...@@ -2562,11 +2429,7 @@
"D": 251.335, "D": 251.335,
"E": 251.335, "E": 251.335,
"F": "(空置)", "F": "(空置)",
"N": 3, "N": 3
"AD": 251.335,
"AE": 4534337.74,
"AG": 0.0,
"AI": 4978.91
}, },
{ {
"_id": "rent_parking-0071", "_id": "rent_parking-0071",
...@@ -2581,10 +2444,6 @@ ...@@ -2581,10 +2444,6 @@
"K": 36, "K": 36,
"N": 3, "N": 3,
"P": 36300.0, "P": 36300.0,
"AD": 271.17,
"AE": 4441650.67,
"AG": 51857.1429,
"AH": 3111.43,
"AA": "原祈俊,换人承租" "AA": "原祈俊,换人承租"
}, },
{ {
...@@ -2599,10 +2458,6 @@ ...@@ -2599,10 +2458,6 @@
"J": "2028-10-31", "J": "2028-10-31",
"K": 36, "K": 36,
"N": 3, "N": 3,
"AD": 271.17,
"AE": 5301333.64,
"AG": 53228.5714,
"AH": 3193.71,
"AA": "原祈俊,换人承租" "AA": "原祈俊,换人承租"
}, },
{ {
...@@ -2618,10 +2473,6 @@ ...@@ -2618,10 +2473,6 @@
"K": 12, "K": 12,
"N": 3, "N": 3,
"P": 700.0, "P": 700.0,
"AD": 32.03,
"AE": 207484.37,
"AF": "901",
"AG": 2100.0,
"AA": "外部89-1203" "AA": "外部89-1203"
}, },
{ {
...@@ -2633,9 +2484,6 @@ ...@@ -2633,9 +2484,6 @@
"E": 32.03, "E": 32.03,
"F": "(空置)", "F": "(空置)",
"N": 3, "N": 3,
"AD": 32.03,
"AE": 207484.37,
"AF": "902",
"AA": "内部-胡小凤" "AA": "内部-胡小凤"
}, },
{ {
...@@ -2646,10 +2494,7 @@ ...@@ -2646,10 +2494,7 @@
"D": 32.03, "D": 32.03,
"E": 32.03, "E": 32.03,
"F": "(空置)", "F": "(空置)",
"N": 3, "N": 3
"AD": 32.03,
"AE": 207484.37,
"AF": "903"
}, },
{ {
"_id": "rent_parking-0076", "_id": "rent_parking-0076",
...@@ -2664,10 +2509,6 @@ ...@@ -2664,10 +2509,6 @@
"K": 12, "K": 12,
"N": 3, "N": 3,
"P": 480.0, "P": 480.0,
"AD": 32.03,
"AE": 207484.37,
"AF": "904",
"AG": 1440.0,
"AA": "内部-徐传勇" "AA": "内部-徐传勇"
}, },
{ {
...@@ -2678,10 +2519,7 @@ ...@@ -2678,10 +2519,7 @@
"D": 236.71, "D": 236.71,
"E": 236.71, "E": 236.71,
"F": "(空置)", "F": "(空置)",
"N": 3, "N": 3
"AD": 236.71,
"AE": 5792866.99,
"AI": 89200.4751
}, },
{ {
"_id": "rent_parking-0078", "_id": "rent_parking-0078",
...@@ -2695,11 +2533,7 @@ ...@@ -2695,11 +2533,7 @@
"J": "2027-12-17", "J": "2027-12-17",
"K": 48, "K": 48,
"N": 3, "N": 3,
"P": 17742.1329, "P": 17742.1329
"AD": 253.61,
"AE": 6206114.46,
"AG": 53226.3988,
"AH": 6083.017
}, },
{ {
"_id": "rent_parking-0079", "_id": "rent_parking-0079",
...@@ -2713,11 +2547,7 @@ ...@@ -2713,11 +2547,7 @@
"J": "2027-11-19", "J": "2027-11-19",
"K": 48, "K": 48,
"N": 3, "N": 3,
"P": 17115.2758, "P": 17115.2758
"AD": 255.77,
"AE": 6258967.13,
"AG": 51345.8275,
"AH": 5868.0946
}, },
{ {
"_id": "rent_parking-0080", "_id": "rent_parking-0080",
...@@ -2731,11 +2561,7 @@ ...@@ -2731,11 +2561,7 @@
"J": "2027-11-19", "J": "2027-11-19",
"K": 48, "K": 48,
"N": 3, "N": 3,
"P": 13501.7758, "P": 13501.7758
"AD": 201.77,
"AE": 4937652.23,
"AG": 40505.3275,
"AH": 4629.1803
}, },
{ {
"_id": "rent_parking-0081", "_id": "rent_parking-0081",
...@@ -2749,11 +2575,7 @@ ...@@ -2749,11 +2575,7 @@
"J": "2027-11-19", "J": "2027-11-19",
"K": 48, "K": 48,
"N": 3, "N": 3,
"P": 16852.9625, "P": 16852.9625
"AD": 251.85,
"AE": 6163049.72,
"AG": 50558.8875,
"AH": 5778.1586
}, },
{ {
"_id": "rent_parking-0082", "_id": "rent_parking-0082",
...@@ -2767,11 +2589,7 @@ ...@@ -2767,11 +2589,7 @@
"J": "2026-09-10", "J": "2026-09-10",
"K": 16, "K": 16,
"N": 3, "N": 3,
"P": 16544.0, "P": 16544.0
"AD": 247.24,
"AE": 4441650.67,
"AG": 41360.0,
"AH": 4726.8571
}, },
{ {
"_id": "rent_parking-0083", "_id": "rent_parking-0083",
...@@ -2785,11 +2603,7 @@ ...@@ -2785,11 +2603,7 @@
"J": "2026-09-15", "J": "2026-09-15",
"K": 16, "K": 16,
"N": 3, "N": 3,
"P": 19747.0, "P": 19747.0
"AD": 295.1,
"AE": 5301333.64,
"AG": 45950.75,
"AH": 5251.5143
}, },
{ {
"_id": "rent_parking-0084", "_id": "rent_parking-0084",
...@@ -2803,12 +2617,7 @@ ...@@ -2803,12 +2617,7 @@
"J": "2025-12-31", "J": "2025-12-31",
"K": 12, "K": 12,
"N": 3, "N": 3,
"P": 40000.0, "P": 40000.0
"AD": 228.91,
"AE": 4112399.25,
"AG": 120000.0,
"AH": 13714.2857,
"AI": 30146.8591
}, },
{ {
"_id": "rent_parking-0085", "_id": "rent_parking-0085",
...@@ -2818,10 +2627,7 @@ ...@@ -2818,10 +2627,7 @@
"D": 227.32, "D": 227.32,
"E": 227.32, "E": 227.32,
"F": "(空置)", "F": "(空置)",
"N": 3, "N": 3
"AD": 227.32,
"AE": 4083839.58,
"AI": 29937.4602
}, },
{ {
"_id": "rent_parking-0086", "_id": "rent_parking-0086",
...@@ -2832,11 +2638,7 @@ ...@@ -2832,11 +2638,7 @@
"E": 253.7, "E": 253.7,
"F": "上海明境建筑安装有限公司", "F": "上海明境建筑安装有限公司",
"N": 3, "N": 3,
"P": 17824.0, "P": 17824.0
"AD": 253.7,
"AE": 5342230.25,
"AG": 53472.0,
"AH": 6111.0857
}, },
{ {
"_id": "rent_parking-0087", "_id": "rent_parking-0087",
...@@ -2847,11 +2649,7 @@ ...@@ -2847,11 +2649,7 @@
"E": 255.86, "E": 255.86,
"F": "(空置)", "F": "(空置)",
"N": 3, "N": 3,
"P": 17824.0, "P": 17824.0
"AD": 255.86,
"AE": 5387709.16,
"AG": 53472.0,
"AH": 6111.0857
}, },
{ {
"_id": "rent_parking-0088", "_id": "rent_parking-0088",
...@@ -2865,11 +2663,7 @@ ...@@ -2865,11 +2663,7 @@
"J": "2025-12-31", "J": "2025-12-31",
"K": 12, "K": 12,
"N": 3, "N": 3,
"P": 10000.0, "P": 10000.0
"AD": 200.27,
"AE": 4217269.49,
"AG": 30000.0,
"AH": 3428.5714
}, },
{ {
"_id": "rent_parking-0089", "_id": "rent_parking-0089",
...@@ -2883,15 +2677,11 @@ ...@@ -2883,15 +2677,11 @@
"J": "2025-12-31", "J": "2025-12-31",
"K": 12, "K": 12,
"N": 3, "N": 3,
"P": 10000.0, "P": 10000.0
"AD": 251.94,
"AE": 5305173.87,
"AG": 30000.0,
"AH": 3428.5714
} }
], ],
"tail": [], "tail": [],
"next_seq": 95, "next_seq": 95,
"updated_at": "2026-10-13 11:04:15", "updated_at": "2026-10-13 14:31:33",
"updated_by": "导入车位备注" "updated_by": "删除房产/房产税列 + 新增免租类型"
} }
\ No newline at end of file
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
免租期识别 免租期识别(两种类型)
========== ======================
数据源:「房租车位费」台账(rentos/tables.py · rent_parking)的 数据源:「房租车位费」台账(rentos/tables.py · rent_parking)的
楼层(B) / 房间/车位号(C) / 现租客(F) / 合同期始(I) / 免租期(月)(L) 楼层(B) / 房间/车位号(C) / 现租客(F) / 合同期始(I) / 合同期止(J) /
免租期(月)(L) / 免租类型(AK)
免租窗口 = [合同期始, 合同期始 + 免租期月数),含起始日、不含结束日。 「免租类型」两种取值(留空 = 无免租):
即 2026-08-16 起免租 0.5 个月 → 2026-08-16 ~ 2026-08-30 免租,08-31 起正常计费。
小数月按 30 天/月 折算(0.5 → 15 天)。 1. 首N月免租 从合同期始(I)起免租 N 个月,N 取「免租期(月)」L 列。
窗口 = [I, I + N 个月),含起始日、不含结束日。
即 2026-08-16 起免租 0.5 个月 → 2026-08-16 ~ 2026-08-30 免租,
08-31 起正常计费。小数月按 30 天/月 折算(0.5 → 15 天)。
2. 每年8-9月免租 合约期 [I, J] 内**每个自然年**的 8 月、9 月整月免租。
窗口 = [max(该年08-01, I), min(该年10-01, J+1天)),
即跨年按合约边界裁剪;合约期止(J)按**含当日**处理。
兼容:类型留空但「免租期(月)」有值 → 仍按「首N月免租」识别(历史数据)。
对外提供: 对外提供:
windows() 全部免租窗口 windows() 全部免租窗口(每条带 type)
active(on) 指定日期仍在免租期内的单元 active(on) 指定日期仍在免租期内的单元
hit(units, on) 给定单元集合命中的免租记录 hit(units, on) 给定单元集合命中的免租记录(按时点)
overlaps(units, pf, pt) 按账单期间判定命中(含交集)—— 「每年8-9月」用这条
types() 两种免租类型常量
norm(u) 单元号归一化(去「室」、去空格、大写) norm(u) 单元号归一化(去「室」、去空格、大写)
注意:「免租车位(月)」没有起算日,不参与判定。 注意:「免租车位(月)」没有起算日,不参与判定。
""" """
import datetime as dt import datetime as dt
from . import tables
TABLE_KEY = "rent_parking" TABLE_KEY = "rent_parking"
UNIT_COL = "C" UNIT_COL = "C"
TENANT_COL = "F" TENANT_COL = "F"
FLOOR_COL = "B" FLOOR_COL = "B"
BEGIN_COL = "I" BEGIN_COL = "I"
FREE_MONTH_COL = "L" END_COL = tables.BILL_COLS["end"] # J 合同期止
FREE_MONTH_COL = tables.BILL_COLS["rentfree"] # L 免租期(月)
TYPE_COL = tables.FREE_TYPE_COL # AK 免租类型
FIRST_N = tables.FREE_TYPE_FIRST_N # 首N月免租
ANNUAL = tables.FREE_TYPE_ANNUAL # 每年8-9月免租
# ---------------------------------------------------------------- 日期工具 # ---------------------------------------------------------------- 日期工具
...@@ -58,7 +77,7 @@ def add_months(d, n): ...@@ -58,7 +77,7 @@ def add_months(d, n):
def end_of(begin, months): def end_of(begin, months):
"""免租期结束日(不含)。0.5 个月 → begin + 15 天。""" """首N月免租的结束日(不含)。0.5 个月 → begin + 15 天。"""
whole = int(months) whole = int(months)
frac = float(months) - whole frac = float(months) - whole
d = add_months(begin, whole) d = add_months(begin, whole)
...@@ -76,34 +95,82 @@ def norm(u): ...@@ -76,34 +95,82 @@ def norm(u):
return s.upper() return s.upper()
def types():
"""两种免租类型(供前端下拉 / 校验)。"""
return (FIRST_N, ANNUAL)
# ---------------------------------------------------------------- 窗口 # ---------------------------------------------------------------- 窗口
def windows(table=None): def _base(r, unit):
"""从「房租车位费」台账读出全部免租窗口。台账未就绪时返回空列表。""" return {
if table is None: "unit": unit,
from . import tables "key": norm(unit),
table = tables.load_table(TABLE_KEY) "tenant": str(r.get(TENANT_COL) or "").strip(),
out = [] "floor": str(r.get(FLOOR_COL) or "").strip(),
for r in (table or {}).get("rows") or []: }
def _first_n_window(r, unit):
"""首N月免租:合同期始 + 免租月数。无有效月数 / 无起始日 → None。"""
begin = _d(r.get(BEGIN_COL)) begin = _d(r.get(BEGIN_COL))
try: try:
months = float(r.get(FREE_MONTH_COL) or 0) months = float(r.get(FREE_MONTH_COL) or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
months = 0.0 months = 0.0
if not begin or months <= 0: if not begin or months <= 0:
continue return None
end = end_of(begin, months) end = end_of(begin, months)
out.append({ return {**_base(r, unit), "type": FIRST_N,
"unit": str(r.get(UNIT_COL) or "").strip(), "begin": begin.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d"),
"key": norm(r.get(UNIT_COL)), "months": months, "_begin": begin, "_end": end}
"tenant": str(r.get(TENANT_COL) or "").strip(),
"floor": str(r.get(FLOOR_COL) or "").strip(),
"begin": begin.strftime("%Y-%m-%d"), def _annual_windows(r, unit):
"end": end.strftime("%Y-%m-%d"), """每年8-9月免租:合约期内每个自然年的 8、9 月整月,按合约边界裁剪。"""
"months": months, c_beg = _d(r.get(BEGIN_COL))
"_begin": begin, c_end = _d(r.get(END_COL))
"_end": end, if not c_beg:
}) return []
out.sort(key=lambda x: x["begin"]) # 合约期止缺失 → 只算起始那一年;止按含当日 → 裁剪上限用 J+1 天
y_from = c_beg.year
y_to = c_end.year if c_end else c_beg.year
end_lim = (c_end + dt.timedelta(days=1)) if c_end else None
out = []
for y in range(y_from, y_to + 1):
w_beg = dt.date(y, 8, 1)
w_end = dt.date(y, 10, 1) # 不含(10-01 起正常计费)
if c_beg > w_beg:
w_beg = c_beg
if end_lim is not None and end_lim < w_end:
w_end = end_lim
if w_beg >= w_end:
continue # 该年 8-9 月完全落在合约期外
out.append({**_base(r, unit), "type": ANNUAL, "year": y, "months": 2,
"begin": w_beg.strftime("%Y-%m-%d"),
"end": w_end.strftime("%Y-%m-%d"),
"_begin": w_beg, "_end": w_end})
return out
def windows(table=None):
"""从「房租车位费」台账读出全部免租窗口。台账未就绪时返回空列表。"""
if table is None:
table = tables.load_table(TABLE_KEY)
out = []
for r in (table or {}).get("rows") or []:
unit = str(r.get(UNIT_COL) or "").strip()
if not unit:
continue
typ = str(r.get(TYPE_COL) or "").strip()
if typ == ANNUAL:
out.extend(_annual_windows(r, unit))
continue
# 「首N月免租」;类型留空时也走这条(历史数据兼容)
w = _first_n_window(r, unit)
if w:
out.append(w)
out.sort(key=lambda x: (x["begin"], x["unit"]))
return out return out
...@@ -114,7 +181,7 @@ def active(on=None, table=None): ...@@ -114,7 +181,7 @@ def active(on=None, table=None):
def hit(units, on=None, table=None): def hit(units, on=None, table=None):
"""给定单元集合,返回命中的免租记录(含命中的原始单元号)。""" """给定单元集合,返回命中的免租记录(含命中的原始单元号)—— 按时点判定。"""
if not units: if not units:
return [] return []
want = {norm(u): u for u in units} want = {norm(u): u for u in units}
...@@ -125,9 +192,41 @@ def hit(units, on=None, table=None): ...@@ -125,9 +192,41 @@ def hit(units, on=None, table=None):
return out return out
def ranges_overlap(a_from, a_to, b_from, b_to):
"""两个闭区间是否有交集。任一为空 → False。"""
if not (a_from and a_to and b_from and b_to):
return False
return a_from <= b_to and b_from <= a_to
def overlaps(units, period_from, period_to, table=None):
"""按**账单期间**判定:期间与免租窗口有交集即命中。
「每年8-9月免租」必须用这条 —— 通知日通常在该期开始之前,
按时点判定会漏掉整期都落在 8、9 月的账单。
"""
if not units:
return []
f, t = _d(period_from), _d(period_to)
if not (f and t):
return []
want = {norm(u): u for u in units}
out = []
for w in windows(table):
if w["key"] not in want:
continue
if ranges_overlap(f, t, w["_begin"], w["_end"] - dt.timedelta(days=1)):
out.append({**w, "matched": want[w["key"]]})
return out
def describe(hits): def describe(hits):
"""把命中的免租记录拼成一句话,用于状态说明。""" """把命中的免租记录拼成一句话,用于状态说明。"""
return "、".join(f"{h['matched']}(免租至 {h['end']})" for h in hits) out = []
for h in hits:
tag = h.get("type") or FIRST_N
out.append(f"{h['matched']}({tag},免租至 {h['end']})")
return "、".join(out)
def strip(w): def strip(w):
......
...@@ -53,6 +53,7 @@ Python 标准库 http.server,零外部依赖。启动: ...@@ -53,6 +53,7 @@ Python 标准库 http.server,零外部依赖。启动:
GET /api/tables 台账列表 GET /api/tables 台账列表
GET /api/tables/{key} 整表数据(列定义 + 行) GET /api/tables/{key} 整表数据(列定义 + 行)
PUT /api/tables/{key}/cell 改单元格(须带 confirm=true) PUT /api/tables/{key}/cell 改单元格(须带 confirm=true)
PUT /api/tables/{key}/cells 批量改单元格(须带 confirm=true)
POST /api/tables/{key}/row 新增行 / 撤销时放回(须带 confirm=true) POST /api/tables/{key}/row 新增行 / 撤销时放回(须带 confirm=true)
DELETE /api/tables/{key}/row 删除行(须带 confirm=true) DELETE /api/tables/{key}/row 删除行(须带 confirm=true)
GET /api/tables/{key}/backups 备份列表 GET /api/tables/{key}/backups 备份列表
...@@ -333,6 +334,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -333,6 +334,7 @@ class Handler(BaseHTTPRequestHandler):
"rooms": _P.load_unit_map(rooms_only=True), "rooms": _P.load_unit_map(rooms_only=True),
"room_owners": _P.load_owner_map(rooms_only=True), "room_owners": _P.load_owner_map(rooms_only=True),
"free": [rentfree.strip(w) for w in rentfree.active()], "free": [rentfree.strip(w) for w in rentfree.active()],
"free_types": list(rentfree.types()), # 「免租类型」列候选
"companies": directory.company_names(), # 名册候选 "companies": directory.company_names(), # 名册候选
"owners": list(directory.owners().keys()), "owners": list(directory.owners().keys()),
}) })
...@@ -348,6 +350,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -348,6 +350,7 @@ class Handler(BaseHTTPRequestHandler):
act = [rentfree.strip(w) for w in rentfree.active(on)] act = [rentfree.strip(w) for w in rentfree.active(on)]
return self._send(200, {"ok": True, "on": on, return self._send(200, {"ok": True, "on": on,
"count": len(wins), "windows": wins, "count": len(wins), "windows": wins,
"types": list(rentfree.types()),
"active": act}) "active": act})
m = re.match(r"^/api/tables/([A-Za-z0-9_\-]+)/backups$", path) m = re.match(r"^/api/tables/([A-Za-z0-9_\-]+)/backups$", path)
...@@ -656,6 +659,20 @@ class Handler(BaseHTTPRequestHandler): ...@@ -656,6 +659,20 @@ class Handler(BaseHTTPRequestHandler):
plan_key=body.get("plan"))}) plan_key=body.get("plan"))})
# ---- 租金台账:单元格 / 行 # ---- 租金台账:单元格 / 行
# 批量改单元格(前端「批量编辑」攒够一次提交;单次落盘 + 单次备份)
m = re.match(r"^/api/tables/([A-Za-z0-9_\-]+)/cells$", path)
if m and method in ("PUT", "POST"):
if not _confirmed(body):
return self._send(428, _NEED_CONFIRM)
raw = body.get("changes") or []
ups = [(str(c.get("row_id") or ""), str(c.get("col") or ""), c.get("value"))
for c in raw if isinstance(c, dict)]
who = str(body.get("who") or "").strip() or "批量修改"
r = tables.update_cells(m.group(1), ups, who=who)
t = tables.load_table(m.group(1))
return self._send(200, {"ok": True, **r,
"totals": tables.totals_of(t)})
m = re.match(r"^/api/tables/([A-Za-z0-9_\-]+)/cell$", path) m = re.match(r"^/api/tables/([A-Za-z0-9_\-]+)/cell$", path)
if m and method in ("PUT", "POST"): if m and method in ("PUT", "POST"):
if not _confirmed(body): if not _confirmed(body):
......
...@@ -255,7 +255,18 @@ def rent_preview(from_date=None, to_date=None, show_all=False, cfg=None): ...@@ -255,7 +255,18 @@ def rent_preview(from_date=None, to_date=None, show_all=False, cfg=None):
raise ValueError("「房租车位费」台账尚未就绪。") raise ValueError("「房租车位费」台账尚未就绪。")
hit, outside, bad = RP.classify_period(rows, from_date, to_date) hit, outside, bad = RP.classify_period(rows, from_date, to_date)
ok = hit + outside if show_all else hit ok = hit + outside if show_all else hit
# 免租标注:按每行自己的账期与免租窗口的交集判定(页面据此打标 / 提示)
for r in ok:
try:
rf = rentfree.overlaps([r["unit"]], r["period_from"], r["period_to"])
except Exception: # noqa: BLE001
rf = []
r["rentfree"] = [rentfree.strip(h) for h in rf]
docs = RP.group_notices(ok) docs = RP.group_notices(ok)
annotate_rentfree(docs, None, cfg, kind=RENT_KEY)
summary = RP.summary(ok, bad)
summary["rentfree_count"] = sum(1 for d in docs if d.get("rentfree"))
summary["rentfree_rows"] = sum(1 for r in ok if r.get("rentfree"))
return { return {
"from": str(from_date or ""), "from": str(from_date or ""),
"to": str(to_date or ""), "to": str(to_date or ""),
...@@ -266,7 +277,7 @@ def rent_preview(from_date=None, to_date=None, show_all=False, cfg=None): ...@@ -266,7 +277,7 @@ def rent_preview(from_date=None, to_date=None, show_all=False, cfg=None):
**RP.strip_doc(d), **RP.strip_doc(d),
"owner_name": (owners_config(cfg).get(d["owner"]) or {}).get("name", ""), "owner_name": (owners_config(cfg).get(d["owner"]) or {}).get("name", ""),
} for d in docs], } for d in docs],
"summary": RP.summary(ok, bad), "summary": summary,
"all_count": len(rows), "all_count": len(rows),
"owners": list(owners_config(cfg).keys()), "owners": list(owners_config(cfg).keys()),
} }
...@@ -437,7 +448,10 @@ def rollback_rent_advance(batch): ...@@ -437,7 +448,10 @@ def rollback_rent_advance(batch):
def annotate_rentfree(docs, on_date, cfg=None, kind="elec"): def annotate_rentfree(docs, on_date, cfg=None, kind="elec"):
"""给单据打上「免租期命中」标记,供页面提示与发送拦截使用。 """给单据打上「免租期命中」标记,供页面提示与发送拦截使用。
判定时点取单据的通知日期;若该账单计划关闭了「免租期内不通知」,则一律不标记。 判定方式:
· 单据带账期(房租车位费)→ 按**账单期间与免租窗口是否有交集**判定;
· 无账期(电费)→ 按通知日的时点判定。
若该账单计划关闭了「免租期内不通知」,则一律不标记。
""" """
cfg = cfg or store.load_config() cfg = cfg or store.load_config()
plan = plans.of(cfg, kind) plan = plans.of(cfg, kind)
...@@ -447,7 +461,11 @@ def annotate_rentfree(docs, on_date, cfg=None, kind="elec"): ...@@ -447,7 +461,11 @@ def annotate_rentfree(docs, on_date, cfg=None, kind="elec"):
if not enabled: if not enabled:
d["rentfree"] = [] d["rentfree"] = []
continue continue
pf, pt = d.get("period_from"), d.get("period_to")
try: try:
if pf and pt:
hits = rentfree.overlaps(d.get("units") or [], pf, pt)
else:
hits = rentfree.hit(d.get("units") or [], day) hits = rentfree.hit(d.get("units") or [], day)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
hits = [] hits = []
......
...@@ -63,6 +63,12 @@ _RENT_PARKING_COLS = [ ...@@ -63,6 +63,12 @@ _RENT_PARKING_COLS = [
_c("J", "合同期止", "合约期", "date", 88), _c("J", "合同期止", "合约期", "date", 88),
_c("K", "租赁期(月)", "", "num", 76), _c("K", "租赁期(月)", "", "num", 76),
_c("L", "免租期(月)", "", "num", 76), _c("L", "免租期(月)", "", "num", 76),
# ---- 免租类型(2026-10-13 新增)
# 两种类型(留空 = 无免租):
# 「首N月免租」 从合同期始(I)起免租 N 个月,月数看上面的「免租期(月)」L 列
# 「每年8-9月免租」 合约期 [I, J] 内每个自然年的 8 月、9 月整月免租
# 键用 AK(不占用字母顺序),显示位置紧邻 L,便于对照填写。
_c("AK", "免租类型", "免租", "text", 110),
_c("M", "免租车位(月)", "", "num", 84), _c("M", "免租车位(月)", "", "num", 84),
_c("N", "付款周期(月)", "", "num", 84), _c("N", "付款周期(月)", "", "num", 84),
_c("O", "每月物业费(元/月)", "", "money", 116, True), _c("O", "每月物业费(元/月)", "", "money", 116, True),
...@@ -80,14 +86,6 @@ _RENT_PARKING_COLS = [ ...@@ -80,14 +86,6 @@ _RENT_PARKING_COLS = [
_c("AA", "备注", "", "text", 140), _c("AA", "备注", "", "text", 140),
_c("AB", "最近收款月份", "账期", "text", 92), _c("AB", "最近收款月份", "账期", "text", 92),
_c("AC", "账单起始日", "账期", "date", 96), _c("AC", "账单起始日", "账期", "date", 96),
# ---- 房产 / 房产税(2026-10-11 由《三家仓储房产税(2026)》并入)
# 这一组只做登记,不参与出单;出单只看 A~AC。
_c("AD", "产证面积(㎡)", "房产", "num", 108),
_c("AE", "取得成本(元)", "房产", "money", 120, True),
_c("AF", "车位对应房间", "房产", "text", 100),
_c("AG", "季度计税租金", "房产税", "money", 112, True),
_c("AH", "从租季度房产税", "房产税", "money", 118, True),
_c("AI", "从价季度房产税", "房产税", "money", 118, True),
# ---- 推送排期(2026-10-12 新增) # ---- 推送排期(2026-10-12 新增)
# 只用来算「定时推送日」:推送日 = 下一账单日 − 该账单的提前天数。 # 只用来算「定时推送日」:推送日 = 下一账单日 − 该账单的提前天数。
# 出单口径仍看「账单起始日」(AC);两列在「出单推进账期」时一起往后走, # 出单口径仍看「账单起始日」(AC);两列在「出单推进账期」时一起往后走,
...@@ -98,13 +96,21 @@ _RENT_PARKING_COLS = [ ...@@ -98,13 +96,21 @@ _RENT_PARKING_COLS = [
# 出单所需的列(供出单模块引用,避免散落魔法字符串) # 出单所需的列(供出单模块引用,避免散落魔法字符串)
BILL_COLS = { BILL_COLS = {
"owner": "A", "unit": "C", "tenant": "F", "owner": "A", "unit": "C", "tenant": "F",
"begin": "I", "end": "J", "rentfree": "L", "cycle": "N", "begin": "I", "end": "J", "rentfree": "L", "rentfree_type": "AK",
"fee": "O", "rent": "P", "last_paid": "AB", "bill_start": "AC", "cycle": "N", "fee": "O", "rent": "P", "last_paid": "AB", "bill_start": "AC",
"next_bill": "AJ", "next_bill": "AJ",
} }
# 房产 / 房产税列组(仅供导入器与页面展示引用) # 免租类型(「免租类型」列 AK 的合法取值;留空 = 无免租)
TAX_COLS = ("AD", "AE", "AF", "AG", "AH", "AI") FREE_TYPE_COL = "AK"
FREE_TYPE_FIRST_N = "首N月免租"
FREE_TYPE_ANNUAL = "每年8-9月免租"
FREE_TYPES = (FREE_TYPE_FIRST_N, FREE_TYPE_ANNUAL)
# 房产 / 房产税列(AD~AI:产证面积、取得成本、车位对应房间、
# 季度计税租金、从租季度房产税、从价季度房产税)已于 2026-10-13 整体删除,
# 原导入器 tools/import_property_tax.py 随之退役(不再运行)。
RETIRED_TAX_COLS = ("AD", "AE", "AF", "AG", "AH", "AI")
TABLES = [ TABLES = [
{ {
......
# -*- coding: utf-8 -*-
"""一次性迁移:删除「房产 / 房产税」六列 + 新增「免租类型」列并回填。
做三件事(幂等,重复跑不产生新改动):
1. 从台账列定义与每行数据里删掉 AD~AI(产证面积 / 取得成本 / 车位对应房间 /
季度计税租金 / 从租季度房产税 / 从价季度房产税);
2. 列定义按代码(rentos/tables.py)重建 —— 自动带上新增的「免租类型」列(键 AK);
3. 回填「免租类型」:凡「免租期(月)」(L) > 0 且尚未填类型的行 → 「首N月免租」。
用法(两段式,先干跑后落盘)
python tools/migrate_rentfree_type.py # 干跑,只报告
python tools/migrate_rentfree_type.py --apply # 真写台账(自动留备份)
落盘前会调用 tables._backup(),保留最近 10 份;改错可在页面「恢复备份」里回退。
"""
import argparse
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
from rentos import tables # noqa: E402
RETIRED = tables.RETIRED_TAX_COLS
TYPE_COL = tables.FREE_TYPE_COL
FIRST_N = tables.FREE_TYPE_FIRST_N
FREE_MONTH_COL = tables.BILL_COLS["rentfree"] # L
def _num(v):
if v is None or v == "":
return 0.0
try:
return float(str(v).replace(",", "").strip())
except (TypeError, ValueError):
return 0.0
def plan(table):
"""→ (新表, 报告 dict),不落盘。"""
cols = [dict(c) for c in tables._RENT_PARKING_COLS]
keep = {c["id"] for c in cols}
old_ids = [c["id"] for c in (table.get("columns") or [])]
dropped_cols = [c for c in old_ids if c not in keep]
rows = []
dropped_cells = 0
filled = []
for r in table.get("rows") or []:
nr = {}
for k, v in r.items():
if k in RETIRED:
if v not in (None, ""):
dropped_cells += 1
continue
nr[k] = v
# 回填免租类型
if _num(nr.get(FREE_MONTH_COL)) > 0 and not str(nr.get(TYPE_COL) or "").strip():
nr[TYPE_COL] = FIRST_N
filled.append(nr.get("C"))
rows.append(nr)
new = dict(table)
new["columns"] = cols
new["rows"] = rows
report = {
"dropped_cols": dropped_cols,
"dropped_cells": dropped_cells,
"added_cols": [c["id"] for c in cols if c["id"] not in old_ids],
"filled_type": filled,
"type_col_present": TYPE_COL in keep,
"rows": len(rows),
}
return new, report
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="真正写台账(默认只干跑)")
ap.add_argument("--key", default=tables.RENT_KEY, help="表键,默认 rent_parking")
a = ap.parse_args()
raw = tables._read(tables._path(a.key))
if not raw:
print("台账不存在或已损坏:%s" % tables._path(a.key))
return 1
new, rep = plan(raw)
print("表:%s 行数 %d" % (a.key, rep["rows"]))
print(" 删除列:%s" % (rep["dropped_cols"] or "(无)"))
print(" 新增列:%s" % (rep["added_cols"] or "(无)"))
print(" 清理掉的存量单元格值:%d 个" % rep["dropped_cells"])
print(" 回填「%s」的行:%s" % (FIRST_N, "、".join(rep["filled_type"]) or "(无)"))
print(" 「免租类型」列在位:%s(键 %s)" % (rep["type_col_present"], TYPE_COL))
if not a.apply:
print("\n干跑结束。加 --apply 才会写入。")
return 0
tables._backup(a.key, raw) # 改动前先留一份
tables._touch(new, "删除房产/房产税列 + 新增免租类型")
tables.save_table(a.key, new)
print("\n已写入 %s" % tables._path(a.key))
return 0
if __name__ == "__main__":
sys.exit(main())
...@@ -112,6 +112,74 @@ class TestRentfree(unittest.TestCase): ...@@ -112,6 +112,74 @@ class TestRentfree(unittest.TestCase):
self.assertEqual(hits[0]["tenant"], "杭州逢林科技有限公司") self.assertEqual(hits[0]["tenant"], "杭州逢林科技有限公司")
self.assertEqual(rentfree.hit(["306室"], "2026-08-20"), []) self.assertEqual(rentfree.hit(["306室"], "2026-08-20"), [])
# ---------------- 两种免租类型(2026-10-13 新增) ----------------
@staticmethod
def _tab(rows):
return {"rows": rows}
def test_two_types(self):
self.assertEqual(list(rentfree.types()), ["首N月免租", "每年8-9月免租"])
self.assertEqual(rentfree.types(), tables.FREE_TYPES)
def test_first_n_type_rules(self):
"""首N月:类型为空但填了月数 → 仍按首N月(兼容历史);标了类型但月数为 0 → 无窗口。"""
t = self._tab([{"C": "902室", "I": "2026-01-10", "L": 2}])
ws = rentfree.windows(t)
self.assertEqual(len(ws), 1)
self.assertEqual(ws[0]["type"], "首N月免租")
self.assertEqual(ws[0]["end"], "2026-03-10")
t2 = self._tab([{"C": "901室", "I": "2026-01-10", "L": 0, "AK": "首N月免租"}])
self.assertEqual(rentfree.windows(t2), [])
def test_annual_windows_bounded_by_contract(self):
"""每年8-9月:合约期内每个自然年一段,含起始不含结束(10-1 起恢复计费)。"""
t = self._tab([{"C": "601A室", "I": "2026-10-01", "J": "2028-09-30",
"AK": "每年8-9月免租"}])
ws = rentfree.windows(t)
self.assertEqual([w["begin"] for w in ws], ["2027-08-01", "2028-08-01"])
self.assertEqual([w["end"] for w in ws], ["2027-10-01", "2028-10-01"])
self.assertTrue(all(w["type"] == "每年8-9月免租" for w in ws))
self.assertEqual([w["unit"] for w in rentfree.active("2027-08-01", t)], ["601A室"])
self.assertEqual(rentfree.active("2027-07-31", t), [])
self.assertEqual(rentfree.active("2027-10-01", t), [])
def test_annual_windows_clipped_by_contract(self):
"""跨年裁剪:合约从 8 月中旬开始、9 月上旬结束(合约期止含当日)。"""
t = self._tab([{"C": "Y室", "I": "2028-08-15", "J": "2028-09-10",
"AK": "每年8-9月免租"}])
ws = rentfree.windows(t)
self.assertEqual(len(ws), 1)
self.assertEqual((ws[0]["begin"], ws[0]["end"]), ("2028-08-15", "2028-09-11"))
# 合约在 7 月底结束 → 8-9 月完全落在合约期外 → 无窗口
t2 = self._tab([{"C": "U室", "I": "2027-01-01", "J": "2027-07-31",
"AK": "每年8-9月免租"}])
self.assertEqual(rentfree.windows(t2), [])
def test_annual_ignores_free_months(self):
"""选了「每年8-9月」就不再看「免租期(月)」——不会再多出一段首N月。"""
t = self._tab([{"C": "Z室", "I": "2027-01-05", "J": "2027-12-31",
"L": 3, "AK": "每年8-9月免租"}])
ws = rentfree.windows(t)
self.assertEqual(len(ws), 1)
self.assertEqual(ws[0]["begin"], "2027-08-01")
def test_overlaps_by_period(self):
"""按账单期间判定:期间与免租窗口有交集即命中(每年8-9月必须用这条)。"""
t = self._tab([
{"C": "601A室", "I": "2026-10-01", "J": "2028-09-30", "AK": "每年8-9月免租"},
{"C": "302室", "I": "2026-08-16", "L": 0.5, "AK": "首N月免租"},
])
hit = lambda u, a, b: [h["matched"] for h in rentfree.overlaps([u], a, b, t)] # noqa: E731
self.assertEqual(hit("601A室", "2027-08-01", "2027-10-31"), ["601A室"])
self.assertEqual(hit("601A室", "2027-07-01", "2027-09-30"), ["601A室"]) # 3 月周期覆盖 8-9 月
self.assertEqual(hit("601A室", "2027-07-01", "2027-07-31"), []) # 只覆盖 7 月
self.assertEqual(hit("601A室", "2027-10-01", "2027-12-31"), []) # 10 月起
self.assertEqual(hit("302室", "2026-08-01", "2026-10-31"), ["302室"])
self.assertEqual(hit("302室", "2026-08-31", "2026-11-30"), []) # 结束日当天已恢复
self.assertEqual(rentfree.overlaps(["302室"], None, None, t), []) # 期间不全 → 不判
# ================================================================ 3. 调度 # ================================================================ 3. 调度
CFG_ON = { CFG_ON = {
...@@ -304,12 +372,12 @@ class TestRentParkingTable(unittest.TestCase): ...@@ -304,12 +372,12 @@ class TestRentParkingTable(unittest.TestCase):
spec = tables.list_tables()[0] spec = tables.list_tables()[0]
self.assertEqual(spec["name"], "房租车位费") self.assertEqual(spec["name"], "房租车位费")
self.assertTrue(spec["ready"]) self.assertTrue(spec["ready"])
self.assertEqual(spec["col_count"], 36) # 29 出单列 + 6 房产/房产税列 + 1 推送排期列 self.assertEqual(spec["col_count"], 31) # 29 出单列 + 1 免租类型 + 1 推送排期
def test_no_hidden_column(self): def test_no_hidden_column(self):
t = self._t() t = self._t()
self.assertEqual([c["id"] for c in t["columns"] if c.get("hide")], []) self.assertEqual([c["id"] for c in t["columns"] if c.get("hide")], [])
self.assertEqual(len(t["columns"]), 36) self.assertEqual(len(t["columns"]), 31)
self.assertNotIn("序号", [c["label"] for c in t["columns"]]) self.assertNotIn("序号", [c["label"] for c in t["columns"]])
def test_column_layout(self): def test_column_layout(self):
...@@ -320,6 +388,7 @@ class TestRentParkingTable(unittest.TestCase): ...@@ -320,6 +388,7 @@ class TestRentParkingTable(unittest.TestCase):
self.assertEqual(label["C"], "房间/车位号") self.assertEqual(label["C"], "房间/车位号")
self.assertEqual(label["F"], "现租客") self.assertEqual(label["F"], "现租客")
self.assertEqual(label["L"], "免租期(月)") self.assertEqual(label["L"], "免租期(月)")
self.assertEqual(label[tables.FREE_TYPE_COL], "免租类型")
self.assertEqual(label["AB"], "最近收款月份") self.assertEqual(label["AB"], "最近收款月份")
self.assertEqual(label["AC"], "账单起始日") self.assertEqual(label["AC"], "账单起始日")
...@@ -335,20 +404,33 @@ class TestRentParkingTable(unittest.TestCase): ...@@ -335,20 +404,33 @@ class TestRentParkingTable(unittest.TestCase):
self.assertEqual(cmap[tables.BILL_COLS["bill_start"]]["type"], "date") self.assertEqual(cmap[tables.BILL_COLS["bill_start"]]["type"], "date")
self.assertEqual(cmap[tables.BILL_COLS["rentfree"]]["type"], "num") self.assertEqual(cmap[tables.BILL_COLS["rentfree"]]["type"], "num")
def test_tax_columns_defined(self): def test_property_tax_columns_removed(self):
"""房产/房产税列(AD~AI,2026-10-11 由房产税表并入;AJ 之后的推送列不属于房产组)。""" """房产/房产税六列(AD~AI)已于 2026-10-13 整体删除,列与行都不得再残留。"""
t = self._t()
ids = [c["id"] for c in t["columns"]]
labels = [c["label"] for c in t["columns"]]
for col in tables.RETIRED_TAX_COLS:
self.assertNotIn(col, ids, f"{col} 仍残留在列定义里")
for label in ("产证面积(㎡)", "取得成本(元)", "车位对应房间",
"季度计税租金", "从租季度房产税", "从价季度房产税"):
self.assertNotIn(label, labels, f"列「{label}」应已删除")
for r in t["rows"]:
for col in tables.RETIRED_TAX_COLS:
self.assertNotIn(col, r, f"{r.get('C')} 行仍残留 {col} 键")
def test_rentfree_type_column(self):
"""「免租类型」列(键 AK,2026-10-13 新增):两种取值,显示位置紧邻免租期。"""
t = self._t() t = self._t()
cmap = {c["id"]: c for c in t["columns"]} cmap = {c["id"]: c for c in t["columns"]}
for col in tables.TAX_COLS:
self.assertIn(col, cmap, f"{col} 不在列定义里")
ids = [c["id"] for c in t["columns"]] ids = [c["id"] for c in t["columns"]]
self.assertEqual(ids[-7:-1], list(tables.TAX_COLS), "房产列必须是紧挨推送列之前的六列") self.assertIn(tables.FREE_TYPE_COL, cmap)
self.assertEqual(cmap["AD"]["label"], "产证面积(㎡)") self.assertEqual(cmap[tables.FREE_TYPE_COL]["label"], "免租类型")
self.assertEqual(cmap["AD"]["type"], "num") self.assertEqual(cmap[tables.FREE_TYPE_COL]["group"], "免租")
self.assertEqual(cmap["AE"]["type"], "money") self.assertEqual(cmap[tables.FREE_TYPE_COL]["type"], "text")
self.assertEqual(cmap["AH"]["type"], "money") self.assertEqual(ids[ids.index("L") + 1], tables.FREE_TYPE_COL,
self.assertEqual(cmap["AD"]["group"], "房产") "免租类型应紧跟「免租期(月)」之后显示")
self.assertEqual(cmap["AI"]["group"], "房产税") self.assertEqual(list(tables.FREE_TYPES), ["首N月免租", "每年8-9月免租"])
self.assertEqual(tables.BILL_COLS["rentfree_type"], tables.FREE_TYPE_COL)
def test_next_bill_column(self): def test_next_bill_column(self):
"""AJ「下一账单日」= 推送排期列(2026-10-12 新增)。""" """AJ「下一账单日」= 推送排期列(2026-10-12 新增)。"""
...@@ -361,11 +443,11 @@ class TestRentParkingTable(unittest.TestCase): ...@@ -361,11 +443,11 @@ class TestRentParkingTable(unittest.TestCase):
self.assertEqual(tables.BILL_COLS["next_bill"], "AJ") self.assertEqual(tables.BILL_COLS["next_bill"], "AJ")
self.assertEqual([c["id"] for c in t["columns"]][-1], "AJ", "推送列应是最后一列") self.assertEqual([c["id"] for c in t["columns"]][-1], "AJ", "推送列应是最后一列")
def test_tax_columns_not_used_by_billing(self): def test_retired_cols_not_used_by_billing(self):
"""房产/房产税列只做登记,出单逻辑一律不得引用(防止误出单)。""" """已删除的房产/房产税列不得再被出单逻辑(BILL_COLS)引用。"""
for col in tables.TAX_COLS: for col in tables.RETIRED_TAX_COLS:
self.assertNotIn(col, tables.BILL_COLS.values(), self.assertNotIn(col, tables.BILL_COLS.values(),
f"{col} 被 BILL_COLS 引用了,出单会受房产数据影响") f"{col} 已删除,不应再出现在 BILL_COLS 里")
def test_bill_start_is_next_month_of_last_paid(self): def test_bill_start_is_next_month_of_last_paid(self):
"""逐行校验:账单起始日 = 「最近收款月份的次月 1 日」再按付款周期整数倍推进。 """逐行校验:账单起始日 = 「最近收款月份的次月 1 日」再按付款周期整数倍推进。
......
...@@ -95,8 +95,10 @@ def main(): ...@@ -95,8 +95,10 @@ def main():
"#tbWrap tfoot td", "els => els.map(e => e.textContent.trim())") "#tbWrap tfoot td", "els => els.map(e => e.textContent.trim())")
foot = [f for f in foot_raw if f] foot = [f for f in foot_raw if f]
ck("台账渲染 %d 行(= 后端)" % base_rows, rows == base_rows and rows > 0, str(rows)) ck("台账渲染 %d 行(= 后端)" % base_rows, rows == base_rows and rows > 0, str(rows))
ck("首行表头:分组含「房产」「房产税」,末项为「操作」", ck("首行表头:分组含「免租」,末项为「操作」",
heads[-1] == "操作" and "房产" in heads and "房产税" in heads, str(heads)) heads[-1] == "操作" and "免租" in heads, str(heads))
ck("首行表头已无「房产」「房产税」(2026-10-13 已删列)",
"房产" not in heads and "房产税" not in heads, str(heads))
span_total = page.eval_on_selector( span_total = page.eval_on_selector(
"#tbWrap thead tr:first-child", "#tbWrap thead tr:first-child",
"""e => [...e.children] """e => [...e.children]
...@@ -117,14 +119,20 @@ def main(): ...@@ -117,14 +119,20 @@ def main():
page.check("#tbEdit") # 默认锁定,需显式进入编辑模式 page.check("#tbEdit") # 默认锁定,需显式进入编辑模式
page.wait_for_timeout(500) page.wait_for_timeout(500)
# ---- 编辑「现租客」(F 列) # ---- 编辑「现租客」(F 列)—— 2026-10-13 起改为「批量暂存 + 一次确认」
print("\n== 3. 改单元格(现租客 = F 列)==") print("\n== 3. 改单元格(现租客 = F 列)= 暂存后一次确认 ==")
CELL = '#tbWrap tbody tr:nth-child(1) td[data-col="F"]' CELL = '#tbWrap tbody tr:nth-child(1) td[data-col="F"]'
origin = page.eval_on_selector(CELL, "e=>e.innerText.trim()") origin = page.eval_on_selector(CELL, "e=>e.innerText.trim()")
page.click(CELL) page.click(CELL)
page.wait_for_selector(f"{CELL} input", timeout=5000) page.wait_for_selector(f"{CELL} input", timeout=5000)
page.fill(f"{CELL} input", "浏览器测试租客") page.fill(f"{CELL} input", "浏览器测试租客")
page.keyboard.press("Enter") page.keyboard.press("Enter")
page.wait_for_timeout(500)
ck("改一格不再弹确认框(改为暂存)",
not page.eval_on_selector("#cfm", "e=>e.classList.contains('show')"))
ck("该格被标记为待保存",
page.eval_on_selector(CELL, "e=>e.classList.contains('pending')"))
page.click("#btn-tb-save-all")
page.wait_for_selector("#cfm.show", timeout=5000) page.wait_for_selector("#cfm.show", timeout=5000)
print(" 弹出确认框:", print(" 弹出确认框:",
page.eval_on_selector("#cfmBody", "e=>e.innerText.replace(/\\n/g,' | ')")[:90]) page.eval_on_selector("#cfmBody", "e=>e.innerText.replace(/\\n/g,' | ')")[:90])
...@@ -153,23 +161,27 @@ def main(): ...@@ -153,23 +161,27 @@ def main():
page.wait_for_selector('#tbWrap tbody tr:nth-child(1) td[data-col="D"] input', timeout=5000) page.wait_for_selector('#tbWrap tbody tr:nth-child(1) td[data-col="D"] input', timeout=5000)
page.fill('#tbWrap tbody tr:nth-child(1) td[data-col="D"] input', "200") page.fill('#tbWrap tbody tr:nth-child(1) td[data-col="D"] input', "200")
page.keyboard.press("Enter") page.keyboard.press("Enter")
page.wait_for_timeout(400)
page.click("#btn-tb-save-all")
page.wait_for_selector("#cfm.show", timeout=5000) page.wait_for_selector("#cfm.show", timeout=5000)
page.click("#cfmOk") page.click("#cfmOk")
page.wait_for_timeout(900) page.wait_for_timeout(900)
f1 = page.eval_on_selector("#tbWrap tfoot td:nth-child(5)", "e => e.textContent.trim()") f1 = page.eval_on_selector("#tbWrap tfoot td:nth-child(5)", "e => e.textContent.trim()")
ck("服务面积合计已变化", f0 != f1, f"{f0} → {f1}") ck("服务面积合计已变化", f0 != f1, f"{f0} → {f1}")
# ---- 还原:把 F 与 D 改回原值(导入功能已下线,只能靠页面编辑还原) # ---- 还原:把 F 与 D 改回原值(暂存两格后一次保存)
print("\n== 5. 逐格还原 ==") print("\n== 5. 批量还原(F + D 一次保存)==")
for col, val in (("F", origin), ("D", d_origin)): for col, val in (("F", origin), ("D", d_origin)):
sel = f'#tbWrap tbody tr:nth-child(1) td[data-col="{col}"]' sel = f'#tbWrap tbody tr:nth-child(1) td[data-col="{col}"]'
page.click(sel) page.click(sel)
page.wait_for_selector(f"{sel} input", timeout=5000) page.wait_for_selector(f"{sel} input", timeout=5000)
page.fill(f"{sel} input", val) page.fill(f"{sel} input", val)
page.keyboard.press("Enter") page.keyboard.press("Enter")
page.wait_for_timeout(400)
page.click("#btn-tb-save-all")
page.wait_for_selector("#cfm.show", timeout=5000) page.wait_for_selector("#cfm.show", timeout=5000)
page.click("#cfmOk") page.click("#cfmOk")
page.wait_for_timeout(700) page.wait_for_timeout(900)
ck("F 已还原", page.eval_on_selector(CELL, "e=>e.innerText.trim()") == origin, ck("F 已还原", page.eval_on_selector(CELL, "e=>e.innerText.trim()") == origin,
page.eval_on_selector(CELL, "e=>e.innerText.trim()")) page.eval_on_selector(CELL, "e=>e.innerText.trim()"))
ck("D 已还原", page.eval_on_selector( ck("D 已还原", page.eval_on_selector(
...@@ -219,7 +231,9 @@ def main(): ...@@ -219,7 +231,9 @@ def main():
page.evaluate("document.querySelector('#tbHistCard').open = true") page.evaluate("document.querySelector('#tbHistCard').open = true")
page.wait_for_timeout(300) page.wait_for_timeout(300)
hist = page.eval_on_selector("#tbHist", "e=>e.innerText") hist = page.eval_on_selector("#tbHist", "e=>e.innerText")
ck("修改记录里能看到刚才的改动", ("浏览器测试租客" in hist) or ("修改" in hist), hist[:100]) # 2026-10-13 起:单元格改动按「批量」记账("N 处改动"),增/删行各记一条
ck("修改记录里能看到刚才的改动",
("处改动" in hist) and ("新增一行" in hist) and ("删除一行" in hist), hist[:100])
# ============================================ 8. 电费:手工录入 # ============================================ 8. 电费:手工录入
print("\n== 8. 电费:手工录入(不再上传)==") print("\n== 8. 电费:手工录入(不再上传)==")
......
...@@ -79,7 +79,7 @@ def main(): ...@@ -79,7 +79,7 @@ def main():
print("\n== 2. 免租期卡片 ==") print("\n== 2. 免租期卡片 ==")
rf = page.eval_on_selector("#rentfreeBox", "e=>e.innerText") rf = page.eval_on_selector("#rentfreeBox", "e=>e.innerText")
ck("免租期卡片含判定依据", "合同期始 + 免租期" in rf) ck("免租期卡片列出两种免租类型", "首N月免租" in rf and "每年8-9月免租" in rf)
ck("免租期列出 3 段(315室 / 302室 / 303室)", "共 3 段" in rf, rf.split("\n")[0][:80]) ck("免租期列出 3 段(315室 / 302室 / 303室)", "共 3 段" in rf, rf.split("\n")[0][:80])
ck("免租期表格有 302室", "302室" in rf and "2026-08-16" in rf) ck("免租期表格有 302室", "302室" in rf and "2026-08-16" in rf)
ck("免租期来源标注为房租车位费台账", "房租车位费" in rf or "台账" in rf) ck("免租期来源标注为房租车位费台账", "房租车位费" in rf or "台账" in rf)
...@@ -184,8 +184,10 @@ def main(): ...@@ -184,8 +184,10 @@ def main():
base_rows, base_cols = meta["rows"], meta["cols"] base_rows, base_cols = meta["rows"], meta["cols"]
heads = [h for h in page.eval_on_selector_all( heads = [h for h in page.eval_on_selector_all(
"#tbWrap thead tr:first-child th", "e=>e.map(x=>x.innerText.trim())") if h] "#tbWrap thead tr:first-child th", "e=>e.map(x=>x.innerText.trim())") if h]
ck("首行表头:分组含「房产」「房产税」,末项为「操作」", ck("首行表头:分组含「免租」,末项为「操作」",
heads[-1] == "操作" and "房产" in heads and "房产税" in heads, str(heads)) heads[-1] == "操作" and "免租" in heads, str(heads))
ck("首行表头已无「房产」「房产税」(2026-10-13 已删列)",
"房产" not in heads and "房产税" not in heads, str(heads))
span_total = page.eval_on_selector( span_total = page.eval_on_selector(
"#tbWrap thead tr:first-child", "#tbWrap thead tr:first-child",
"""e => [...e.children] """e => [...e.children]
...@@ -202,8 +204,8 @@ def main(): ...@@ -202,8 +204,8 @@ def main():
ck("『隐藏月度列』开关已移除", ck("『隐藏月度列』开关已移除",
page.eval_on_selector_all("#tbHideMonthly", "e=>e.length") == 0) page.eval_on_selector_all("#tbHideMonthly", "e=>e.length") == 0)
# ============================================ 7. 编辑模式 + 确认 + 撤销 # ============================================ 7. 编辑模式 + 批量暂存
print("\n== 7. 编辑模式:二次确认与撤销 ==") print("\n== 7. 编辑模式:批量暂存(不再逐格弹窗) ==")
page.check("#tbEdit") page.check("#tbEdit")
page.wait_for_timeout(700) page.wait_for_timeout(700)
ck("勾选后提示条切换为已开启", "已开启" in page.eval_on_selector("#tbLockBar", "e=>e.innerText")) ck("勾选后提示条切换为已开启", "已开启" in page.eval_on_selector("#tbLockBar", "e=>e.innerText"))
...@@ -218,39 +220,78 @@ def main(): ...@@ -218,39 +220,78 @@ def main():
page.fill(f"{CELL} input", "临时测试值") page.fill(f"{CELL} input", "临时测试值")
page.keyboard.press("Enter") page.keyboard.press("Enter")
page.wait_for_timeout(600) page.wait_for_timeout(600)
ck("回车后弹出确认框", cfm_open(page)) ck("改一格不再弹确认框(改为暂存)", not cfm_open(page))
t = cfm_text(page) ck("该格被标记为待保存",
ck("确认框含行 / 列 / 旧值 / 新值", page.eval_on_selector(CELL, "e=>e.classList.contains('pending')"))
"行" in t and "列" in t and origin[:4] in t and "临时测试值" in t, ck("出现「1 处改动待保存」",
t.replace("\n", " | ")[:140]) "1 处改动待保存" in page.eval_on_selector("#tbPendBar", "e=>e.innerText"),
page.screenshot(path=os.path.join(OUT, "S6-cfm-cell.png")) page.eval_on_selector("#tbPendBar", "e=>e.innerText").replace("\n", " | ")[:90])
page.click("#cfmCancel")
page.wait_for_timeout(600) CELL2 = '#tbWrap tbody tr:nth-child(3) td[data-col="B"]'
ck("取消后单元格还原", page.eval_on_selector(CELL, "e=>e.innerText.trim()") == origin) origin2 = page.eval_on_selector(CELL2, "e=>e.innerText.trim()")
page.click(CELL2)
page.wait_for_selector(f"{CELL2} input", timeout=5000)
page.fill(f"{CELL2} input", "9楼")
page.keyboard.press("Enter")
page.wait_for_timeout(500)
ck("第二处改动同样只暂存(计数 2)",
"2 处改动待保存" in page.eval_on_selector("#tbPendBar", "e=>e.innerText"),
page.eval_on_selector("#tbPendBar", "e=>e.innerText").replace("\n", " | ")[:90])
ck("两处改动都没弹确认框", not cfm_open(page))
page.screenshot(path=os.path.join(OUT, "S6-pending.png"))
print("\n== 8. 放弃改动 ==")
page.once("dialog", lambda d: d.accept())
page.click("#btn-tb-discard")
page.wait_for_timeout(700)
ck("放弃后第 1 格还原", page.eval_on_selector(CELL, "e=>e.innerText.trim()") == origin)
ck("放弃后第 2 格还原", page.eval_on_selector(CELL2, "e=>e.innerText.trim()") == origin2)
ck("待保存条消失",
"待保存" not in page.eval_on_selector("#tbPendBar", "e=>e.innerText"))
print("\n== 9. 保存全部:只弹一次确认,落盘后可撤销 ==")
page.click(CELL) page.click(CELL)
page.wait_for_selector(f"{CELL} input", timeout=5000) page.wait_for_selector(f"{CELL} input", timeout=5000)
page.fill(f"{CELL} input", "临时测试值") page.fill(f"{CELL} input", "临时测试值")
page.keyboard.press("Enter") page.keyboard.press("Enter")
page.wait_for_timeout(500) page.wait_for_timeout(500)
page.click("#btn-tb-save-all")
page.wait_for_timeout(500)
ck("点「保存全部」弹出一次确认框", cfm_open(page))
t = cfm_text(page)
ck("确认框含列 / 旧值 / 新值",
"现租客" in t and origin[:4] in t and "临时测试值" in t,
t.replace("\n", " | ")[:140])
page.screenshot(path=os.path.join(OUT, "S7-cfm-batch.png"))
page.click("#cfmCancel")
page.wait_for_timeout(500)
ck("取消确认后仍在暂存(未写入)",
page.eval_on_selector(CELL, "e=>e.classList.contains('pending')"))
page.click("#btn-tb-save-all")
page.wait_for_timeout(400)
page.click("#cfmOk") page.click("#cfmOk")
page.wait_for_timeout(1200) page.wait_for_timeout(1400)
ck("确认后单元格更新", ck("确认后单元格更新",
page.eval_on_selector(CELL, "e=>e.innerText.trim()") == "临时测试值") page.eval_on_selector(CELL, "e=>e.innerText.trim()") == "临时测试值",
ck("修改记录出现 1 条", page.eval_on_selector_all("#tbHist tbody tr", "e=>e.length") == 1) page.eval_on_selector(CELL, "e=>e.innerText.trim()"))
ck("待保存条消失",
"待保存" not in page.eval_on_selector("#tbPendBar", "e=>e.innerText"))
ck("修改记录出现 1 条(批量)",
page.eval_on_selector_all("#tbHist tbody tr", "e=>e.length") == 1)
page.evaluate("document.querySelector('#tbHistCard').open = true") page.evaluate("document.querySelector('#tbHistCard').open = true")
page.wait_for_timeout(200) page.wait_for_timeout(200)
ck("修改记录显示旧→新", origin[:4] in page.eval_on_selector("#tbHist", "e=>e.innerText"), hist = page.eval_on_selector("#tbHist", "e=>e.innerText")
page.eval_on_selector("#tbHist", "e=>e.innerText").replace("\n", " | ")[:120]) ck("修改记录标为「批量」并计数", "批量" in hist and "处单元格" in hist,
hist.replace("\n", " | ")[:120])
ck("撤销按钮可用", not page.is_disabled("#btn-tb-undo")) ck("撤销按钮可用", not page.is_disabled("#btn-tb-undo"))
page.screenshot(path=os.path.join(OUT, "S7-history.png"), full_page=True) page.screenshot(path=os.path.join(OUT, "S7-history.png"), full_page=True)
print("\n== 8. 撤销 ==") print("\n== 10. 撤销(批量) ==")
page.click("#btn-tb-undo") page.click("#btn-tb-undo")
page.wait_for_timeout(500) page.wait_for_timeout(500)
ck("撤销也弹确认框", cfm_open(page)) ck("撤销也弹确认框", cfm_open(page))
page.click("#cfmOk") page.click("#cfmOk")
page.wait_for_timeout(1200) page.wait_for_timeout(1400)
ck("撤销后单元格回到原值", ck("撤销后单元格回到原值",
page.eval_on_selector(CELL, "e=>e.innerText.trim()") == origin, page.eval_on_selector(CELL, "e=>e.innerText.trim()") == origin,
page.eval_on_selector(CELL, "e=>e.innerText.trim()")) page.eval_on_selector(CELL, "e=>e.innerText.trim()"))
......
...@@ -210,18 +210,20 @@ def main(): ...@@ -210,18 +210,20 @@ def main():
page.wait_for_timeout(800) page.wait_for_timeout(800)
page.fill("#tbSearch", "1101") page.fill("#tbSearch", "1101")
page.wait_for_timeout(700) page.wait_for_timeout(700)
# 2026-10-13 起改按列 id 取值:新增「免租类型」列后 DOM 位置整体后移,
# 再按下标取(旧的 tds[14]/tds[15])会读到 M/N,误判成「付款周期为空」。
row = page.evaluate("""() => { row = page.evaluate("""() => {
const tr = document.querySelector('#tbWrap tbody tr'); const tr = document.querySelector('#tbWrap tbody tr');
if (!tr) return null; if (!tr) return null;
const tds = [...tr.children].map(t => (t.innerText || '').trim()); const cell = c => { const td = tr.querySelector('td.cell[data-col="' + c + '"]');
return tds; return td ? (td.innerText || '').trim() : null; };
return { unit: cell('C'), cycle: cell('N'), fee: cell('O') };
}""") }""")
# tds[0]=行号, tds[1]=A出租方 ... tds[14]=N付款周期, tds[15]=O物业费 ck("筛出 1101 行", bool(row) and "1101" in (row.get("unit") or ""), row)
ck("筛出 1101 行", bool(row) and "1101" in "".join(row or []), (row or [])[:4]) ck("11F 付款周期 = 3", bool(row) and (row.get("cycle") or "") == "3",
ck("11F 付款周期 = 3", bool(row) and len(row) > 14 and row[14] == "3", "N=%r" % (row.get("cycle") if row else "—"))
"N=%r" % (row[14] if row and len(row) > 14 else "—")) ck("11F 不收物业费(该列为空)", bool(row) and (row.get("fee") or "") == "",
ck("11F 不收物业费(该列为空)", bool(row) and len(row) > 15 and row[15] == "", "O=%r" % (row.get("fee") if row else "—"))
"O=%r" % (row[15] if row and len(row) > 15 else "—"))
page.screenshot(path=os.path.join(OUT, "V4-table-11f.png"), full_page=True) page.screenshot(path=os.path.join(OUT, "V4-table-11f.png"), full_page=True)
# 按楼层整体筛:8F / 11F 每行的付款周期都必须是 3、物业费都必须为空 # 按楼层整体筛:8F / 11F 每行的付款周期都必须是 3、物业费都必须为空
...@@ -229,10 +231,14 @@ def main(): ...@@ -229,10 +231,14 @@ def main():
page.fill("#tbSearch", label) page.fill("#tbSearch", label)
page.wait_for_timeout(700) page.wait_for_timeout(700)
rs = page.evaluate("""() => [...document.querySelectorAll('#tbWrap tbody tr')] rs = page.evaluate("""() => [...document.querySelectorAll('#tbWrap tbody tr')]
.map(tr => [...tr.children].map(t => (t.innerText || '').trim()))""") .map(tr => {
const cell = c => { const td = tr.querySelector('td.cell[data-col="' + c + '"]');
return td ? (td.innerText || '').trim() : null; };
return { unit: cell('C'), cycle: cell('N'), fee: cell('O') };
})""")
ck("筛出 %s 的台账行" % label, len(rs) >= want_rows, "行数 %s" % len(rs)) ck("筛出 %s 的台账行" % label, len(rs) >= want_rows, "行数 %s" % len(rs))
bad_n = [r[3] for r in rs if len(r) > 14 and r[14] != "3"] bad_n = [r["unit"] for r in rs if (r.get("cycle") or "").strip() != "3"]
bad_o = [r[3] for r in rs if len(r) > 15 and r[15] != ""] bad_o = [r["unit"] for r in rs if (r.get("fee") or "").strip() != ""]
ck("%s 每行「付款周期」= 3" % label, not bad_n, "异常行 %s" % (bad_n or "无")) ck("%s 每行「付款周期」= 3" % label, not bad_n, "异常行 %s" % (bad_n or "无"))
ck("%s 每行「每月物业费」为空(不收)" % label, not bad_o, "异常行 %s" % (bad_o or "无")) ck("%s 每行「每月物业费」为空(不收)" % label, not bad_o, "异常行 %s" % (bad_o or "无"))
page.screenshot(path=os.path.join(OUT, "V5-table-8f.png"), full_page=True) page.screenshot(path=os.path.join(OUT, "V5-table-8f.png"), full_page=True)
......
...@@ -78,9 +78,11 @@ def main(): ...@@ -78,9 +78,11 @@ def main():
# ============================================ 1. 出一期(1 行) # ============================================ 1. 出一期(1 行)
print("\n1. 准备:出一期房租车位费(1 行)") print("\n1. 准备:出一期房租车位费(1 行)")
before = ac_state() before = ac_state()
# 不写死日期:台账账期会随出单推进,「on=2026-12-01」早晚会筛空。
# 改为取**当前全部可出单行**(不限区间),再用选中行自己的账期起作出单日。
pv = js("""async () => { pv = js("""async () => {
const t = localStorage.getItem('rentos_token'); const t = localStorage.getItem('rentos_token');
const r = await fetch('/api/rent/preview?on=2026-12-01', const r = await fetch('/api/rent/preview',
{headers: {Authorization: 'Bearer ' + t}}); {headers: {Authorization: 'Bearer ' + t}});
return await r.json(); return await r.json();
}""") }""")
...@@ -88,17 +90,18 @@ def main(): ...@@ -88,17 +90,18 @@ def main():
ck("预览里有可出单行", len(cand) > 0, "%d 行" % len(cand)) ck("预览里有可出单行", len(cand) > 0, "%d 行" % len(cand))
rid = cand[0]["row_id"] rid = cand[0]["row_id"]
unit = cand[0].get("unit") unit = cand[0].get("unit")
print(" 选中:%s(%s)" % (unit, rid)) od = str(cand[0].get("period_from") or "")[:10] or "2026-12-01"
print(" 选中:%s(%s)账期起 %s" % (unit, rid, od))
got = js("""async (rid) => { got = js("""async ([rid, od]) => {
const t = localStorage.getItem('rentos_token'); const t = localStorage.getItem('rentos_token');
const r = await fetch('/api/rent/batches', {method: 'POST', const r = await fetch('/api/rent/batches', {method: 'POST',
headers: {'Content-Type': 'application/json', headers: {'Content-Type': 'application/json',
Authorization: 'Bearer ' + t}, Authorization: 'Bearer ' + t},
body: JSON.stringify({row_ids: [rid], on_date: '2026-12-01', body: JSON.stringify({row_ids: [rid], on_date: od,
notice_date: '2026-12-01', due_date: '2026-12-31'})}); notice_date: od, due_date: od})});
return await r.json(); return await r.json();
}""", rid) }""", [rid, od])
ck("出单成功", bool(got.get("ok")), got.get("error", "")) ck("出单成功", bool(got.get("ok")), got.get("error", ""))
created = (got.get("batch") or {}).get("batch_id") created = (got.get("batch") or {}).get("batch_id")
ck("拿到批次号", bool(created), created) ck("拿到批次号", bool(created), created)
......
# -*- coding: utf-8 -*-
"""
第八轮需求的端到端验证(2026-10-13)
====================================
对应需求:
1. 删除台账「房产」「房产税」六个栏位(AD~AI);
2. 免租期分两种 —— 「首N月免租」与「每年8-9月免租」,在表中填「免租类型」;
账单按免租数据跳过;
3. 台账编辑不再逐格弹确认,改为批量暂存 + 一次性确认。
做法:
* **自带沙箱**:把 data/ 复制到临时目录并改写路径常量后另起服务,真实 data/ 零改动。
* 真浏览器(Playwright/Chromium)跑批量编辑全流程;后端接口直连校验列结构与免租判定。
运行:python tools/ui_verify9.py
"""
import io
import os
import shutil
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*"
from rentos import config as C # noqa: E402
from rentos import server as S # noqa: E402
from rentos import tables # noqa: E402
PORT = int(os.environ.get("RENTOS_VERIFY_PORT", "8903"))
BASE = "http://127.0.0.1:%d" % PORT
OUT = os.path.join(ROOT, "output", "ui_shots")
OK, FAIL = [], []
def ck(name, cond, extra=""):
(OK if cond else FAIL).append(name)
print(" [%s] %s%s" % ("ok" if cond else "FAIL", name,
(" → %s" % extra) if extra != "" else ""))
def start_sandbox():
tmp = tempfile.mkdtemp(prefix="rentos-verify9-")
shutil.copytree(os.path.join(ROOT, "data"), os.path.join(tmp, "data"))
data = os.path.join(tmp, "data")
for name, val in {
"DATA_DIR": data,
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
"UNIT_MAP_FILE": os.path.join(data, "unit_map.json"),
"UPLOAD_DIR": os.path.join(tmp, "uploads"),
"OUTPUT_DIR": os.path.join(tmp, "output", "elec"),
"OUTPUT_RENT_DIR": os.path.join(tmp, "output", "rent"),
"TABLE_EXPORT_DIR": os.path.join(tmp, "output", "tables"),
"BATCH_EXPORT_DIR": os.path.join(tmp, "output", "exports"),
"LOG_DIR": os.path.join(tmp, "logs"),
}.items():
setattr(C, name, val)
C.ensure_dirs()
srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler)
srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, tmp
def seed_annual_unit():
"""在沙箱台账里挑一个**可出单**的房间行,设成「每年8-9月免租」并把账期挪到 2027-08。
必须挑「可出单」行:空置(F 空或含「空置」)/ 月租金 ≤ 0 都会被归入 skip,
根本不会出现在预览的 rows 里,无法验证免租判定。
"""
t = tables.load_table(tables.RENT_KEY)
rid = None
for r in t["rows"]:
if "车位" in str(r.get("C") or ""):
continue
f = str(r.get("F") or "").strip()
if not f or "空置" in f: # 空置行不可出单,跳过
continue
try:
if float(r.get("P") or 0) <= 0: # 无租金不可出单,跳过
continue
except (TypeError, ValueError):
continue
rid = r["_id"]
break
if not rid:
return None, None
tables.update_cells(tables.RENT_KEY, [
(rid, "I", "2026-01-01"), # 合约期始
(rid, "J", "2028-12-31"), # 合约期止
(rid, "N", 3), # 付款周期 3 个月
(rid, "AC", "2027-08-01"), # 账单起始日 → 期间覆盖 8-9 月
(rid, tables.FREE_TYPE_COL, "每年8-9月免租"),
], who="ui_verify9 造数")
tb = tables.load_table(tables.RENT_KEY)
row = {x["_id"]: x for x in tb["rows"]}[rid]
return rid, str(row.get("C") or "")
def main():
os.makedirs(OUT, exist_ok=True)
srv, tmp = start_sandbox()
print("沙箱数据目录:%s" % tmp)
print("沙箱服务:%s(真实 data/ 未被触碰)" % BASE)
target_rid, target_unit = seed_annual_unit()
print("沙箱造数:%s 设为「每年8-9月免租」,账期 2027-08-01" % (target_unit or "(无)"))
from playwright.sync_api import sync_playwright
errors = []
try:
with sync_playwright() as pw:
b = pw.chromium.launch()
p = b.new_page(viewport={"width": 1680, "height": 980})
p.on("console", lambda m: errors.append("console:" + m.text)
if (m.type == "error" and "favicon" not in m.text.lower()
and "Failed to load resource" not in m.text) else None)
p.on("pageerror", lambda e: errors.append("pageerror:" + str(e)))
def js(fn, *a):
return p.evaluate(fn, *a) if a else p.evaluate(fn)
def api_call(method, path, body=None):
return js("""async ([m, path, body]) => {
const t = localStorage.getItem('rentos_token');
const r = await fetch(path, {method: m,
headers: Object.assign({'Content-Type': 'application/json'},
t ? {Authorization: 'Bearer ' + t} : {}),
body: body === null ? undefined : JSON.stringify(body)});
let d = {}; try { d = await r.json(); } catch (e) {}
return [r.status, d];
}""", [method, path, body])
def cfm_open():
# 确认框用 .modal-mask.show(不是 .open)—— 与 app.js 的 confirmBox 对齐
return p.eval_on_selector("#cfm", "e=>e.classList.contains('show')")
def cfm_text():
return p.inner_text("#cfm")
# ---------------- 登录
p.goto(BASE + "/login.html")
p.fill("#u", "admin")
p.fill("#p", "1989@rent05")
p.click("button[type=submit]")
p.wait_for_url("**/admin.html")
p.wait_for_function("() => location.hash.length > 2", timeout=20000)
# ============================================ 1. 列结构(后端)
print("\n1. 台账列结构:房产/房产税已删,「免租类型」在位")
st, d = api_call("GET", "/api/tables/rent_parking")
tb = d.get("table") or d
ids = [c["id"] for c in (tb.get("columns") or [])]
ck("接口正常", st == 200, str(st))
ck("房产/房产税六列已删除",
not [c for c in tables.RETIRED_TAX_COLS if c in ids],
str([c for c in tables.RETIRED_TAX_COLS if c in ids]))
ck("列数为 31(29 出单列 + 免租类型 + 推送)", len(ids) == 31, str(len(ids)))
ck("「免租类型」列在位", tables.FREE_TYPE_COL in ids)
ck("「免租类型」紧跟「免租期(月)」显示",
ids[ids.index("L") + 1] == tables.FREE_TYPE_COL,
"%s -> %s" % (ids[ids.index("L")], ids[ids.index("L") + 1]))
leftover = sum(1 for r in (tb.get("rows") or [])
for c in tables.RETIRED_TAX_COLS if c in r)
ck("行数据里无房产列残留", leftover == 0, str(leftover))
# ============================================ 2. 免租类型接口与候选
print("\n2. 免租:两种类型与候选")
st, rf = api_call("GET", "/api/rentfree")
ck("/api/rentfree 返回两种类型",
rf.get("types") == ["首N月免租", "每年8-9月免租"], str(rf.get("types")))
annual = [w for w in (rf.get("windows") or [])
if w.get("type") == "每年8-9月免租"]
ck("年度免租窗口已生成(造数单元)", len(annual) >= 2, "%d 段" % len(annual))
ck("窗口按合约期裁剪到 8-1 ~ 10-1",
all(w["begin"].endswith("-08-01") and w["end"].endswith("-10-01")
for w in annual), str(annual[0]) if annual else "")
# ============================================ 3. 账单按免租数据跳过
print("\n3. 账单按免租数据跳过(出单预览标注)")
st, pv = api_call("GET", "/api/rent/preview?from=2027-08-01&to=2027-08-01")
ck("预览接口正常", st == 200 and pv.get("ok"), str(st))
hit = [r for r in (pv.get("rows") or []) if r.get("unit") == target_unit]
ck("覆盖 8-9 月的账期被判为免租",
bool(hit) and bool(hit[0].get("rentfree")),
str(hit[0].get("rentfree")) if hit else "(未命中该行)")
ck("汇总里标出免租份数", (pv.get("summary") or {}).get("rentfree_count", 0) >= 1,
str((pv.get("summary") or {}).get("rentfree_count")))
# 对比:把该行账期挪到 11 月(不覆盖 8-9 月)后,同一判定不应再标免租
tables.update_cells(tables.RENT_KEY, [(target_rid, "AC", "2027-11-01")],
who="ui_verify9 挪账期")
st, pv2 = api_call("GET", "/api/rent/preview?from=2027-11-01&to=2027-11-01")
hit2 = [r for r in (pv2.get("rows") or []) if r.get("unit") == target_unit]
ck("账期挪到 11 月后不再判免租",
bool(hit2) and not hit2[0].get("rentfree"),
str(hit2[0].get("rentfree")) if hit2 else "(未命中该行)")
# ============================================ 4. 台账页:列与候选
print("\n4. 台账页:表头与「免租类型」候选")
p.goto(BASE + "/admin.html#/table/rent_parking")
p.wait_for_selector("#tbWrap table.edgrid", timeout=25000)
p.wait_for_timeout(1200)
heads = [h for h in p.eval_on_selector_all(
"#tbWrap thead tr:first-child th", "e=>e.map(x=>x.innerText.trim())") if h]
ck("表头分组含「免租」", "免租" in heads, str(heads))
ck("表头已无「房产」「房产税」",
"房产" not in heads and "房产税" not in heads, str(heads))
opts = p.eval_on_selector_all("#tbFreeTypes option", "e=>e.map(x=>x.value)")
ck("「免租类型」候选有 2 项",
sorted(opts) == sorted(["首N月免租", "每年8-9月免租"]), str(opts))
# ============================================ 5. 批量编辑:暂存
print("\n5. 批量编辑:改动先暂存(不逐格弹窗)")
p.check("#tbEdit")
p.wait_for_timeout(700)
CELL = '#tbWrap tbody tr:nth-child(2) td[data-col="AA"]'
origin = p.eval_on_selector(CELL, "e=>e.innerText.trim()")
p.click(CELL)
p.wait_for_selector(f"{CELL} input", timeout=5000)
p.fill(f"{CELL} input", "批量暂存测试甲")
p.keyboard.press("Enter")
p.wait_for_timeout(600)
ck("改一格不弹确认框", not cfm_open())
ck("该格标为待保存",
p.eval_on_selector(CELL, "e=>e.classList.contains('pending')"))
bar = p.inner_text("#tbPendBar")
ck("出现「1 处改动待保存」", "1 处改动待保存" in bar, bar.replace("\n", " | ")[:80])
CELL2 = '#tbWrap tbody tr:nth-child(3) td[data-col="AA"]'
origin2 = p.eval_on_selector(CELL2, "e=>e.innerText.trim()")
p.click(CELL2)
p.wait_for_selector(f"{CELL2} input", timeout=5000)
p.fill(f"{CELL2} input", "批量暂存测试乙")
p.keyboard.press("Enter")
p.wait_for_timeout(500)
bar = p.inner_text("#tbPendBar")
ck("第二处改动同样只暂存(计数 2)", "2 处改动待保存" in bar,
bar.replace("\n", " | ")[:80])
ck("仍未弹确认框", not cfm_open())
p.screenshot(path=os.path.join(OUT, "V9-1-pending.png"))
# ============================================ 6. 放弃改动
print("\n6. 放弃改动:全部还原")
p.once("dialog", lambda dlg: dlg.accept())
p.click("#btn-tb-discard")
p.wait_for_timeout(700)
ck("第 1 格还原", p.eval_on_selector(CELL, "e=>e.innerText.trim()") == origin)
ck("第 2 格还原", p.eval_on_selector(CELL2, "e=>e.innerText.trim()") == origin2)
ck("待保存条消失", "待保存" not in p.inner_text("#tbPendBar"))
# ============================================ 7. 保存全部:只确认一次
print("\n7. 保存全部:一次确认后写入(可整批撤销)")
p.click(CELL)
p.wait_for_selector(f"{CELL} input", timeout=5000)
p.fill(f"{CELL} input", "批量暂存测试甲")
p.keyboard.press("Enter")
p.wait_for_timeout(500)
p.click("#btn-tb-save-all")
p.wait_for_timeout(500)
ck("点「保存全部」弹一次确认框", cfm_open())
txt = cfm_text()
ck("确认框含列 / 旧值 / 新值",
"备注" in txt and "(空)" in txt and "→" in txt and "批量暂存测试甲" in txt,
txt.replace("\n", " | ")[:120])
p.screenshot(path=os.path.join(OUT, "V9-2-cfm.png"))
p.click("#cfmCancel")
p.wait_for_timeout(500)
ck("取消确认后仍是暂存态(未写入)",
p.eval_on_selector(CELL, "e=>e.classList.contains('pending')"))
p.click("#btn-tb-save-all")
p.wait_for_timeout(400)
p.click("#cfmOk")
p.wait_for_timeout(1400)
ck("确认后单元格更新",
p.eval_on_selector(CELL, "e=>e.innerText.trim()") == "批量暂存测试甲",
p.eval_on_selector(CELL, "e=>e.innerText.trim()"))
ck("待保存条消失", "待保存" not in p.inner_text("#tbPendBar"))
ck("修改记录出现 1 条", p.eval_on_selector_all("#tbHist tbody tr", "e=>e.length") == 1)
p.evaluate("document.querySelector('#tbHistCard').open = true")
p.wait_for_timeout(250)
hist = p.inner_text("#tbHist")
ck("记录标为「批量」并含旧→新", "批量" in hist and origin[:3] in hist,
hist.replace("\n", " | ")[:110])
p.screenshot(path=os.path.join(OUT, "V9-3-history.png"), full_page=True)
# 落盘校验
st, d = api_call("GET", "/api/tables/rent_parking")
tb = d.get("table") or d
rid2 = p.eval_on_selector(CELL, "e=>e.dataset.row")
saved = {r["_id"]: r for r in (tb.get("rows") or [])}[rid2]
ck("后端已落盘新值", saved.get("AA") == "批量暂存测试甲", str(saved.get("AA")))
# ============================================ 8. 撤销(批量)
print("\n8. 撤销批量修改")
p.click("#btn-tb-undo")
p.wait_for_timeout(500)
ck("撤销弹确认框", cfm_open())
p.click("#cfmOk")
p.wait_for_timeout(1400)
ck("撤销后回到原值",
p.eval_on_selector(CELL, "e=>e.innerText.trim()") == origin,
p.eval_on_selector(CELL, "e=>e.innerText.trim()"))
ck("修改记录清空", p.eval_on_selector_all("#tbHist tbody tr", "e=>e.length") == 0)
st, d = api_call("GET", "/api/tables/rent_parking")
tb = d.get("table") or d
saved = {r["_id"]: r for r in (tb.get("rows") or [])}[rid2]
ck("后端也回到原值", (saved.get("AA") or "") == origin,
"%r vs %r" % (saved.get("AA"), origin))
# ============================================ 9. 服务端仍拦未确认写
print("\n9. 服务端仍要求 confirm=true(批量接口)")
st, d = api_call("PUT", "/api/tables/rent_parking/cells",
{"changes": [{"row_id": rid2, "col": "AA", "value": "x"}]})
ck("不带 confirm → 428", st == 428, str(st))
st, d = api_call("PUT", "/api/tables/rent_parking/cells",
{"changes": [{"row_id": rid2, "col": "AA", "value": "x"}],
"confirm": True})
ck("带 confirm → 200 且返回改动清单",
st == 200 and len(d.get("changed") or []) == 1, str(st))
ck("无页面 JS 错误", not errors, str(errors[:2]))
b.close()
finally:
srv.shutdown()
print("\n===== 通过 %d / 失败 %d =====" % (len(OK), len(FAIL)))
for f in FAIL:
print(" FAIL:", f)
return 0 if not FAIL else 1
if __name__ == "__main__":
sys.exit(main())
# -*- coding: utf-8 -*-
"""在**沙箱 data/** 上跑指定的 UI 回归脚本(注入 RENTOS_BASE)。
用法:
python tools/ui_verify_runner.py # 默认跑 ui_verify + ui_verify2
python tools/ui_verify_runner.py tools/ui_verify2.py # 只跑一个
RENTOS_VERIFY_PORT=8905 python tools/ui_verify_runner.py
为什么需要它:
`tools/ui_verify.py` / `tools/ui_verify2.py` 默认连 8899 —— 那是**真实台账**,
而这两个脚本会改单元格 / 增行 / 删行。直接跑会污染权威数据。
本 runner 为每个脚本各起一份**全新沙箱**(复制 data/ 到临时目录 + 改写 config 路径常量
+ 另起端口服务),真实 data/ 零改动;脚本通过环境变量 RENTOS_BASE 连沙箱。
"""
import io
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*"
from rentos import config as C # noqa: E402
from rentos import server as S # noqa: E402
PORT = int(os.environ.get("RENTOS_VERIFY_PORT", "8901"))
BASE = "http://127.0.0.1:%d" % PORT
DEFAULT_SCRIPTS = ["tools/ui_verify.py", "tools/ui_verify2.py"]
def start_sandbox():
tmp = tempfile.mkdtemp(prefix="rentos-uiverify-")
shutil.copytree(os.path.join(ROOT, "data"), os.path.join(tmp, "data"))
data = os.path.join(tmp, "data")
for name, val in {
"DATA_DIR": data,
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
"UNIT_MAP_FILE": os.path.join(data, "unit_map.json"),
"UPLOAD_DIR": os.path.join(tmp, "uploads"),
"OUTPUT_DIR": os.path.join(tmp, "output", "elec"),
"OUTPUT_RENT_DIR": os.path.join(tmp, "output", "rent"),
"TABLE_EXPORT_DIR": os.path.join(tmp, "output", "tables"),
"BATCH_EXPORT_DIR": os.path.join(tmp, "output", "exports"),
"LOG_DIR": os.path.join(tmp, "logs"),
}.items():
setattr(C, name, val)
C.ensure_dirs()
srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler)
srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, tmp
def run_one(script):
srv, tmp = start_sandbox()
print("\n" + "=" * 72)
print("沙箱:%s(真实 data/ 未被触碰)" % tmp)
print("脚本:%s → %s" % (script, BASE))
print("=" * 72, flush=True)
env = dict(os.environ)
env["RENTOS_BASE"] = BASE
env["no_proxy"] = "*"
env["NO_PROXY"] = "*"
try:
rc = subprocess.call([sys.executable, os.path.join(ROOT, script)],
cwd=ROOT, env=env)
finally:
srv.shutdown()
shutil.rmtree(tmp, ignore_errors=True)
return rc
def main():
scripts = sys.argv[1:] or DEFAULT_SCRIPTS
bad = []
for s in scripts:
if run_one(s) != 0:
bad.append(s)
print("\n" + "=" * 72)
if bad:
print("失败脚本:%s" % ", ".join(bad))
else:
print("全部脚本通过:%s" % ", ".join(scripts))
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
...@@ -291,18 +291,21 @@ ...@@ -291,18 +291,21 @@
<div class="card"> <div class="card">
<h2>数据表 <span class="hint" id="tbMeta"></span></h2> <h2>数据表 <span class="hint" id="tbMeta"></span></h2>
<div id="tbLockBar"></div> <div id="tbLockBar"></div>
<div id="tbPendBar"></div>
<div class="grid-wrap" id="tbWrap"><div class="empty">加载中…</div></div> <div class="grid-wrap" id="tbWrap"><div class="empty">加载中…</div></div>
<div class="spacer"></div> <div class="spacer"></div>
<div class="muted" style="font-size:12px"> <div class="muted" style="font-size:12px">
数据默认<b>锁定只读</b>。勾选上方「编辑模式」后,单击单元格才能修改; 数据默认<b>锁定只读</b>。勾选上方「编辑模式」后,单击单元格才能修改;
保存前会弹出确认框(显示行、列、旧值、新值),确认后才写入 JSON 存档;Esc 取消编辑。 改动会先<b>暂存并高亮</b>(不逐格弹窗),改完点顶部「保存 N 处改动」时<b>统一确认一次</b>再写入;
数值/金额列按类型校验,「合计」行为当前显示行的实时汇总。每一步改动都会记入下方「修改记录」,可逐条撤销。 不想要就点「放弃改动」。Esc 取消当前单元格编辑。
数值/金额列按类型校验,「合计」行为当前显示行的实时汇总。每次提交都会记入下方「修改记录」,可撤销。
<br>数据仅初次导入有效,之后不提供导入;增删改一律在页面完成。 <br>数据仅初次导入有效,之后不提供导入;增删改一律在页面完成。
</div> </div>
</div> </div>
<datalist id="tbCompanies" data-dir="companies"></datalist> <datalist id="tbCompanies" data-dir="companies"></datalist>
<datalist id="tbOwners" data-dir="owners"></datalist> <datalist id="tbOwners" data-dir="owners"></datalist>
<datalist id="tbFreeTypes"></datalist>
<details class="card" id="tbHistCard" style="display:none"> <details class="card" id="tbHistCard" style="display:none">
<summary style="cursor:pointer;font-weight:600">修改记录(最近 10 步,可撤销)</summary> <summary style="cursor:pointer;font-weight:600">修改记录(最近 10 步,可撤销)</summary>
......
...@@ -161,6 +161,9 @@ function applyNav(hash) { ...@@ -161,6 +161,9 @@ function applyNav(hash) {
/** 跳转:优先改 hash(由 hashchange 统一渲染),同址则直接刷新 */ /** 跳转:优先改 hash(由 hashchange 统一渲染),同址则直接刷新 */
function navTo(hash) { function navTo(hash) {
if (pendList().length &&
!confirm(`还有 ${pendList().length} 处改动未保存,离开会放弃它们。确定离开?`)) return;
if (pendList().length) TB_PEND = {};
const path = '#/' + hash; const path = '#/' + hash;
if (location.hash === path) applyNav(hash); if (location.hash === path) applyNav(hash);
else location.hash = path; else location.hash = path;
...@@ -312,6 +315,7 @@ let UNITS = {}; // 单元 → 现租客(来自「房租 ...@@ -312,6 +315,7 @@ let UNITS = {}; // 单元 → 现租客(来自「房租
let UOWN = {}; // 单元 → 出租方(台账 A 列) let UOWN = {}; // 单元 → 出租方(台账 A 列)
let ECO = []; // 租客名册(单位名称候选) let ECO = []; // 租客名册(单位名称候选)
let EO = []; // 出租方名单(出租方列候选) let EO = []; // 出租方名单(出租方列候选)
let FREE_TYPES = []; // 免租类型候选(来自 /api/units 的 free_types)
let ELEC = []; // 待提交的明细行 let ELEC = []; // 待提交的明细行
let EDEF = { price: 1.2, ac: 12.25, room: 1.0 }; let EDEF = { price: 1.2, ac: 12.25, room: 1.0 };
...@@ -333,7 +337,8 @@ async function loadUnits() { ...@@ -333,7 +337,8 @@ async function loadUnits() {
UOWN = d.room_owners || d.unit_owners || {}; UOWN = d.room_owners || d.unit_owners || {};
ECO = d.companies || []; ECO = d.companies || [];
EO = d.owners || []; EO = d.owners || [];
} catch (e) { UNITS = {}; UOWN = {}; ECO = []; EO = []; } FREE_TYPES = d.free_types || [];
} catch (e) { UNITS = {}; UOWN = {}; ECO = []; EO = []; FREE_TYPES = []; }
const seen = new Set(), opts = []; const seen = new Set(), opts = [];
Object.keys(UNITS).sort((a, b) => a.localeCompare(b, 'zh')).forEach(u => { Object.keys(UNITS).sort((a, b) => a.localeCompare(b, 'zh')).forEach(u => {
if (seen.has(u)) return; if (seen.has(u)) return;
...@@ -344,6 +349,7 @@ async function loadUnits() { ...@@ -344,6 +349,7 @@ async function loadUnits() {
fillDatalist('eCompanies', ECO); fillDatalist('eCompanies', ECO);
fillDatalist('eOwners', EO); fillDatalist('eOwners', EO);
fillDatalist('eMeters', METERS); fillDatalist('eMeters', METERS);
fillDatalist('tbFreeTypes', FREE_TYPES); // 台账「免租类型」列候选
} }
/** 电费明细的两种类目(与后端 elec_parser 一致) */ /** 电费明细的两种类目(与后端 elec_parser 一致) */
...@@ -1303,6 +1309,7 @@ function renderRentPreview() { ...@@ -1303,6 +1309,7 @@ function renderRentPreview() {
$('#rpSum').innerHTML = $('#rpSum').innerHTML =
`可出单 <b>${s.row_count || 0}</b> 行 / 通知书 <b>${s.document_count || 0}</b> 份 / ` + `可出单 <b>${s.row_count || 0}</b> 行 / 通知书 <b>${s.document_count || 0}</b> 份 / ` +
`合计 <b>¥${fmt(s.total_amount || 0)}</b>` + `合计 <b>¥${fmt(s.total_amount || 0)}</b>` +
(s.rentfree_count ? ` | <span class="tag skip">免租期内 ${s.rentfree_count} 份</span>` : '') +
(s.skip_count ? ` | 未纳入 ${s.skip_count} 行` : ''); (s.skip_count ? ` | 未纳入 ${s.skip_count} 行` : '');
// ---- 将生成的通知书(分组结果) // ---- 将生成的通知书(分组结果)
...@@ -1315,7 +1322,10 @@ function renderRentPreview() { ...@@ -1315,7 +1322,10 @@ function renderRentPreview() {
<tr><td>${i + 1}</td> <tr><td>${i + 1}</td>
<td>${esc(d.owner_name || d.owner)}</td> <td>${esc(d.owner_name || d.owner)}</td>
<td>${esc(d.company)}</td> <td>${esc(d.company)}</td>
<td class="mono-sm">${esc((d.units || []).join('、'))}</td> <td class="mono-sm">${esc((d.units || []).join('、'))}${
(d.rentfree || []).length
? ' <span class="tag skip" title="' + esc(d.rentfree.map(h => `${h.matched}(${h.type || '首N月免租'},免租至 ${h.end})`).join(';')) + '">免租期内</span>'
: ''}</td>
<td class="mono-sm">${esc(d.period_from)} ~ ${esc(d.period_to)}</td> <td class="mono-sm">${esc(d.period_from)} ~ ${esc(d.period_to)}</td>
<td class="num">${(d.items || []).length}</td> <td class="num">${(d.items || []).length}</td>
<td class="num">¥${fmt(d.total_amount)}</td></tr>`).join('')} <td class="num">¥${fmt(d.total_amount)}</td></tr>`).join('')}
...@@ -1326,8 +1336,13 @@ function renderRentPreview() { ...@@ -1326,8 +1336,13 @@ function renderRentPreview() {
const body = rows.length const body = rows.length
? rows.map(r => { ? rows.map(r => {
const on = RP_PICK.has(r.row_id); const on = RP_PICK.has(r.row_id);
const tag = (r.in_period === false || r.due === false) const tags = [];
? '<span class="tag wait">区间外</span>' : ''; if (r.in_period === false || r.due === false) tags.push('<span class="tag wait">区间外</span>');
if ((r.rentfree || []).length) {
const rf = r.rentfree.map(h => `${h.matched}(${h.type || '首N月免租'},免租至 ${h.end})`).join(';');
tags.push(`<span class="tag skip" title="${esc(rf)}">免租期内</span>`);
}
const tag = tags.join(' ');
return `<tr> return `<tr>
<td><input type="checkbox" data-pick="${esc(r.row_id)}"${on ? ' checked' : ''}></td> <td><input type="checkbox" data-pick="${esc(r.row_id)}"${on ? ' checked' : ''}></td>
<td>${esc(r.owner)}</td> <td>${esc(r.owner)}</td>
...@@ -1616,23 +1631,26 @@ function renderRentfree(rf) { ...@@ -1616,23 +1631,26 @@ function renderRentfree(rf) {
const wins = rf.windows || [], day = rf.on || todayStr(0); const wins = rf.windows || [], day = rf.on || todayStr(0);
const act = wins.filter(w => w.begin <= day && day < w.end); const act = wins.filter(w => w.begin <= day && day < w.end);
const head = `<div style="margin-bottom:10px"> const head = `<div style="margin-bottom:10px">
判定依据:<b>合同期始 + 免租期(月)</b>,含起始日、不含结束日(0.5 个月按 15 天折算)。 免租类型:<b>首N月免租</b>(合同期始起免 N 个月,N 看「免租期(月)」列)|
<b>每年8-9月免租</b>(合约期内每个自然年的 8、9 月整月)
| 来源:<b>房租车位费台账</b> | 来源:<b>房租车位费台账</b>
| 共 <b>${wins.length}</b> 段 | 共 <b>${wins.length}</b> 段
| <b>${esc(day)}</b> 处于免租期:<b>${act.length ? act.map(a => esc(a.unit)).join('、') : '无'}</b> | <b>${esc(day)}</b> 处于免租期:<b>${act.length ? act.map(a => esc(a.unit)).join('、') : '无'}</b>
</div>`; </div>`;
if (!wins.length) { if (!wins.length) {
$('#rentfreeBox').innerHTML = head + $('#rentfreeBox').innerHTML = head +
'<span class="muted">「房租车位费」台账里没有登记免租期(合同期始 + 免租期(月)两列都为空)。</span>'; '<span class="muted">「房租车位费」台账里没有登记免租期(「免租类型」列留空且「免租期(月)」为 0)。</span>';
return; return;
} }
$('#rentfreeBox').innerHTML = head + `<div class="table-wrap" style="max-height:300px"><table> $('#rentfreeBox').innerHTML = head + `<div class="table-wrap" style="max-height:300px"><table>
<thead><tr><th>单元</th><th style="width:64px">楼层</th><th>租客</th> <thead><tr><th>单元</th><th style="width:64px">楼层</th><th>租客</th>
<th style="width:126px">免租类型</th>
<th style="width:200px">免租区间</th><th class="num" style="width:70px">月数</th></tr></thead> <th style="width:200px">免租区间</th><th class="num" style="width:70px">月数</th></tr></thead>
<tbody>${wins.map(w => { <tbody>${wins.map(w => {
const on = w.begin <= day && day < w.end; const on = w.begin <= day && day < w.end;
return `<tr${on ? ' style="background:#f5f3ff"' : ''}> return `<tr${on ? ' style="background:#f5f3ff"' : ''}>
<td>${esc(w.unit)}</td><td>${esc(w.floor)}</td><td>${esc(w.tenant)}</td> <td>${esc(w.unit)}</td><td>${esc(w.floor)}</td><td>${esc(w.tenant)}</td>
<td>${esc(w.type || '首N月免租')}</td>
<td class="mono-sm">${esc(w.begin)} → ${esc(w.end)}</td> <td class="mono-sm">${esc(w.begin)} → ${esc(w.end)}</td>
<td class="num">${w.months}</td></tr>`; <td class="num">${w.months}</td></tr>`;
}).join('')}</tbody></table></div>`; }).join('')}</tbody></table></div>`;
...@@ -2121,6 +2139,7 @@ let TB_KEY = ''; ...@@ -2121,6 +2139,7 @@ let TB_KEY = '';
let TB_FILTER = ''; let TB_FILTER = '';
let TB_EDIT = false; // 编辑模式:默认关闭(锁定只读) let TB_EDIT = false; // 编辑模式:默认关闭(锁定只读)
let TB_HIST = []; // 修改记录(最近 10 步,可撤销) let TB_HIST = []; // 修改记录(最近 10 步,可撤销)
let TB_PEND = {}; // 批量编辑:暂存的单元格改动(key = rowId|col)
function nowStr() { function nowStr() {
const d = new Date(); const d = new Date();
...@@ -2137,6 +2156,7 @@ async function loadTable(key) { ...@@ -2137,6 +2156,7 @@ async function loadTable(key) {
if (!key) return; if (!key) return;
TB_KEY = key; TB_KEY = key;
TB_HIST = []; TB_HIST = [];
TB_PEND = {};
$('#tbWrap').innerHTML = '<div class="empty">加载中…</div>'; $('#tbWrap').innerHTML = '<div class="empty">加载中…</div>';
try { try {
const d = await api('/api/tables/' + key); const d = await api('/api/tables/' + key);
...@@ -2284,6 +2304,8 @@ function renderGrid() { ...@@ -2284,6 +2304,8 @@ function renderGrid() {
` | ${TB_EDIT ? '可编辑' : '只读锁定'} | 合计为当前显示行的实时汇总`; ` | ${TB_EDIT ? '可编辑' : '只读锁定'} | 合计为当前显示行的实时汇总`;
bindGridEvents(); bindGridEvents();
paintPending();
renderPendBar();
} }
function cellOf(rowId, col) { function cellOf(rowId, col) {
...@@ -2317,19 +2339,110 @@ function renderLockBar() { ...@@ -2317,19 +2339,110 @@ function renderLockBar() {
<span><b>只读访客账号</b>:台账可以浏览与导出,但不能修改。 <span><b>只读访客账号</b>:台账可以浏览与导出,但不能修改。
登记收款 / 出单 / 改单元格等写操作在服务端一律被拒绝。</span></div>`; 登记收款 / 出单 / 改单元格等写操作在服务端一律被拒绝。</span></div>`;
$('#btn-tb-add').disabled = true; $('#btn-tb-add').disabled = true;
renderPendBar();
updateUndoBtn(); updateUndoBtn();
return; return;
} }
$('#tbLockBar').innerHTML = TB_EDIT $('#tbLockBar').innerHTML = TB_EDIT
? `<div class="lockbar on"><i class="dot"></i> ? `<div class="lockbar on"><i class="dot"></i>
<span><b>编辑模式已开启</b>:单击单元格可修改;每次保存前都会弹出确认框(行 / 列 / 旧值 / 新值), <span><b>编辑模式已开启</b>:单击单元格即可修改;改动先<b>暂存并高亮</b>,不逐格弹窗。
确认后才写入 JSON 存档。改错了可在下方「修改记录」里撤销。</span></div>` 改完点上方「保存 N 处改动」统一确认一次即写入 JSON 存档,或用「放弃改动」全部还原。</span></div>`
: `<div class="lockbar"><i class="dot"></i> : `<div class="lockbar"><i class="dot"></i>
<span><b>数据已锁定(只读)</b>:勾选工具栏里的「编辑模式」后才能修改,防止误触改写台账。</span></div>`; <span><b>数据已锁定(只读)</b>:勾选工具栏里的「编辑模式」后才能修改,防止误触改写台账。</span></div>`;
$('#btn-tb-add').disabled = !TB_EDIT; $('#btn-tb-add').disabled = !TB_EDIT;
renderPendBar();
updateUndoBtn(); updateUndoBtn();
} }
/* ---------------- 批量编辑:暂存 / 保存 / 放弃 ---------------- */
function tbColLabel(col) {
return ((TB && TB.table && TB.table.columns || []).find(x => x.id === col) || {}).label || col;
}
function pendList() {
return Object.values(TB_PEND || {});
}
function paintPending() {
const wrap = $('#tbWrap');
if (!wrap) return;
wrap.querySelectorAll('td.cell').forEach(td => {
const k = td.dataset.row + '|' + td.dataset.col;
td.classList.toggle('pending', !!TB_PEND[k]);
});
}
function renderPendBar() {
const box = $('#tbPendBar');
if (!box) return;
const list = pendList();
if (!list.length) { box.innerHTML = ''; return; }
box.innerHTML = `<div class="lockbar pend"><i class="dot"></i>
<span><b>${list.length} 处改动待保存</b>:已高亮标记,点「保存全部」统一确认后写入存档。</span>
<span style="margin-left:auto;display:flex;gap:8px">
<button class="primary sm" id="btn-tb-save-all">保存 ${list.length} 处改动</button>
<button class="sm" id="btn-tb-discard">放弃改动</button>
</span></div>`;
const b1 = $('#btn-tb-save-all'), b2 = $('#btn-tb-discard');
if (b1) b1.addEventListener('click', savePending);
if (b2) b2.addEventListener('click', discardPending);
}
/** 暂存一处单元格改动(不落盘)。值未变则视为撤销该处暂存。 */
function stageCell(rowId, col, colType, label, from, to) {
const key = rowId + '|' + col;
const a = (from === null || from === undefined) ? '' : String(from);
const b = (to === null || to === undefined) ? '' : String(to);
if (a === b) delete TB_PEND[key];
else TB_PEND[key] = { rowId, col, colType, label, from, to };
renderPendBar();
paintPending();
}
async function savePending() {
const list = pendList();
if (!list.length) return;
const show = list.slice(0, 12);
const more = list.length - show.length;
const ok = await confirmBox({
title: `确认保存 ${list.length} 处改动`,
rows: show.map(h => ({
k: tbColLabel(h.col),
v: `${esc(h.label)}:<span class="cfm-old">${esc(hFmt(h, h.from))}</span>` +
`<span class="cfm-arrow">→</span><span class="cfm-new">${esc(hFmt(h, h.to))}</span>`,
})).concat(more > 0 ? [{ k: '…', v: `另有 ${more} 处改动未展开` }] : []),
warn: '确认后一次性写入 JSON 存档(可在「修改记录」里整批撤销)。',
okText: '确认保存',
});
if (!ok) return;
try {
const d = await api(`/api/tables/${TB_KEY}/cells`, {
method: 'PUT',
body: {
changes: list.map(h => ({ row_id: h.rowId, col: h.col, value: h.to })),
confirm: true, who: '批量修改',
},
});
(d.changed || []).forEach(c => {
const r = (TB.table.rows || []).find(x => x._id === c.row_id);
if (r) r[c.col] = c.to;
});
pushHist({ t: 'batch', items: list.map(h => ({ ...h })), label: `${list.length} 处改动` });
TB_PEND = {};
renderGrid(); renderHist(); updateUndoBtn();
toast(`已保存 ${(d.changed || []).length} 处改动`, 'ok');
} catch (e) { toast(e.message, 'err'); }
}
async function discardPending() {
const n = pendList().length;
if (!n) return;
if (!confirm(`放弃 ${n} 处未保存的改动?`)) return;
TB_PEND = {};
renderGrid(); renderPendBar();
toast('已放弃改动', 'ok');
}
/* ---------------- 修改记录 / 撤销 ---------------- */ /* ---------------- 修改记录 / 撤销 ---------------- */
function pushHist(rec) { function pushHist(rec) {
rec.at = nowStr(); rec.at = nowStr();
...@@ -2362,10 +2475,12 @@ function renderHist() { ...@@ -2362,10 +2475,12 @@ function renderHist() {
const desc = h.t === 'cell' const desc = h.t === 'cell'
? `<span class="cfm-old">${esc(hFmt(h, h.from))}</span><span class="cfm-arrow">→</span>` + ? `<span class="cfm-old">${esc(hFmt(h, h.from))}</span><span class="cfm-arrow">→</span>` +
`<span class="cfm-new">${esc(hFmt(h, h.to))}</span>` `<span class="cfm-new">${esc(hFmt(h, h.to))}</span>`
: h.t === 'batch'
? `<span class="muted">批量 ${(h.items || []).length} 处改动</span>`
: `<span class="muted">${h.t === 'add' ? '新增一行' : '删除一行'}</span>`; : `<span class="muted">${h.t === 'add' ? '新增一行' : '删除一行'}</span>`;
const colTxt = h.t === 'cell' const colTxt = h.t === 'cell'
? `${esc(colLabel(h.col))} <span class="muted mono-sm">[${esc(h.col)}]</span>` ? `${esc(colLabel(h.col))} <span class="muted mono-sm">[${esc(h.col)}]</span>`
: '整行'; : (h.t === 'batch' ? `${(h.items || []).length} 处单元格` : '整行');
return `<tr><td class="mono-sm">${esc(h.at)}</td><td>${esc(h.label)}</td> return `<tr><td class="mono-sm">${esc(h.at)}</td><td>${esc(h.label)}</td>
<td>${colTxt}</td><td>${desc}</td> <td>${colTxt}</td><td>${desc}</td>
<td><button class="sm" data-undo="${i}">撤销</button></td></tr>`; <td><button class="sm" data-undo="${i}">撤销</button></td></tr>`;
...@@ -2385,6 +2500,8 @@ async function undoAt(i) { ...@@ -2385,6 +2500,8 @@ async function undoAt(i) {
{ k: '操作', v: h.t === 'cell' { k: '操作', v: h.t === 'cell'
? `${esc(h.col)}:<span class="cfm-new">${esc(hFmt(h, h.to))}</span>` + ? `${esc(h.col)}:<span class="cfm-new">${esc(hFmt(h, h.to))}</span>` +
`<span class="cfm-arrow">→</span><span class="cfm-old">${esc(hFmt(h, h.from))}</span>` `<span class="cfm-arrow">→</span><span class="cfm-old">${esc(hFmt(h, h.from))}</span>`
: h.t === 'batch'
? `把这 ${(h.items || []).length} 处改动还原为修改前的值`
: (h.t === 'add' ? '撤销新增(删除该空行)' : '恢复被删除的整行') }, : (h.t === 'add' ? '撤销新增(删除该空行)' : '恢复被删除的整行') },
], ],
warn: '撤销会立即写入 JSON 存档。', warn: '撤销会立即写入 JSON 存档。',
...@@ -2412,6 +2529,21 @@ async function undoAt(i) { ...@@ -2412,6 +2529,21 @@ async function undoAt(i) {
TB_HIST.splice(i, 1); TB_HIST.splice(i, 1);
renderGrid(); renderHist(); updateUndoBtn(); renderGrid(); renderHist(); updateUndoBtn();
toast('已撤销新增行', 'ok'); toast('已撤销新增行', 'ok');
} else if (h.t === 'batch') {
await api(`/api/tables/${TB_KEY}/cells`, {
method: 'PUT',
body: {
changes: (h.items || []).map(x => ({ row_id: x.rowId, col: x.col, value: x.from })),
confirm: true, who: '撤销批量修改',
},
});
(h.items || []).forEach(x => {
const r = (TB.table.rows || []).find(y => y._id === x.rowId);
if (r) r[x.col] = x.from;
});
TB_HIST.splice(i, 1);
renderGrid(); renderHist(); updateUndoBtn();
toast('已撤销该批改动', 'ok');
} else { } else {
const data = {}; const data = {};
Object.entries(h.row || {}).forEach(([k, v]) => { if (k !== '_id') data[k] = v; }); Object.entries(h.row || {}).forEach(([k, v]) => { if (k !== '_id') data[k] = v; });
...@@ -2436,10 +2568,13 @@ async function startEdit(td) { ...@@ -2436,10 +2568,13 @@ async function startEdit(td) {
const row = (TB.table.rows || []).find(x => x._id === rowId) || {}; const row = (TB.table.rows || []).find(x => x._id === rowId) || {};
const colDef = (TB.table.columns || []).find(x => x.id === col) || { label: col, type: 'text' }; const colDef = (TB.table.columns || []).find(x => x.id === col) || { label: col, type: 'text' };
// 「现租客」「出租方」两列给出名册候选(仍可自由输入不在册的值) // 「现租客」「出租方」「免租类型」给出候选(仍可自由输入不在册的值)
const dlAttr = colDef.label === '现租客' ? ' list="tbCompanies"' const dlAttr = colDef.label === '现租客' ? ' list="tbCompanies"'
: (colDef.label === '出租方' ? ' list="tbOwners"' : ''); : (colDef.label === '出租方' ? ' list="tbOwners"'
td.innerHTML = `<input${dlAttr} value="${esc(raw === null || raw === undefined ? '' : raw)}">`; : (colDef.label === '免租类型' ? ' list="tbFreeTypes"' : ''));
const pend = TB_PEND[rowId + '|' + col];
const cur = pend ? pend.to : raw; // 已暂存过的格子显示暂存值
td.innerHTML = `<input${dlAttr} value="${esc(cur === null || cur === undefined ? '' : cur)}">`;
const inp = td.querySelector('input'); const inp = td.querySelector('input');
inp.focus(); inp.focus();
inp.select(); inp.select();
...@@ -2449,47 +2584,22 @@ async function startEdit(td) { ...@@ -2449,47 +2584,22 @@ async function startEdit(td) {
if (done) return; if (done) return;
done = true; done = true;
const val = inp.value; const val = inp.value;
const same = String(val) === String(raw === null || raw === undefined ? '' : raw); const rawS = (raw === null || raw === undefined) ? '' : String(raw);
if (!save || same) { td.innerHTML = old; return; } if (!save) { td.innerHTML = old; return; }
if (String(val) === rawS) { // 改回原值 → 撤销该处暂存
// 行的识别标签:房间号优先(早期误把它写成面积列,弹窗里显示成「80」认不出是哪行) delete TB_PEND[rowId + '|' + col];
td.innerHTML = esc(fmtVal(raw, colDef.type));
td.title = String(fmtVal(raw, colDef.type) || '');
renderPendBar(); paintPending();
return;
}
// 批量编辑:先暂存(不落盘),点「保存 N 处改动」时统一确认后一次写入
const label = [row.C || row.D || row.B || row._id, row.F || row.G] const label = [row.C || row.D || row.B || row._id, row.F || row.G]
.filter(Boolean).join(' · '); .filter(Boolean).join(' · ');
const ok = await confirmBox({ stageCell(rowId, col, colDef.type, label, raw, val);
title: '确认修改数据', td.innerHTML = val === '' ? '' : esc(val);
rows: [ td.title = val;
{ k: '行', v: `${esc(label)} <span class="muted mono-sm">(${esc(rowId)})</span>` }, td.classList.add('pending');
{ k: '列', v: `${esc(colDef.label)} <span class="muted mono-sm">[${esc(col)}]</span>` },
{ k: '改动', v: `<span class="cfm-old">${esc(fmtVal(raw, colDef.type) || '(空)')}</span>` +
`<span class="cfm-arrow">→</span>` +
`<span class="cfm-new">${esc(val || '(空)')}</span>` },
],
warn: '确认后立即写入 JSON 存档(可在「修改记录」里撤销)。',
okText: '确认修改',
});
if (!ok) { td.innerHTML = old; return; }
td.classList.add('saving');
try {
const d = await api(`/api/tables/${TB_KEY}/cell`, {
method: 'PUT', body: { row_id: rowId, col, value: val, confirm: true }
});
const r = (TB.table.rows || []).find(x => x._id === rowId);
if (r) r[col] = d.cell.value;
td.classList.remove('saving');
td.classList.add('saved');
td.innerHTML = esc(fmtVal(d.cell.value, colDef.type));
td.title = fmtVal(d.cell.value, colDef.type);
setTimeout(() => td.classList.remove('saved'), 900);
refreshFoot();
pushHist({ t: 'cell', rowId, label, col, colType: colDef.type, from: raw, to: d.cell.value });
} catch (e) {
td.classList.remove('saving');
td.classList.add('fail');
td.innerHTML = old;
setTimeout(() => td.classList.remove('fail'), 1500);
toast(e.message, 'err');
}
}; };
inp.addEventListener('blur', () => finish(true)); inp.addEventListener('blur', () => finish(true));
...@@ -2567,10 +2677,22 @@ $('#tbEdit').addEventListener('change', e => { ...@@ -2567,10 +2677,22 @@ $('#tbEdit').addEventListener('change', e => {
renderLockBar(); renderLockBar();
return toast('只读访客账号:无修改权限', 'err'); return toast('只读访客账号:无修改权限', 'err');
} }
const off = !e.target.checked;
if (off && pendList().length &&
!confirm(`还有 ${pendList().length} 处改动未保存,关掉编辑模式会放弃它们。确定关闭?`)) {
e.target.checked = true;
return;
}
if (off) TB_PEND = {};
TB_EDIT = e.target.checked; TB_EDIT = e.target.checked;
renderLockBar(); renderLockBar();
if (TB) renderGrid(); if (TB) renderGrid();
toast(TB_EDIT ? '已开启编辑模式:修改前会二次确认' : '已锁定为只读', TB_EDIT ? 'ok' : ''); toast(TB_EDIT ? '已开启编辑模式:改完统一保存' : '已锁定为只读', TB_EDIT ? 'ok' : '');
});
// 未保存的改动:刷新 / 关闭页面时拦一下
window.addEventListener('beforeunload', e => {
if (pendList().length) { e.preventDefault(); e.returnValue = ''; }
}); });
$('#btn-tb-undo').addEventListener('click', () => { $('#btn-tb-undo').addEventListener('click', () => {
......
...@@ -524,6 +524,11 @@ table.edgrid td.me { font-variant-numeric: tabular-nums; } ...@@ -524,6 +524,11 @@ table.edgrid td.me { font-variant-numeric: tabular-nums; }
table.edgrid td.saving { background: #fffbeb !important; } table.edgrid td.saving { background: #fffbeb !important; }
table.edgrid td.saved { background: #f0fdf4 !important; } table.edgrid td.saved { background: #f0fdf4 !important; }
table.edgrid td.fail { background: #fef2f2 !important; } table.edgrid td.fail { background: #fef2f2 !important; }
/* 批量编辑:已暂存、尚未保存的格子 */
table.edgrid td.cell.pending {
background: #fffbeb !important;
box-shadow: inset 0 0 0 2px #fcd34d;
}
table.edgrid td.cell input { table.edgrid td.cell input {
width: 100%; height: 22px; padding: 0 4px; margin: 0; width: 100%; height: 22px; padding: 0 4px; margin: 0;
...@@ -576,6 +581,9 @@ table.edgrid tfoot td.act { background: #fff4d6; z-index: 8; } ...@@ -576,6 +581,9 @@ table.edgrid tfoot td.act { background: #fff4d6; z-index: 8; }
background: #f8fafc; color: var(--text-2); background: #f8fafc; color: var(--text-2);
} }
.lockbar.on { background: #f0fdf4; border-color: #bbf7d0; color: #166534; } .lockbar.on { background: #f0fdf4; border-color: #bbf7d0; color: #166534; }
.lockbar.pend { background: #fffbeb; border-color: #fcd34d; color: #92400e; }
.lockbar.pend .dot { background: #f59e0b; }
.lockbar.pend button { margin: 0; }
.lockbar .dot { width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; flex: 0 0 auto; } .lockbar .dot { width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; flex: 0 0 auto; }
.lockbar.on .dot { background: var(--ok); } .lockbar.on .dot { background: var(--ok); }
......
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