Commit 3aa94d82 authored by wenwen.tang's avatar wenwen.tang 😕

feat(电费): 核对改按行粒度 + 台账按账期汇总 + 楼层总表登记

一、核对粒度:房间 → 行
核对从「按房间」改为「按行」,行键 = `单元|类目`(如 `302室|照明`),
于是可以只核照明、或只核空调,也可只核一部分行。

- service: `blank_verify()` 增 rows/scope;新增 `_row_keys()` / `_checked_rows()`
  (旧 `units` 自动按整室展开,向后兼容)
- `verify_summary()` 的 `ok` 由「全勾完」改为「已点过确认核对」,
  新增 `partial`(done 且仍有未勾行)
- `set_verify()` 确认时把勾选快照存进 `verify.scope`;之后进度保存若勾选集合
  少于 scope 就**自动撤销确认**,防止改完数据还挂着「已核对」
- 归一化时 `units` 置空只留 `rows`,否则行级取消勾选会被派生 units 抵消
- `period_summary()` 改行并集:一行算已核对 ⇔ 含它的每张批次都勾了它

二、批次台账:按账期汇总、入口收敛
- 列表按账期(月)汇总成一行,默认折叠,点汇总头展开该账期批次
- 移除列表里的「重新编辑 / 核对」,统一进批次详情
- 详情页展示是否核对状态;部分核对发送前二次提醒

三、核对模式的列显示
- 12 个可显隐列 + 持久化 `localStorage['rentos_vcols']`;「核对」列不可隐藏
- 修:`[hidden]` 被 `.colpick-menu{display:flex}` 顶掉导致菜单收不回来
- 修:`bindVCols()` 每次重渲染往 document 挂新监听导致无限累积
- 开关状态提到模块级 `VCOLS_OPEN`,支持再点按钮/×/点外部/Esc 四种收起方式

四、新增:楼层电费总表(3F/6F/7F)
- `rentos/masters.py`:每层拆 公共/套内/空调总,按 用量×电价×系数 算金额
- 口径:**只记账,不出单、不发送**,随批次存档并支持导出
- 接入 `GET /api/masters`、`ingest_elec(masters=…)`、`reingest_elec(masters=…)`
parent fce1db5e
# -*- coding: utf-8 -*-
"""
电费「总表」登记(3F / 6F / 7F)
================================
逐室电费是「一户一表」,除此之外还要按楼层登记一张**总表**:
每个楼层拆成 公共 / 套内 / 空调总 三个类目,各自填上期、本期抄表数,
系统按 **用量 = 本期 − 上期**、**金额 = 用量 × 电价 × 系数** 算出金额。
⚠️ 口径(2026-10-13 巴特菲):
总表**只记账,不出单、不发送** —— 它不参与《电费收费通知书》的生成,
只随批次一起存档、并支持导出 xlsx(批次详情页也能看)。
系数默认值(取自巴特菲给的样表):
公共 12.25 · 套内 1.00 · 空调总 12.25
"""
from . import elec_parser as P
# 楼层(顺序即页面与导出的行序)
FLOORS = ("3F", "6F", "7F")
# 类目 + 默认系数
ITEMS = (
{"label": "公共", "factor": 12.25},
{"label": "套内", "factor": 1.00},
{"label": "空调总", "factor": 12.25},
)
ITEMS_BY_LABEL = {i["label"]: i for i in ITEMS}
# 类目别名(录入页 / 老数据里可能写成别的叫法)
LABEL_ALIAS = {
"公共": "公共", "公摊": "公共", "公共用电": "公共", "公共电费": "公共",
"套内": "套内", "室内": "套内", "套内用电": "套内", "套内电费": "套内",
"空调总": "空调总", "空调总表": "空调总", "空调": "空调总", "空调总用电": "空调总",
}
# 楼层别名(3 → 3F / 3楼 → 3F / 3层 → 3F)
_FLOOR_SUFFIX = ("层", "楼", "楼总表", "总表", "F", "f")
def spec():
"""供前端渲染的规格:楼层、类目与默认系数。"""
return {
"floors": list(FLOORS),
"items": [dict(i) for i in ITEMS],
"formula": "用量 = 本期 − 上期;金额 = 用量 × 电价 × 系数",
"note": "总表只记账:不出通知书、不发送,随批次导出。",
}
def _num(v):
return P._num(v)
def floor_of(v):
"""楼层名归一:3 / 3楼 / 3层 / 3F → 3F;不在名单内返回空串。"""
s = str(v if v is not None else "").strip().replace(" ", "")
if not s:
return ""
for suf in _FLOOR_SUFFIX:
if s.endswith(suf) and len(s) > len(suf):
s = s[: -len(suf)]
break
s = s.upper()
if s in FLOORS:
return s
if s.isdigit():
s = s + "F"
return s if s in FLOORS else ""
def label_of(v):
"""类目名归一:公摊 → 公共、空调总表 → 空调总 …;不认识的返回空串。"""
s = str(v if v is not None else "").strip().replace(" ", "")
if not s:
return ""
return LABEL_ALIAS.get(s, s if s in ITEMS_BY_LABEL else "")
def blank_rows():
"""空的总表骨架(楼层 × 类目),供页面初始化。"""
out = []
for f in FLOORS:
for it in ITEMS:
out.append({"floor": f, "label": it["label"],
"prev": None, "cur": None, "qty": None,
"price": None, "factor": it["factor"],
"amount": None, "pending": False})
return out
def _given(r):
"""这一行有没有填过东西(三个数全空就当没填)。"""
return any(_num(r.get(k)) is not None for k in ("prev", "cur", "qty", "amount"))
def build(submitted, price=None):
"""录入的总表行 → 计费后的总表明细(只保留真正填过的行,按 楼层/类目 排序)。
submitted = [{"floor","label","prev","cur","qty","price","factor","amount"}]
price 默认取配置里的电价(el.price)。
"""
if price is None:
from . import config as C
price = C.DEFAULT_CONFIG["elec"]["price"]
given = {}
for r in submitted or []:
if not isinstance(r, dict):
continue
f, lb = floor_of(r.get("floor")), label_of(r.get("label") or r.get("item"))
if not f or not lb:
continue
given[(f, lb)] = r
out = []
for f in FLOORS:
for it in ITEMS:
r = given.get((f, it["label"]))
if r is None or not _given(r):
continue
fac = _num(r.get("factor"))
row = P.compute_row(
it["label"], prev=_num(r.get("prev")), cur=_num(r.get("cur")),
qty_in=_num(r.get("qty")), price=_num(r.get("price")) or price,
factor=it["factor"] if fac is None else fac,
amount_in=_num(r.get("amount")),
)
if row is None:
continue
out.append({"floor": f, "label": it["label"], **row})
return out
def totals(rows):
"""总表汇总:行数 / 用量 / 金额 / 未填本期行数。"""
rows = rows or []
ok = [r for r in rows if not r.get("pending")]
return {
"rows": len(rows),
"pending": len(rows) - len(ok),
"qty": round(sum(r.get("qty") or 0 for r in ok), 4),
"amount": round(sum(r.get("amount") or 0 for r in ok), 2),
}
This diff is collapsed.
This diff is collapsed.
......@@ -118,6 +118,26 @@ def load_batch(batch_id):
return _read(batch_path(batch_id))
def load_all_batches(kind=None):
"""按上传时间倒序返回**完整**批次(含明细 / 单据),供账期分组等统计使用。"""
with _LOCK:
out = []
if not os.path.isdir(C.BATCH_DIR):
return out
for fn in os.listdir(C.BATCH_DIR):
if not fn.endswith(".json"):
continue
b = _read(os.path.join(C.BATCH_DIR, fn))
if not b:
continue
k = b.get("kind") or "elec"
if kind and k != kind:
continue
out.append(b)
out.sort(key=lambda x: x.get("created_at") or "", reverse=True)
return out
def list_batches(kind=None):
"""按上传时间倒序列出全部批次(只返回摘要,不含明细)。
......@@ -138,6 +158,25 @@ def list_batches(kind=None):
continue
tot = b.get("totals") or {}
docs = b.get("documents") or []
rooms = {str(d.get("unit")) for d in (b.get("details") or [])
if str(d.get("unit") or "").strip()}
v = b.get("verify") if isinstance(b.get("verify"), dict) else {}
v_done = bool(v.get("done"))
# 核对粒度是「行」(单元|类目);旧数据的 units 按整室展开
rkeys, seen = [], set()
for d in (b.get("details") or []):
u = str(d.get("unit") or "").strip()
if not u:
continue
m = str(d.get("meter") or "").strip() or "照明"
rk = u + "|" + m
if rk not in seen:
seen.add(rk)
rkeys.append(rk)
rowset = {str(x).strip() for x in (v.get("rows") or [])}
checked_rows = {k for k in rkeys if k in rowset}
for u in {str(x).strip() for x in (v.get("units") or [])}:
checked_rows |= {k for k in rkeys if k.split("|", 1)[0] == u}
out.append({
"batch_id": b.get("batch_id"),
"kind": k,
......@@ -146,7 +185,7 @@ def list_batches(kind=None):
"period_label": b.get("period_label"),
"meter_date": b.get("meter_date"),
"tenant_count": len(docs),
"unit_count": len({d["unit"] for d in b.get("details", [])}),
"unit_count": len(rooms),
# document_amount = 实际出单合计;table_amount = 明细全部合计
"total_amount": tot.get("document_amount", tot.get("total_amount", 0)),
"table_amount": tot.get("total_amount", 0),
......@@ -154,6 +193,19 @@ def list_batches(kind=None):
"due_date": b.get("due_date"),
"notice_date": b.get("notice_date"),
"send_state": b.get("send_state", "未发送"),
# 核对(电费批次):按行勾选;确认过就能发送(允许部分核对)
"verify_rows": len(rkeys),
"verify_rows_done": len(checked_rows),
"verify_done": v_done,
"verify_ok": v_done,
"verify_partial": bool(v_done and len(checked_rows) < len(rkeys)),
"verify_at": v.get("at") or "",
"verify_by": v.get("by") or "",
"reingest_count": int(b.get("reingest_count") or 0),
"reingest_at": b.get("reingest_at") or "",
# 总表(3F/6F/7F):只记账不出单
"master_rows": len(b.get("masters") or []),
"master_amount": round(tot.get("master_amount") or 0, 2),
})
out.sort(key=lambda x: x.get("created_at") or "", reverse=True)
return out
......
......@@ -122,8 +122,9 @@
<!-- ==================== 电费通知单 ==================== -->
<section class="page" id="page-elec">
<div class="card" data-cap="elec">
<div class="card" data-cap="elec" id="elecCard">
<h2>录入本期电费 <span class="hint">逐行手工录入,系统按规则计费并出单</span></h2>
<div id="eModeBar"></div>
<div class="desc">
计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>;
<b>空调 = 用量 × 电价 × 12.25</b>、<b>照明 = 用量 × 电价</b>。
......@@ -135,6 +136,12 @@
也可以从
<a href="#/companies" style="color:var(--brand)">公司管理</a>
维护的名册里选,留空则按台账取值。
<br>下面的<b>楼层总表(3F / 6F / 7F)</b>与本期电费一并录入,按「公共 / 套内 / 空调总」分行,
<b>只记账:不出通知书、不发送</b>,随批次留档并可导出。
<br>已生成的批次在下面「批次台账」里<b>按账期(月)汇总</b>,点汇总行可展开该账期的批次;
<b>核对</b>与<b>重新编辑</b>统一在<b>批次详情</b>里进行。
核对按<b>账期</b>统一做,可逐行勾选(照明 / 空调分别核对,也允许只核一部分),
<b>确认核对后才能发送账单</b>。
</div>
<div class="row">
......@@ -145,7 +152,7 @@
</div>
<div class="spacer"></div>
<div class="row" style="align-items:flex-end">
<div class="row" style="align-items:flex-end" id="eActions">
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-e-row">+ 添加一行</button></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-e-copy" title="重新从最近一期批次带入上期抄表数等信息">重新带入上期</button></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-e-clear">清空</button></div>
......@@ -157,22 +164,50 @@
<div class="grid-wrap" id="eWrap">
<table class="edgrid" id="eGrid">
<thead><tr>
<th style="width:120px">房间号 *</th>
<th style="width:120px" data-col="unit">房间号 *</th>
<th style="width:110px" data-col="meter">明细类目</th>
<th style="width:110px" data-col="prev">上期抄表数</th>
<th style="width:110px" data-col="cur">本期抄表数</th>
<th style="width:100px" data-col="qty">用量</th>
<th style="width:90px" data-col="price">电价</th>
<th style="width:80px" data-col="factor">系数</th>
<th style="width:190px" data-col="company">单位名称</th>
<th style="width:110px" data-col="owner">出租方</th>
<th style="width:200px" data-col="note">备注 / 图片</th>
<th style="width:80px" data-col="amt">金额</th>
<th class="act" style="width:56px" data-col="act"></th>
<th style="width:76px" data-col="vcol">核对</th>
</tr></thead>
<tbody id="eBody"></tbody>
</table>
</div>
<div id="masterBlock">
<div class="spacer"></div>
<div class="row" style="align-items:flex-end">
<div style="flex:1 1 auto">
<label>楼层总表(3F / 6F / 7F)
<span class="hint">与本期电费<b>一并录入</b>:用量 = 本期 − 上期、金额 = 用量 × 电价 × 系数;
<b>只记账 —— 不出通知书、不发送</b>,随批次留档并可导出 xlsx</span></label>
</div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-m-clear">清空总表</button></div>
<div style="flex:0 0 auto" class="muted" id="mSum"></div>
</div>
<div class="grid-wrap">
<table class="edgrid" id="mGrid">
<thead><tr>
<th style="width:80px">楼层</th>
<th style="width:110px">明细类目</th>
<th style="width:110px">上期抄表数</th>
<th style="width:110px">本期抄表数</th>
<th style="width:100px">用量</th>
<th style="width:120px">上期抄表数</th>
<th style="width:120px">本期抄表数</th>
<th style="width:110px">用量</th>
<th style="width:90px">电价</th>
<th style="width:80px">系数</th>
<th style="width:190px">单位名称</th>
<th style="width:110px">出租方</th>
<th style="width:200px">备注 / 图片</th>
<th style="width:80px">金额</th>
<th class="act" style="width:56px"></th>
<th style="width:90px">系数</th>
<th style="width:120px">金额</th>
</tr></thead>
<tbody id="eBody"></tbody>
<tbody id="mBody"></tbody>
</table>
</div>
</div>
<div id="uploadResult"></div>
</div>
......@@ -187,6 +222,8 @@
<h2>批次详情 <span class="hint" id="detailTitle"></span></h2>
<div class="row" style="margin-bottom:12px">
<div style="flex:0 0 auto"><button id="btn-send-all" class="primary" data-cap="elec">发送全部待发</button></div>
<div style="flex:0 0 auto"><button id="btn-detail-verify" data-cap="elec">核对</button></div>
<div style="flex:0 0 auto"><button id="btn-detail-reingest" data-cap="elec">重新编辑</button></div>
<div style="flex:0 0 auto"><button id="btn-rerender" data-cap="elec">按最新配置重出单据</button></div>
<div style="flex:0 0 auto"><a id="link-zip"><button>打包下载(zip)</button></a></div>
<div style="flex:0 0 auto"><a id="link-export"><button>导出批次数据(xlsx)</button></a></div>
......@@ -369,9 +406,10 @@
<!-- ==================== 设置 ==================== -->
<section class="page" id="page-settings">
<div class="card">
<h2>定时发送设置 <span class="hint">两张账单各自独立排期</span></h2>
<h2>定时发送设置 <span class="hint">房租车位费按排期自动发送;电费通知单全手动,不在此配置</span></h2>
<div class="desc">
触发日 = 该账单的<b>缴费期限</b> − <b>提前天数</b>,当天到达 <b>发送时点</b> 后自动把「待发送」通知书推送到企业微信。
<b>房租车位费</b>:触发日 = 该账单的<b>缴费期限</b> − <b>提前天数</b>,
当天到达 <b>发送时点</b> 后自动把「待发送」通知书推送到企业微信。
处于<b>免租期</b>内的单元按下面各自的开关决定是否跳过不通知。
</div>
<div class="row" style="align-items:flex-end">
......@@ -433,15 +471,6 @@
<div id="wecomHint" class="muted" style="font-size:12px"></div>
</div>
<div class="card">
<h2>出租方 · 汇款公司 <span class="hint">已迁到「公司管理」</span></h2>
<div class="desc">
名单的录入、改名、删除统一在
<a href="#/companies" data-nav="companies" style="color:var(--brand)">公司管理</a> 里维护,
这里不再重复一份。通知书底部「汇款信息」三行仍取自那份名单。
</div>
</div>
<div class="card" data-ro>
<h2>修改密码 <span class="hint">仅改管理员自己的登录密码</span></h2>
<div class="row">
......
This diff is collapsed.
......@@ -460,6 +460,19 @@ body:not(.cap-elec) [data-cap="elec"] { display: none !important; }
}
.chk input { width: auto; margin: 0; }
/* ---- 电费录入页:核对模式(逐室勾选 ✔) ---- */
table.edgrid td.vcol { text-align: center; }
table.edgrid td.vcol .vchk { display: inline-flex; justify-content: center; height: auto; }
table.edgrid td.vcol .vchk input { transform: scale(1.25); cursor: pointer; }
table.edgrid tr.vok { background: #f0fdf4; }
table.edgrid tr.vok td.vcol .vchk { color: var(--ok); font-weight: 700; }
#eModeBar .banner { margin-bottom: 12px; }
/* ---- 批次台账:操作列与「手动发送」计划行 ---- */
td.batch-act { white-space: nowrap; }
td.batch-act .sm { margin-right: 4px; }
tr.manual td { background: #fbfbfd; }
.grid-wrap {
overflow: auto;
max-height: 62vh;
......@@ -653,6 +666,48 @@ td.e-note > input:focus { border-color: var(--brand); outline: none; }
background: #fff8e6; font-weight: 600; border-top: 1px solid #f0d79a;
}
/* 账期分组头:批次台账按「账期」汇总、账期核对模式按「批次」分组 */
.table-wrap tr.grp td, table.edgrid tbody tr.grp td {
background: #eef3fb; color: #1f3f6b; font-weight: 600;
border-top: 1px solid #d8e2f0; border-bottom: 1px solid #d8e2f0;
white-space: normal; overflow: visible;
}
tr.grp .grp-title { font-weight: 700; margin-right: 6px; }
tr.grp .grp-act { float: right; margin-left: 10px; }
tr.grp .grp-toggle { display: inline-block; width: 14px; color: var(--text-2); }
tr.grp .tag { margin-left: 4px; }
tr.grp .mono-sm { color: var(--text-2); font-weight: 400; }
/* 账期汇总分组默认折叠:只显示汇总行,点一下才展开该账期的批次 */
tbody.pgrp.collapsed tr.prow { display: none; }
tbody.pgrp tr.grp { cursor: pointer; }
/* 核对模式:列显示选择器 */
.colpick { position: relative; display: inline-block; vertical-align: middle; }
.colpick-menu {
position: absolute; top: 100%; right: 0; z-index: 40; margin-top: 4px;
min-width: 176px; max-height: 60vh; overflow: auto; padding: 8px 10px;
background: var(--panel);
border: 1px solid var(--line); border-radius: 8px;
box-shadow: 0 8px 24px rgba(15, 23, 42, .18);
display: flex; flex-direction: column; gap: 4px;
}
/* 关键:.colpick-menu 自带 display:flex,会盖掉 [hidden] 的 display:none,
必须在这里显式收起,否则菜单永远挂在页面上挡视线 */
.colpick-menu[hidden] { display: none; }
/* 按钮靠近视口底部时改为向上弹,别被裁掉 */
.colpick-menu.up { top: auto; bottom: 100%; margin: 0 0 4px 0; }
.colpick-menu .colpick-title {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; color: var(--text-2); margin-bottom: 2px; font-weight: 600;
}
.colpick-menu .colpick-title .x {
cursor: pointer; border: 0; background: none; padding: 0 2px;
color: var(--text-2); font-size: 15px; line-height: 1;
}
.colpick-menu .colpick-title .x:hover { color: var(--text); }
.colpick-menu label.chk { font-size: 12px; }
.colpick-act { display: flex; gap: 6px; margin-top: 6px; }
/* 批次详情:收款日期 / 是否开票(房租车位费) */
td.rc-cell { white-space: nowrap; vertical-align: middle; }
td.rc-cell .chk { font-size: 12px; margin: 0; }
......
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