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

feat(房租出单): 本期出单筛选改为按账期区间(起~止)

「房租车位费」本期出单原先按「出单基准日」筛(账单起始日 <= 基准日),
现改为按账期区间筛:命中 = 该行账期起始日落在 [起, 止] 内,区间取代基准日。

- rent_parser: 新增 in_period() / classify_period()(-> 区间内/区间外/不可出单);
  原 classify(rows, on_date) 保留,定时器 due_for_push 仍走它
- service: rent_preview(from, to, all) 改用 classify_period,返回 from/to 取代 on;
  later 语义变为「区间外」;新增 _period_label() 生成账期标签(单月/跨月);
  ingest_rent 增 from_date/to_date 三分支选行,批次记 period_filter_from/to
- server: /api/rent/preview 收 ?from=&to=(?on= 作兼容别名 = to);
  POST /api/rent/batches 透传 from/to
- web: #rp-on 拆为 #rp-from / #rp-to;「显示未到期」-> 「显示区间外」;
  文案改「按账期区间筛选」
- 测试: test_rent 新增 21 项(区间端点/空区间/区间外原因/原 classify 不变/出单/账期标签);
  新增 ui_verify8.py(沙箱+Playwright 端到端 30 项);ui_verify5.py 适配新控件

单测 212 项全绿;ui_verify8 30/30;ui_verify5 43/43。

Co-authored-by: CodeBuddy
parent 422a556c
...@@ -231,6 +231,48 @@ def billable(rows, on_date=None): ...@@ -231,6 +231,48 @@ def billable(rows, on_date=None):
return due, later + bad return due, later + bad
# ---------------------------------------------------------------- 账期区间筛选
def in_period(row, from_date=None, to_date=None):
"""该行的账期起始日是否落在 [from_date, to_date] 区间内(含端点)。
两侧都可为空 —— 空表示该侧不设限;两侧都空则只要行有账期起始日就算命中。
"""
d = row.get("period_from")
if not d:
return False
f = _d(from_date)
t = _d(to_date)
if f and d < f:
return False
if t and d > t:
return False
return True
def classify_period(rows, from_date=None, to_date=None):
"""按「账期区间」筛选 → (区间内可出单, 区间外可出单, 不可出单)。
命中口径:行的**账期起始日**(台账「账单起始日」)落在 [from_date, to_date] 内。
区间外的行保留在第二桶里(供页面「显示区间外」时列出),带 reasons 说明原因。
"""
hit, outside, bad = [], [], []
for r in rows:
if not r["billable"]:
bad.append(r)
continue
r = dict(r)
if in_period(r, from_date, to_date):
r["due"] = True
r["in_period"] = True
hit.append(r)
else:
r["due"] = False
r["in_period"] = False
r["reasons"] = list(r["reasons"]) + ["账期不在筛选区间内"]
outside.append(r)
return hit, outside, bad
# ---------------------------------------------------------------- 分组 # ---------------------------------------------------------------- 分组
def group_notices(rows): def group_notices(rows):
"""按「出租方 + 租客 + 期间起止」归并成通知书(房间与车位合并一张)。 """按「出租方 + 租客 + 期间起止」归并成通知书(房间与车位合并一张)。
......
...@@ -23,7 +23,9 @@ Python 标准库 http.server,零外部依赖。启动: ...@@ -23,7 +23,9 @@ Python 标准库 http.server,零外部依赖。启动:
GET /api/wecom/targets 可发送的会话 / 授权人 GET /api/wecom/targets 可发送的会话 / 授权人
POST /api/batches 新建一期电费账单(手工录入明细)→ 计费 → 出单 POST /api/batches 新建一期电费账单(手工录入明细)→ 计费 → 出单
POST /api/rent/batches 新建一期房租车位费账单(按台账出单,自动推进账单起始日) POST /api/rent/batches 新建一期房租车位费账单(按台账出单,自动推进账单起始日)
GET /api/rent/preview 房租车位费本期出单预览(?on=基准日&all=1 含未到期) GET /api/rent/preview 房租车位费本期出单预览
(?from=账期起&to=账期止&all=1 含区间外;
旧的 ?on=基准日 仍兼容)
GET /api/batches 批次列表 + 按月留档统计(?kind=elec|rent_parking) GET /api/batches 批次列表 + 按月留档统计(?kind=elec|rent_parking)
GET /api/batches/{id} 批次详情(含明细与单据 + 收款/开票统计) GET /api/batches/{id} 批次详情(含明细与单据 + 收款/开票统计)
DELETE /api/batches/{id} 删除批次(body.rollback=true 时回滚账期推进) DELETE /api/batches/{id} 删除批次(body.rollback=true 时回滚账期推进)
...@@ -288,14 +290,17 @@ class Handler(BaseHTTPRequestHandler): ...@@ -288,14 +290,17 @@ class Handler(BaseHTTPRequestHandler):
"batches": store.list_batches(kind), "batches": store.list_batches(kind),
"months": store.batch_months(kind)}) "months": store.batch_months(kind)})
# ---- 房租车位费:本期出单预览 # ---- 房租车位费:本期出单预览(按账期区间筛选)
if path == "/api/rent/preview": if path == "/api/rent/preview":
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
import datetime as _dt f = (q.get("from") or [""])[0].strip()
on = (q.get("on") or [""])[0] or _dt.date.today().strftime("%Y-%m-%d") t = (q.get("to") or [""])[0].strip()
# 兼容旧参数 ?on=基准日 —— 等价于「账期起始日 ≤ 该日」
if not f and not t:
t = (q.get("on") or [""])[0].strip()
show_all = (q.get("all") or ["0"])[0] in ("1", "true", "yes") show_all = (q.get("all") or ["0"])[0] in ("1", "true", "yes")
return self._send(200, {"ok": True, return self._send(200, {"ok": True,
**service.rent_preview(on, show_all)}) **service.rent_preview(f, t, show_all)})
# ---- 备注栏图片附件 # ---- 备注栏图片附件
if path == "/api/attachments": if path == "/api/attachments":
...@@ -593,6 +598,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -593,6 +598,8 @@ class Handler(BaseHTTPRequestHandler):
username=user["username"], username=user["username"],
advance=body.get("advance") is not False, advance=body.get("advance") is not False,
period_label=body.get("period_label"), period_label=body.get("period_label"),
from_date=body.get("from"),
to_date=body.get("to"),
) )
return self._send(200, {"ok": True, "batch": batch}) return self._send(200, {"ok": True, "batch": batch})
......
...@@ -241,24 +241,26 @@ def ingest_parts(details, period_label, notice_date=None, due_date=None, ...@@ -241,24 +241,26 @@ def ingest_parts(details, period_label, notice_date=None, due_date=None,
# ================================================================ 房租车位费出单 # ================================================================ 房租车位费出单
def rent_preview(on_date=None, show_all=False, cfg=None): def rent_preview(from_date=None, to_date=None, show_all=False, cfg=None):
"""房租车位费的出单预览:台账逐行计费 + 将生成的通知书分组。 """房租车位费的出单预览:台账逐行计费 + 将生成的通知书分组。
on_date 出单基准日:账单起始日 ≤ 该日的行算「到期」 from_date/to_date 账期区间(含端点):行的**账期起始日**落在区间内即命中。
show_all True 时把「未到期」的行也放进可出单列表(供页面「显示未到期」) 两侧可空,空表示该侧不设限。
show_all True 时把「区间外」的可出单行也放进列表(供页面「显示区间外」)
""" """
cfg = cfg or store.load_config() cfg = cfg or store.load_config()
table = tables.load_table(tables.RENT_KEY) table = tables.load_table(tables.RENT_KEY)
rows = RP.ledger_rows(table) rows = RP.ledger_rows(table)
if not rows: if not rows:
raise ValueError("「房租车位费」台账尚未就绪。") raise ValueError("「房租车位费」台账尚未就绪。")
due, later, bad = RP.classify(rows, on_date) hit, outside, bad = RP.classify_period(rows, from_date, to_date)
ok = due + later if show_all else due ok = hit + outside if show_all else hit
docs = RP.group_notices(ok) docs = RP.group_notices(ok)
return { return {
"on": str(on_date or ""), "from": str(from_date or ""),
"to": str(to_date or ""),
"rows": [RP.strip(r) for r in ok], "rows": [RP.strip(r) for r in ok],
"later": [RP.strip(r) for r in later], "later": [RP.strip(r) for r in outside],
"skipped": [RP.strip(r) for r in bad], "skipped": [RP.strip(r) for r in bad],
"docs": [{ "docs": [{
**RP.strip_doc(d), **RP.strip_doc(d),
...@@ -270,12 +272,31 @@ def rent_preview(on_date=None, show_all=False, cfg=None): ...@@ -270,12 +272,31 @@ def rent_preview(on_date=None, show_all=False, cfg=None):
} }
def _period_label(picked, fallback):
"""账期标签:取所选行的账期月份。
同一月份的账期(常见情形)→ 「2026年12月」;跨月 → 「2026年12月~2027年2月」;
一行都没有时退回 fallback 所在月份。
"""
months = sorted({(r["period_from"].year, r["period_from"].month)
for r in picked if r.get("period_from")})
if not months:
return f"{fallback.year}年{fallback.month}月"
a = months[0]
if len(months) == 1:
return f"{a[0]}年{a[1]}月"
b = months[-1]
return f"{a[0]}年{a[1]}月~{b[0]}年{b[1]}月"
def ingest_rent(row_ids=None, on_date=None, notice_date=None, due_date=None, def ingest_rent(row_ids=None, on_date=None, notice_date=None, due_date=None,
username="admin", advance=True, period_label=None): username="admin", advance=True, period_label=None,
from_date=None, to_date=None):
"""房租车位费台账 → 出单 → 存档(并推进账单起始日)。 """房租车位费台账 → 出单 → 存档(并推进账单起始日)。
row_ids 给定时只出这些行(以台账为准重算,不信任前端传来的数字); row_ids 给定时只出这些行(以台账为准重算,不信任前端传来的数字);
不给则按 on_date 取「到期」的全部行。 不给则按账期区间 [from_date, to_date] 取全部命中的可出单行(未给区间时
退回旧的「账单起始日 ≤ on_date」口径,供定时器沿用)。
advance=True 时,出单成功后把每行的「账单起始日」推进一个付款周期, advance=True 时,出单成功后把每行的「账单起始日」推进一个付款周期,
下次再看「本期出单」就只剩新一期。推进是**一次原子写入 + 改动前自动备份**, 下次再看「本期出单」就只剩新一期。推进是**一次原子写入 + 改动前自动备份**,
...@@ -293,6 +314,10 @@ def ingest_rent(row_ids=None, on_date=None, notice_date=None, due_date=None, ...@@ -293,6 +314,10 @@ def ingest_rent(row_ids=None, on_date=None, notice_date=None, due_date=None,
picked = [r for r in rows if r["row_id"] in want and r["billable"]] picked = [r for r in rows if r["row_id"] in want and r["billable"]]
if not picked: if not picked:
raise ValueError("所选行都不满足出单条件(空置 / 每月租金为 0 / 无账单起始日)。") raise ValueError("所选行都不满足出单条件(空置 / 每月租金为 0 / 无账单起始日)。")
elif from_date or to_date:
picked, _outside, _bad = RP.classify_period(rows, from_date, to_date)
if not picked:
raise ValueError("账期区间内没有可出单单元:请检查台账的「账单起始日」与所选区间。")
else: else:
due, _later, _bad = RP.classify(rows, on_date) due, _later, _bad = RP.classify(rows, on_date)
picked = due picked = due
...@@ -307,7 +332,7 @@ def ingest_rent(row_ids=None, on_date=None, notice_date=None, due_date=None, ...@@ -307,7 +332,7 @@ def ingest_rent(row_ids=None, on_date=None, notice_date=None, due_date=None,
dd = _d(due_date) if due_date else month_end(nd) dd = _d(due_date) if due_date else month_end(nd)
notice_s = nd.strftime("%Y-%m-%d") notice_s = nd.strftime("%Y-%m-%d")
due_s = dd.strftime("%Y-%m-%d") due_s = dd.strftime("%Y-%m-%d")
period_label = str(period_label or "").strip() or f"{day.year}年{day.month}月" period_label = str(period_label or "").strip() or _period_label(picked, nd)
batch_id = make_batch_id() batch_id = make_batch_id()
el = cfg.get("elec") or {} el = cfg.get("elec") or {}
...@@ -365,6 +390,8 @@ def ingest_rent(row_ids=None, on_date=None, notice_date=None, due_date=None, ...@@ -365,6 +390,8 @@ def ingest_rent(row_ids=None, on_date=None, notice_date=None, due_date=None,
"meter_date": "", "meter_date": "",
"title": f"{period_label}房租车位费", "title": f"{period_label}房租车位费",
"on_date": day.strftime("%Y-%m-%d"), "on_date": day.strftime("%Y-%m-%d"),
"period_filter_from": str(from_date or ""),
"period_filter_to": str(to_date or ""),
"notice_date": notice_s, "notice_date": notice_s,
"due_date": due_s, "due_date": due_s,
"periods": periods, "periods": periods,
......
...@@ -165,6 +165,72 @@ class TestClassify(unittest.TestCase): ...@@ -165,6 +165,72 @@ class TestClassify(unittest.TestCase):
self.assertEqual(later, []) self.assertEqual(later, [])
# ================================================================ 3b. 账期区间筛选
class TestPeriodFilter(unittest.TestCase):
"""账期区间筛选:命中口径 = 行的账期起始日(AC)落在 [from, to] 内。"""
def setUp(self):
self.rows = [RP._row_of(r) for r in FAKE_LEDGER]
def test_both_ends_means_all_billable(self):
hit, out, bad = RP.classify_period(self.rows, None, None)
self.assertEqual(len(hit) + len(out), 6) # 6 行可出单
self.assertEqual(out, []) # 不限区间 → 无「区间外」
self.assertEqual({r["unit"] for r in bad}, {"301室", "193车位", "306室"})
def test_lower_bound_inclusive(self):
# 账期起 ≥ 2026-11-01 → 只有 302 室(12-01)命中,其余 5 行落在区间外
hit, out, _bad = RP.classify_period(self.rows, "2026-11-01", None)
self.assertEqual({r["unit"] for r in hit}, {"302室"})
self.assertEqual({r["unit"] for r in out},
{"801室", "802室", "190车位", "305室", "129车位"})
def test_upper_bound_inclusive(self):
# 账期起 ≤ 2026-10-31 → 10-01 的 5 行命中,302 室(12-01)落在区间外
hit, out, _bad = RP.classify_period(self.rows, None, "2026-10-31")
self.assertEqual({r["unit"] for r in hit},
{"801室", "802室", "190车位", "305室", "129车位"})
self.assertEqual({r["unit"] for r in out}, {"302室"})
def test_closed_range_hits_boundaries(self):
hit, out, _bad = RP.classify_period(self.rows, "2026-10-01", "2026-12-01")
self.assertEqual(len(hit), 6) # 两端都含
self.assertEqual(out, [])
def test_narrow_range_empty_hit(self):
hit, out, _bad = RP.classify_period(self.rows, "2026-11-01", "2026-11-30")
self.assertEqual(hit, []) # 没有行的账期落在 11 月
self.assertEqual(len(out), 6) # 全部算区间外
def test_outside_rows_carry_reason_and_flag(self):
_hit, out, _bad = RP.classify_period(self.rows, "2027-01-01", None)
self.assertTrue(all(r["in_period"] is False for r in out))
self.assertTrue(all("账期不在筛选区间内" in r["reasons"] for r in out))
def test_hit_rows_flagged_in_period(self):
hit, _out, _bad = RP.classify_period(self.rows, "2026-10-01", None)
self.assertTrue(all(r["in_period"] is True and r["due"] is True for r in hit))
def test_bad_rows_never_in_hit(self):
for f, t in ((None, None), ("2026-01-01", "2027-12-31")):
_hit, _out, bad = RP.classify_period(self.rows, f, t)
self.assertEqual({r["unit"] for r in bad}, {"301室", "193车位", "306室"})
def test_in_period_helper(self):
r = RP._row_of(mkrow("x", bill_start="2026-10-01")) # 账期起 = 2026-10-01
self.assertTrue(RP.in_period(r, "2026-10-01", "2026-10-01"))
self.assertFalse(RP.in_period(r, "2026-10-02", None))
self.assertFalse(RP.in_period(r, None, "2026-09-30"))
self.assertTrue(RP.in_period(r, None, None))
def test_original_classify_untouched(self):
"""账期区间是新增口径,旧的基准日 classify 行为不变(定时器仍用它)。"""
due, later, _bad = RP.classify(self.rows, "2026-10-09")
self.assertEqual({r["unit"] for r in due},
{"801室", "802室", "190车位", "305室", "129车位"})
self.assertEqual({r["unit"] for r in later}, {"302室"})
# ================================================================ 4. 分组 # ================================================================ 4. 分组
class TestGrouping(unittest.TestCase): class TestGrouping(unittest.TestCase):
def test_room_and_park_merge_into_one_notice(self): def test_room_and_park_merge_into_one_notice(self):
...@@ -337,6 +403,75 @@ class TestIngest(FakeBackend): ...@@ -337,6 +403,75 @@ class TestIngest(FakeBackend):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
service.ingest_rent(on_date="2020-01-01", notice_date="2020-01-01") service.ingest_rent(on_date="2020-01-01", notice_date="2020-01-01")
# ---- 账期区间(取代出单基准日)
def test_ingest_by_period_range(self):
b = service.ingest_rent(from_date="2026-10-01", to_date="2026-10-31",
notice_date="2026-10-09", advance=False)
self.assertEqual({d["unit"] for d in b["details"]},
{"801室", "802室", "190车位", "305室", "129车位"})
self.assertEqual(b["period_filter_from"], "2026-10-01")
self.assertEqual(b["period_filter_to"], "2026-10-31")
def test_ingest_period_range_excludes_outside(self):
# 区间只覆盖 12 月 → 只有 302 室命中
b = service.ingest_rent(from_date="2026-12-01", to_date="2026-12-31",
notice_date="2026-10-09", advance=False)
self.assertEqual({d["unit"] for d in b["details"]}, {"302室"})
def test_ingest_period_range_empty_raises(self):
with self.assertRaises(ValueError):
service.ingest_rent(from_date="2020-01-01", to_date="2020-12-31",
notice_date="2020-01-01")
def test_period_label_from_actual_period(self):
# 账期区间落在 10 月 → 标签应取自账期(2026年10月),而不是通知日期
b = service.ingest_rent(from_date="2026-10-01", to_date="2026-10-31",
notice_date="2026-10-09", advance=False)
self.assertEqual(b["period_label"], "2026年10月")
def test_period_label_spans_months(self):
b = service.ingest_rent(from_date="2026-10-01", to_date="2026-12-31",
notice_date="2026-10-09", advance=False)
self.assertEqual(b["period_label"], "2026年10月~2026年12月")
def test_period_label_explicit_wins(self):
b = service.ingest_rent(row_ids=["r3"], notice_date="2026-10-09",
advance=False, period_label="自定义期")
self.assertEqual(b["period_label"], "自定义期")
class TestRentPreview(FakeBackend):
"""service.rent_preview 的账期区间筛选。"""
def test_no_range_lists_all_billable(self):
d = service.rent_preview()
self.assertEqual(len(d["rows"]), 6)
self.assertEqual(d["later"], [])
self.assertEqual(len(d["skipped"]), 3)
def test_lower_bound(self):
d = service.rent_preview(from_date="2026-11-01")
self.assertEqual({r["unit"] for r in d["rows"]}, {"302室"})
self.assertEqual(len(d["later"]), 5)
def test_show_all_includes_outside(self):
d = service.rent_preview(from_date="2026-11-01", show_all=True)
self.assertEqual(len(d["rows"]), 6) # 1 命中 + 5 区间外
self.assertEqual(len(d["later"]), 5)
def test_range_echoed_back(self):
d = service.rent_preview(from_date="2026-10-01", to_date="2026-12-31")
self.assertEqual(d["from"], "2026-10-01")
self.assertEqual(d["to"], "2026-12-31")
self.assertEqual(d["all_count"], 9)
def test_summary_counts(self):
d = service.rent_preview(from_date="2026-10-01", to_date="2026-10-31")
s = d["summary"]
self.assertEqual(s["row_count"], 5)
self.assertEqual(s["skip_count"], 3)
self.assertEqual(s["document_count"], 4)
class TestRollback(FakeBackend): class TestRollback(FakeBackend):
def test_rolls_back_only_untouched_rows(self): def test_rolls_back_only_untouched_rows(self):
......
...@@ -5,10 +5,10 @@ ...@@ -5,10 +5,10 @@
覆盖 2026-10-09 交付、2026-10-11 补数据的「房租车位费」出单链路(页面部分): 覆盖 2026-10-09 交付、2026-10-11 补数据的「房租车位费」出单链路(页面部分):
1. 三张卡齐全(本期出单 / 批次台账 / 批次详情) 1. 三张卡齐全(本期出单 / 批次台账 / 批次详情)
2. 默认值:出单基准日 = 今天、通知日期 = 今天、缴费期限 = 当月月底 2. 默认值:账期起/账期止留空(不限区间)、通知日期 = 今天、缴费期限 = 当月月底
3. 预览渲染:汇总条给出「可出单 N 行 / 通知书 M 份 / 合计 ¥X」,明细表行数一致 3. 预览渲染:汇总条给出「可出单 N 行 / 通知书 M 份 / 合计 ¥X」,明细表行数一致
4. 明细列语义:类型列同时出现「房间」「车位」(**车位参与出单**) 4. 明细列语义:类型列同时出现「房间」「车位」(**车位参与出单**)
5. 「显示未到期」勾上后行数不少于默认 5. 账期区间:收紧「账期起」后行数不增;勾「显示区间外」补回区间外的行
6. 全选/取消:默认全选;取消后「已勾选」计数归零 6. 全选/取消:默认全选;取消后「已勾选」计数归零
7. 只勾 1 行 → 生成出单 → 页面回执 + 批次台账多 1 条 + 批次详情自动展开 7. 只勾 1 行 → 生成出单 → 页面回执 + 批次台账多 1 条 + 批次详情自动展开
8. 出单确实推进了台账「账单起始日」(按付款周期 +N 月) 8. 出单确实推进了台账「账单起始日」(按付款周期 +N 月)
...@@ -115,10 +115,12 @@ def main(): ...@@ -115,10 +115,12 @@ def main():
month_end = js("""() => { const d=new Date(); const z=n=>String(n).padStart(2,'0'); month_end = js("""() => { const d=new Date(); const z=n=>String(n).padStart(2,'0');
const e=new Date(d.getFullYear(), d.getMonth()+1, 0); const e=new Date(d.getFullYear(), d.getMonth()+1, 0);
return e.getFullYear()+'-'+z(e.getMonth()+1)+'-'+z(e.getDate()); }""") return e.getFullYear()+'-'+z(e.getMonth()+1)+'-'+z(e.getDate()); }""")
ck("基准日默认今天", p.input_value("#rp-on") == today, p.input_value("#rp-on")) ck("账期起/账期止默认留空(不限区间)",
p.input_value("#rp-from") == "" and p.input_value("#rp-to") == "",
"%r ~ %r" % (p.input_value("#rp-from"), p.input_value("#rp-to")))
ck("通知日期默认今天", p.input_value("#rp-notice") == today, p.input_value("#rp-notice")) ck("通知日期默认今天", p.input_value("#rp-notice") == today, p.input_value("#rp-notice"))
ck("缴费期限默认当月月底", p.input_value("#rp-due") == month_end, p.input_value("#rp-due")) ck("缴费期限默认当月月底", p.input_value("#rp-due") == month_end, p.input_value("#rp-due"))
ck("「显示未到期」默认不勾", not p.is_checked("#rp-showall")) ck("「显示区间外」默认不勾", not p.is_checked("#rp-showall"))
def pick_rows(): def pick_rows():
"""真正带勾选框的行(空列表时页面只有 1 行占位 tr)。""" """真正带勾选框的行(空列表时页面只有 1 行占位 tr)。"""
...@@ -130,18 +132,10 @@ def main(): ...@@ -130,18 +132,10 @@ def main():
mark: tr.children[11].innerText.trim()}))""") mark: tr.children[11].innerText.trim()}))""")
ac_all = ledger() ac_all = ledger()
n_default = len(pick_rows()) n_default = len(pick_rows()) # 区间留空 → 全部可出单行
acs = sorted([v for v in ac_all.values() if v])
if n_default == 0: if n_default == 0:
# 当天没有到期的可出单行(历史批次已把早期账期推进走了,存量行都排在 print(" ! 台账当前没有可出单行(空置 / 租金为 0 / 无账单起始日)")
# 更晚的月份)→ 把基准日推到**最晚**的「账单起始日」,让存量行全部进入
# 列表,这样才能验证链路;跑完必定删除批次并回滚,不留痕。
acs = sorted([v for v in ac_all.values() if v])
if acs:
p.eval_on_selector("#rp-on",
"(e,d)=>{e.value=d;e.dispatchEvent(new Event('change'))}", acs[-1])
p.wait_for_timeout(2400)
n_default = len(pick_rows())
print(" 基准日推至最晚账单起始日 %s(当天没有到期的行)" % acs[-1])
s_default = p.inner_text("#rpSum") s_default = p.inner_text("#rpSum")
m = re.search(r"可出单\s*(\d+)\s*行\s*/\s*通知书\s*(\d+)\s*份\s*/\s*合计\s*¥([\d,]+)", s_default) m = re.search(r"可出单\s*(\d+)\s*行\s*/\s*通知书\s*(\d+)\s*份\s*/\s*合计\s*¥([\d,]+)", s_default)
...@@ -173,18 +167,38 @@ def main(): ...@@ -173,18 +167,38 @@ def main():
"e=>e.length") == 0) "e=>e.length") == 0)
p.screenshot(path=os.path.join(OUT, "R1-rent-preview.png"), full_page=True) p.screenshot(path=os.path.join(OUT, "R1-rent-preview.png"), full_page=True)
# ============================================ 3. 显示未到期 # ============================================ 3. 账期区间筛选
print("\n3. 「显示未到期」开关") print("\n3. 账期区间筛选 · 显示区间外")
p.check("#rp-showall") if acs and n_default > 0:
p.wait_for_timeout(1800) # 收紧「账期起」到最晚的账单起始日 → 只剩该期之后的行
n_all = len(pick_rows()) latest = acs[-1]
wait_tags = p.eval_on_selector_all("#rpWrap .tag.wait", "e=>e.length") p.eval_on_selector("#rp-from",
ck("勾上后行数不少于默认", n_all >= n_default, "%s → %s 行" % (n_default, n_all)) "(e,d)=>{e.value=d;e.dispatchEvent(new Event('change'))}", latest)
ck("「未到期」标记数 = 勾上后多出来的行数", wait_tags == n_all - n_default, p.wait_for_timeout(2400)
"标记 %d 行 / 多出 %d 行" % (wait_tags, n_all - n_default)) n_hit = len(pick_rows())
p.uncheck("#rp-showall") ck("收紧「账期起」后行数不多于不限区间",
p.wait_for_timeout(1800) n_hit <= n_default, "%s → %s 行" % (n_default, n_hit))
ck("取消后回到默认行数", len(pick_rows()) == n_default) ck("区间内的行都不带「区间外」标记",
p.eval_on_selector_all("#rpWrap .tag.wait", "e=>e.length") == 0)
p.check("#rp-showall")
p.wait_for_timeout(2200)
n_show = len(pick_rows())
tags = p.eval_on_selector_all("#rpWrap .tag.wait", "e=>e.length")
ck("勾「显示区间外」后行数回到全部可出单行",
n_show == n_default, "%s vs %s 行" % (n_show, n_default))
ck("「区间外」标记数 = 多出来的行数",
tags == n_show - n_hit, "标记 %d 行 / 多出 %d 行" % (tags, n_show - n_hit))
p.uncheck("#rp-showall")
p.wait_for_timeout(2200)
ck("取消「显示区间外」后回到区间内行数", len(pick_rows()) == n_hit)
# 清空区间,恢复到默认(不限)
p.eval_on_selector("#rp-from",
"(e)=>{e.value='';e.dispatchEvent(new Event('change'))}")
p.wait_for_timeout(2400)
ck("清空「账期起」后恢复不限区间行数",
len(pick_rows()) == n_default, "%d vs %d" % (len(pick_rows()), n_default))
# ============================================ 4. 勾选 # ============================================ 4. 勾选
print("\n4. 勾选 · 只选 1 行") print("\n4. 勾选 · 只选 1 行")
......
# -*- coding: utf-8 -*-
"""
第七轮需求的端到端验证(2026-10-13)
====================================
对应需求:
「房租车位费 · 本期出单」的筛选条件改为**按账期区间**,可筛出账期落在
区间内的所有数据。
口径(经确认):
* 筛选形式:账期起 / 账期止 两个日期
* 命中判定:行的**账期起始日**(台账「账单起始日」AC)落在区间内(含端点)
* 与旧「出单基准日」的关系:取代(界面上不再有基准日输入框)
做法:
* **自带沙箱**:把 data/ 复制到临时目录并改写路径常量后另起服务,真实 data/ 零改动。
* 真浏览器(Playwright/Chromium)跑:页面筛选 → 出单 → 台账推进 → 删除回滚。
* 后端接口也直连校验(/api/rent/preview?from=&to=)。
运行:python tools/ui_verify8.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 rent_parser as RP # noqa: E402
PORT = int(os.environ.get("RENTOS_VERIFY_PORT", "8902"))
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-verify8-")
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 main():
os.makedirs(OUT, exist_ok=True)
srv, tmp = start_sandbox()
print("沙箱数据目录:%s" % tmp)
print("沙箱服务:%s(真实 data/ 未被触碰)" % BASE)
from playwright.sync_api import sync_playwright
errors = []
created = None
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 ac_state():
st, d = api_call("GET", "/api/tables/rent_parking")
tb = d.get("table") or d
return {r["_id"]: r.get("AC") for r in (tb.get("rows") or [])}
def pick_rows():
return js("""() => [...document.querySelectorAll('#rpWrap tbody tr')]
.filter(tr => tr.querySelector('input[data-pick]'))
.map(tr => ({rid: tr.querySelector('input[data-pick]').dataset.pick,
unit: tr.children[2].innerText.trim(),
period: tr.children[5].innerText.trim(),
mark: tr.children[11].innerText.trim()}))""")
# ---------------- 登录
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)
p.goto(BASE + "/admin.html#/table/rent_parking")
p.wait_for_selector("#rpWrap table", timeout=25000)
p.wait_for_timeout(1500)
# ============================================ 1. 控件形态
print("\n1. 筛选控件已换成账期区间")
ck("「账期起」输入框存在", p.query_selector("#rp-from") is not None)
ck("「账期止」输入框存在", p.query_selector("#rp-to") is not None)
ck("旧的「出单基准日」输入框已移除", p.query_selector("#rp-on") is None)
ck("开关文案为「显示区间外」",
"显示区间外" in p.inner_text("#page-table"), "")
ck("说明文字写明按账期区间筛选",
"账期起始日落在该区间内" in p.inner_text("#page-table"))
# ============================================ 2. 区间为空 = 不限
print("\n2. 区间留空 = 不限区间(列出全部可出单行)")
rows_all = pick_rows()
ck("留空时列出了可出单行", len(rows_all) > 0, "%d 行" % len(rows_all))
s_all = p.inner_text("#rpSum")
ck("汇总条格式正确", "可出单" in s_all and "通知书" in s_all,
s_all.strip()[:110])
# 与后端直连口径对齐(不限区间)
st, pv = api_call("GET", "/api/rent/preview")
ck("接口 /api/rent/preview 正常", st == 200 and pv.get("ok"), str(st))
ck("不限区间时接口返回行数 = 页面行数",
len(pv.get("rows") or []) == len(rows_all),
"%s vs %s" % (len(pv.get("rows") or []), len(rows_all)))
ck("接口回显 from/to 为空",
pv.get("from") == "" and pv.get("to") == "", str((pv.get("from"), pv.get("to"))))
# ============================================ 3. 收紧区间
print("\n3. 账期区间筛选 · 收紧区间后行数收窄")
acs = sorted({r["period"].split(" ~ ")[0] for r in rows_all if r["period"]})
ck("明细行都带账期(期起 ~ 期止)", bool(acs), "%d 个不同期起" % len(acs))
firsts = sorted({r["period"].split(" ~ ")[0] for r in rows_all})
latest = firsts[-1] if firsts else ""
p.eval_on_selector("#rp-from",
"(e,d)=>{e.value=d;e.dispatchEvent(new Event('change'))}", latest)
p.wait_for_timeout(2200)
rows_hit = pick_rows()
ck("收紧「账期起」后行数不多于不限区间",
len(rows_hit) <= len(rows_all), "%d → %d 行" % (len(rows_all), len(rows_hit)))
ck("区间内所有行的账期起始日 ≥ 账期起",
all(r["period"].split(" ~ ")[0] >= latest for r in rows_hit),
"账期起 = %s" % latest)
ck("区间内的行都不带「区间外」标记",
all(r["mark"] == "" for r in rows_hit), str([r["mark"] for r in rows_hit][:4]))
st, pv2 = api_call("GET", "/api/rent/preview?from=" + latest)
ck("接口按 from 过滤后行数 = 页面行数",
len(pv2.get("rows") or []) == len(rows_hit),
"%s vs %s" % (len(pv2.get("rows") or []), len(rows_hit)))
# ============================================ 4. 显示区间外
print("\n4. 「显示区间外」把区间外的行补回来")
p.check("#rp-showall")
p.wait_for_timeout(2200)
rows_show = pick_rows()
tags = [r for r in rows_show if r["mark"] == "区间外"]
ck("勾上后行数回到全部可出单行",
len(rows_show) == len(rows_all), "%s vs %s" % (len(rows_show), len(rows_all)))
ck("「区间外」标记数 = 多出来的行数",
len(tags) == len(rows_show) - len(rows_hit),
"标记 %d / 多出 %d" % (len(tags), len(rows_show) - len(rows_hit)))
ck("区间外行的账期起始日 < 账期起",
all(r["period"].split(" ~ ")[0] < latest for r in tags) if tags else True)
p.uncheck("#rp-showall")
p.wait_for_timeout(2200)
ck("取消后回到区间内行数", len(pick_rows()) == len(rows_hit))
# ============================================ 5. 区间内出单
print("\n5. 按区间出单 → 台账账期推进 → 删除回滚")
before = ac_state()
n_before = len(api_call("GET", "/api/batches?kind=rent_parking")[1].get("batches") or [])
target = rows_hit[0] if rows_hit else None
if not target:
print(" ! 区间内没有可出单行,跳过出单验证")
else:
rid = target["rid"]
print(" 选中:%s(%s)" % (target["unit"], rid))
st, got = api_call("POST", "/api/rent/batches",
{"row_ids": [rid], "from": latest,
"notice_date": "2026-10-13", "due_date": "2026-10-31"})
ck("按区间出单成功", bool(got.get("ok")), got.get("error", ""))
created = (got.get("batch") or {}).get("batch_id")
batch = got.get("batch") or {}
ck("批次记下账期筛选区间",
batch.get("period_filter_from") == latest,
"%r / %r" % (batch.get("period_filter_from"), batch.get("period_filter_to")))
exp_label = "%s年%s月" % (latest[:4], int(latest[5:7]))
ck("批次账期标签取自实际账期(非通知日)",
batch.get("period_label") == exp_label,
"%r(期望 %r)" % (batch.get("period_label"), exp_label))
n_after = len(api_call("GET", "/api/batches?kind=rent_parking")[1].get("batches") or [])
ck("房租批次 +1", n_after == n_before + 1, "%d → %d" % (n_before, n_after))
after = ac_state()
ck("台账「账单起始日」已推进该行",
after.get(rid) and after.get(rid) != before.get(rid),
"%s → %s" % (before.get(rid), after.get(rid)))
ck("其它行未被误改",
all(after[k] == before[k] for k in before if k != rid))
# ---- 删除 + 回滚
st, res = api_call("DELETE", "/api/batches/" + (created or "x"), {"rollback": True})
ck("批次已删除并回滚", bool(res.get("ok")), res.get("error", ""))
created = None
final = ac_state()
diff = [k for k in before if before[k] != final.get(k)]
ck("台账逐格回到跑前", not diff, "差异 %s" % str(diff[:5]))
# ============================================ 6. 兼容旧参数 ?on=
print("\n6. 旧参数 ?on= 仍兼容(等价于账期止)")
st, pv3 = api_call("GET", "/api/rent/preview?on=2027-12-31")
ck("?on= 可用", st == 200 and pv3.get("ok"), str(st))
st, pv4 = api_call("GET", "/api/rent/preview?to=2027-12-31")
ck("?on= 与 ?to= 结果一致",
len(pv3.get("rows") or []) == len(pv4.get("rows") or []),
"%s vs %s" % (len(pv3.get("rows") or []), len(pv4.get("rows") or [])))
p.screenshot(path=os.path.join(OUT, "round7_period_filter.png"), full_page=True)
print(" 截图 → output/ui_shots/round7_period_filter.png")
print("\n7. 页面无 JS 报错")
ck("无 console / pageerror", not errors, str(errors[:2]))
b.close()
finally:
srv.shutdown()
srv.server_close()
shutil.rmtree(tmp, ignore_errors=True)
print("\n" + "=" * 62)
print("通过 %d 项 / 失败 %d 项" % (len(OK), len(FAIL)))
for f in FAIL:
print(" FAIL:", f)
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
...@@ -199,24 +199,27 @@ ...@@ -199,24 +199,27 @@
<!-- ==================== 房租车位费 ==================== --> <!-- ==================== 房租车位费 ==================== -->
<section class="page" id="page-table"> <section class="page" id="page-table">
<div class="card"> <div class="card">
<h2>本期出单 <span class="hint">按台账「账单起始日」出《房租车位费收费通知书》</span></h2> <h2>本期出单 <span class="hint">按「账期区间」筛选台账,出《房租车位费收费通知书》</span></h2>
<div class="desc"> <div class="desc">
计费口径:<b>房屋租赁费 = 每月租金 × 付款周期</b>;<b>物业费 = 每月物业费 × 付款周期</b> 计费口径:<b>房屋租赁费 = 每月租金 × 付款周期</b>;<b>物业费 = 每月物业费 × 付款周期</b>
(8F/11F 不收物业费,台账里那列为空);单元号含「车位」的行出<b>车位租赁费</b>。 (8F/11F 不收物业费,台账里那列为空);单元号含「车位」的行出<b>车位租赁费</b>。
账期 = 从台账「账单起始日」起 <b>付款周期</b> 个自然月。 账期 = 从台账「账单起始日」起 <b>付款周期</b> 个自然月。
同一租客、同一期间的<b>房间与车位合并到一张通知书</b>(历史口径)。 同一租客、同一期间的<b>房间与车位合并到一张通知书</b>(历史口径)。
<br><b>筛选</b>:按「账期起 / 账期止」列出<b>账期起始日落在该区间内</b>的可出单行;两个框都留空即不限区间。
勾上「显示区间外」可把区间外的行也列出来补单。
<br>出单成功后,每行的<b>账单起始日会自动推进一个付款周期</b>,所以这些单元不会再出现在「本期出单」里; <br>出单成功后,每行的<b>账单起始日会自动推进一个付款周期</b>,所以这些单元不会再出现在「本期出单」里;
推进前会自动备份台账,删除批次时可一键回滚。空置、每月租金为 0、没填账单起始日的行一律不出单。 推进前会自动备份台账,删除批次时可一键回滚。空置、每月租金为 0、没填账单起始日的行一律不出单。
</div> </div>
<div class="row" style="align-items:flex-end"> <div class="row" style="align-items:flex-end">
<div style="flex:0 0 160px"><label>出单基准日(只看已到期)</label><input type="date" id="rp-on"></div> <div style="flex:0 0 150px"><label>账期起(起始日 ≥)</label><input type="date" id="rp-from"></div>
<div style="flex:0 0 150px"><label>账期止(起始日 ≤)</label><input type="date" id="rp-to"></div>
<div style="flex:0 0 150px"><label>通知日期</label><input type="date" id="rp-notice"></div> <div style="flex:0 0 150px"><label>通知日期</label><input type="date" id="rp-notice"></div>
<div style="flex:0 0 150px"><label>缴费期限</label><input type="date" id="rp-due"></div> <div style="flex:0 0 150px"><label>缴费期限</label><input type="date" id="rp-due"></div>
<div style="flex:0 0 auto"> <div style="flex:0 0 auto">
<label>&nbsp;</label> <label>&nbsp;</label>
<label class="chk lock" title="把「未到账单起始日」的行也列出来,方便补单"> <label class="chk lock" title="把「账期不在所选区间」的可出单行也列出来,方便补单">
<input type="checkbox" id="rp-showall"> 显示未到期 <input type="checkbox" id="rp-showall"> 显示区间外
</label> </label>
</div> </div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-rp-refresh">刷新预览</button></div> <div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-rp-refresh">刷新预览</button></div>
......
...@@ -1278,11 +1278,12 @@ function monthEndStr() { ...@@ -1278,11 +1278,12 @@ function monthEndStr() {
async function loadRentPreview() { async function loadRentPreview() {
const box = $('#rpWrap'); const box = $('#rpWrap');
if (!box) return; if (!box) return;
const on = $('#rp-on').value || todayStr(); const from = $('#rp-from').value || '';
const to = $('#rp-to').value || '';
const all = $('#rp-showall').checked ? 1 : 0; const all = $('#rp-showall').checked ? 1 : 0;
box.innerHTML = '<div class="empty">加载中…</div>'; box.innerHTML = '<div class="empty">加载中…</div>';
try { try {
const d = await api(`/api/rent/preview?on=${encodeURIComponent(on)}&all=${all}`); const d = await api(`/api/rent/preview?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&all=${all}`);
RP = d; RP = d;
RP_PICK = new Set((d.rows || []).map(r => r.row_id)); // 默认全选 RP_PICK = new Set((d.rows || []).map(r => r.row_id)); // 默认全选
renderRentPreview(); renderRentPreview();
...@@ -1321,14 +1322,12 @@ function renderRentPreview() { ...@@ -1321,14 +1322,12 @@ function renderRentPreview() {
</tbody></table></div>` </tbody></table></div>`
: '<div class="empty">当前没有可出单的单元</div>'; : '<div class="empty">当前没有可出单的单元</div>';
// ---- 台账明细行(可勾选) // ---- 台账明细行(可勾选);区间外的行在末列标「区间外」
const later = {}; // 未到期的行(勾选框禁用)
(RP.later || []).forEach(r => { later[r.row_id] = true; });
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.due === false ? '<span class="tag wait">未到期</span>' : ''; const tag = (r.in_period === false || r.due === false)
? '<span class="tag wait">区间外</span>' : '';
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>
...@@ -1344,7 +1343,7 @@ function renderRentPreview() { ...@@ -1344,7 +1343,7 @@ function renderRentPreview() {
<td>${tag}</td> <td>${tag}</td>
</tr>`; </tr>`;
}).join('') }).join('')
: '<tr><td colspan="12" class="empty">没有可出单的行</td></tr>'; : '<tr><td colspan="12" class="empty">没有符合账期区间的行</td></tr>';
$('#rpWrap').innerHTML = `<table> $('#rpWrap').innerHTML = `<table>
<thead><tr> <thead><tr>
...@@ -1373,7 +1372,8 @@ function renderRentPreview() { ...@@ -1373,7 +1372,8 @@ function renderRentPreview() {
function strOf(v) { return v === null || v === undefined ? '' : String(v); } function strOf(v) { return v === null || v === undefined ? '' : String(v); }
$('#rp-on').addEventListener('change', loadRentPreview); $('#rp-from').addEventListener('change', loadRentPreview);
$('#rp-to').addEventListener('change', loadRentPreview);
$('#rp-showall').addEventListener('change', loadRentPreview); $('#rp-showall').addEventListener('change', loadRentPreview);
$('#btn-rp-refresh').addEventListener('click', loadRentPreview); $('#btn-rp-refresh').addEventListener('click', loadRentPreview);
...@@ -1383,11 +1383,13 @@ $('#btn-rp-submit').addEventListener('click', async () => { ...@@ -1383,11 +1383,13 @@ $('#btn-rp-submit').addEventListener('click', async () => {
if (!ids.length) return toast('请至少勾选一行', 'err'); if (!ids.length) return toast('请至少勾选一行', 'err');
const un = ids.filter(id => (RP.later || []).some(r => r.row_id === id)).length; const un = ids.filter(id => (RP.later || []).some(r => r.row_id === id)).length;
const pf = $('#rp-from').value || '', pt = $('#rp-to').value || '';
const ok = await confirmBox({ const ok = await confirmBox({
title: '确认出单(房租车位费)', title: '确认出单(房租车位费)',
rows: [ rows: [
{ k: '账期', v: `通知日期 ${esc($('#rp-notice').value || '—')} | 缴费期限 ${esc($('#rp-due').value || '—')}` }, { k: '账期筛选', v: (pf || pt) ? `${pf || '不限'} ~ ${pt || '不限'}` : '不限区间(全部可出单行)' },
{ k: '行数', v: `<b>${ids.length}</b> 行(其中未到期 ${un} 行)` }, { k: '单据日期', v: `通知日期 ${esc($('#rp-notice').value || '—')} | 缴费期限 ${esc($('#rp-due').value || '—')}` },
{ k: '行数', v: `<b>${ids.length}</b> 行(其中区间外 ${un} 行)` },
{ k: '合并', v: '同一「出租方 + 租客 + 期间」合并成一张通知书' }, { k: '合并', v: '同一「出租方 + 租客 + 期间」合并成一张通知书' },
{ k: '账期推进', v: '出单成功后台账「账单起始日」自动 +1 个付款周期(改动前自动备份,可回滚)' }, { k: '账期推进', v: '出单成功后台账「账单起始日」自动 +1 个付款周期(改动前自动备份,可回滚)' },
], ],
...@@ -1404,7 +1406,8 @@ $('#btn-rp-submit').addEventListener('click', async () => { ...@@ -1404,7 +1406,8 @@ $('#btn-rp-submit').addEventListener('click', async () => {
method: 'POST', method: 'POST',
body: { body: {
row_ids: ids, row_ids: ids,
on_date: $('#rp-on').value, from: pf,
to: pt,
notice_date: $('#rp-notice').value, notice_date: $('#rp-notice').value,
due_date: $('#rp-due').value, due_date: $('#rp-due').value,
advance: true, advance: true,
...@@ -2665,8 +2668,10 @@ function viewerSuffix(name) { ...@@ -2665,8 +2668,10 @@ function viewerSuffix(name) {
loadUnits(); loadUnits();
loadElecDefaults(); loadElecDefaults();
loadDirectory(); // 名册候选(台账 / 电费录入的联想都靠它) loadDirectory(); // 名册候选(台账 / 电费录入的联想都靠它)
// 房租车位费出单的默认日期:基准日与通知日期 = 今天,缴费期限 = 当月最后一天 // 房租车位费出单的默认日期:账期区间留空(不限,列出全部可出单行),
$('#rp-on').value = todayStr(); // 通知日期 = 今天,缴费期限 = 当月最后一天
$('#rp-from').value = '';
$('#rp-to').value = '';
$('#rp-notice').value = todayStr(); $('#rp-notice').value = todayStr();
$('#rp-due').value = monthEndStr(); $('#rp-due').value = monthEndStr();
const h = hashOf(); const h = hashOf();
......
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