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 回归均走沙箱)
This diff is collapsed.
This diff is collapsed.
# -*- 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)
......
This diff is collapsed.
# -*- 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>
......
This diff is collapsed.
...@@ -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