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),
}
...@@ -25,17 +25,24 @@ Python 标准库 http.server,零外部依赖。启动: ...@@ -25,17 +25,24 @@ Python 标准库 http.server,零外部依赖。启动:
GET /api/wecom/status 企微连通性自检 GET /api/wecom/status 企微连通性自检
GET /api/wecom/targets 可发送的会话 / 授权人 GET /api/wecom/targets 可发送的会话 / 授权人
POST /api/batches 新建一期电费账单(手工录入明细)→ 计费 → 出单 POST /api/batches 新建一期电费账单(手工录入明细)→ 计费 → 出单
body.masters = 总表(3F/6F/7F)录入行,只记账不出单
GET /api/masters 电费总表的录入规格(楼层 / 类目 / 默认系数 / 默认电价)
POST /api/rent/batches 新建一期房租车位费账单(按台账出单,自动推进账单起始日) POST /api/rent/batches 新建一期房租车位费账单(按台账出单,自动推进账单起始日)
GET /api/rent/preview 房租车位费本期出单预览 GET /api/rent/preview 房租车位费本期出单预览
(?from=账期起&to=账期止&all=1 含区间外; (?from=账期起&to=账期止&all=1 含区间外;
旧的 ?on=基准日 仍兼容) 旧的 ?on=基准日 仍兼容)
GET /api/batches 批次列表 + 按月留档统计(?kind=elec|rent_parking) GET /api/batches 批次列表 + 按月留档统计(?kind=elec|rent_parking)
GET /api/batches/{id} 批次详情(含明细与单据 + 收款/开票统计) GET /api/periods 账期核对概况(批次台账按账期分组;?kind=elec)
POST /api/periods/verify 账期级核对:{period, units:[室...], done:true}
GET /api/batches/{id} 批次详情(含明细与单据 + 收款/开票统计 + 核对进度)
PUT /api/batches/{id} 重新编辑(重编)该电费批次:覆盖明细并重出单据
DELETE /api/batches/{id} 删除批次(body.rollback=true 时回滚账期推进) DELETE /api/batches/{id} 删除批次(body.rollback=true 时回滚账期推进)
POST /api/batches/{id}/rerender 按最新配置重出单据 POST /api/batches/{id}/rerender 按最新配置重出单据
POST /api/batches/{id}/verify 核对:{units:[室...]} 存进度、{done:true} 确认核对
POST /api/batches/{id}/receipt 登记收款日期 / 是否开票(勾上即记开票日期) POST /api/batches/{id}/receipt 登记收款日期 / 是否开票(勾上即记开票日期)
房租批次会同步台账「最近收款月份」 房租批次会同步台账「最近收款月份」
POST /api/batches/{id}/send 手动发送(可指定单据序号) POST /api/batches/{id}/send 手动发送(可指定单据序号);
⚠️ 电费批次必须已「确认核对」,否则 409
GET /api/batches/{id}/export 导出批次数据(xlsx:单据 + 明细 + 汇总) GET /api/batches/{id}/export 导出批次数据(xlsx:单据 + 明细 + 汇总)
GET /api/batches/{id}/file/{i} 下载第 i 份(PDF) GET /api/batches/{id}/file/{i} 下载第 i 份(PDF)
GET /api/batches/{id}/zip 打包下载 GET /api/batches/{id}/zip 打包下载
...@@ -77,6 +84,7 @@ from . import attachments ...@@ -77,6 +84,7 @@ from . import attachments
from . import auth from . import auth
from . import config as C from . import config as C
from . import directory from . import directory
from . import masters
from . import plans from . import plans
from . import rentfree from . import rentfree
from . import scheduler from . import scheduler
...@@ -186,11 +194,13 @@ class Handler(BaseHTTPRequestHandler): ...@@ -186,11 +194,13 @@ class Handler(BaseHTTPRequestHandler):
m = re.match(r"^/api/attachments/([A-Za-z0-9]+)$", path) m = re.match(r"^/api/attachments/([A-Za-z0-9]+)$", path)
if m and method == "DELETE": if m and method == "DELETE":
return auth.CAP_ELEC return auth.CAP_ELEC
m = re.match(r"^/api/batches/([^/]+)/(rerender|send)$", path) m = re.match(r"^/api/batches/([^/]+)/(rerender|send|verify)$", path)
if m and method == "POST": if m and method == "POST":
return "*" if self._batch_kind(m.group(1)) == service.RENT_KEY else auth.CAP_ELEC return "*" if self._batch_kind(m.group(1)) == service.RENT_KEY else auth.CAP_ELEC
if path == "/api/periods/verify" and method == "POST":
return auth.CAP_ELEC
m = re.match(r"^/api/batches/([^/]+)$", path) m = re.match(r"^/api/batches/([^/]+)$", path)
if m and method == "DELETE": if m and method in ("DELETE", "PUT"):
return "*" if self._batch_kind(m.group(1)) == service.RENT_KEY else auth.CAP_ELEC return "*" if self._batch_kind(m.group(1)) == service.RENT_KEY else auth.CAP_ELEC
return "*" return "*"
...@@ -336,6 +346,13 @@ class Handler(BaseHTTPRequestHandler): ...@@ -336,6 +346,13 @@ 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/periods":
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
kind = (q.get("kind") or ["elec"])[0].strip() or "elec"
return self._send(200, {"ok": True, "kind": kind,
"periods": service.list_periods(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)
...@@ -387,6 +404,14 @@ class Handler(BaseHTTPRequestHandler): ...@@ -387,6 +404,14 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/companies": if path == "/api/companies":
return self._send(200, {"ok": True, **directory.summary()}) return self._send(200, {"ok": True, **directory.summary()})
# ---- 电费总表(3F/6F/7F)的录入规格:楼层 / 类目 / 默认系数 / 默认电价
if path == "/api/masters":
cfg = store.load_config()
el = cfg.get("elec") or {}
return self._send(200, {"ok": True, **masters.spec(),
"blank": masters.blank_rows(),
"price": el.get("price")})
if path == "/api/rentfree": if path == "/api/rentfree":
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 import datetime as _dt
...@@ -429,7 +454,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -429,7 +454,8 @@ class Handler(BaseHTTPRequestHandler):
if not b: if not b:
return self._send(404, {"ok": False, "error": "批次不存在"}) return self._send(404, {"ok": False, "error": "批次不存在"})
return self._send(200, {"ok": True, "batch": b, return self._send(200, {"ok": True, "batch": b,
"receipt": service.receipt_summary(b)}) "receipt": service.receipt_summary(b),
"verify": service.verify_summary(b)})
m = re.match(r"^/api/batches/([^/]+)/export$", path) m = re.match(r"^/api/batches/([^/]+)/export$", path)
if m: if m:
...@@ -647,6 +673,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -647,6 +673,7 @@ class Handler(BaseHTTPRequestHandler):
due_date=body.get("due_date"), due_date=body.get("due_date"),
username=user["username"], username=user["username"],
meter_date=body.get("meter_date") or "", meter_date=body.get("meter_date") or "",
masters=body.get("masters"),
) )
return self._send(200, {"ok": True, "batch": batch}) return self._send(200, {"ok": True, "batch": batch})
...@@ -668,6 +695,24 @@ class Handler(BaseHTTPRequestHandler): ...@@ -668,6 +695,24 @@ class Handler(BaseHTTPRequestHandler):
) )
return self._send(200, {"ok": True, "batch": batch}) return self._send(200, {"ok": True, "batch": batch})
# ---- 重新编辑(重编)一期电费批次:覆盖原批次,batch_id 不变
m = re.match(r"^/api/batches/([^/]+)$", path)
if m and method == "PUT":
rows = body.get("rows") or []
if not isinstance(rows, list) or not rows:
return self._send(400, {"ok": False,
"error": "请至少录入一行电费明细"})
batch = service.reingest(
m.group(1), rows,
period_label=body.get("period_label"),
notice_date=body.get("notice_date"),
due_date=body.get("due_date"),
meter_date=body.get("meter_date"),
username=user["username"],
masters=body.get("masters"),
)
return self._send(200, {"ok": True, "batch": batch})
m = re.match(r"^/api/batches/([^/]+)$", path) m = re.match(r"^/api/batches/([^/]+)$", path)
if m and method == "DELETE": if m and method == "DELETE":
bid = m.group(1) bid = m.group(1)
...@@ -707,9 +752,50 @@ class Handler(BaseHTTPRequestHandler): ...@@ -707,9 +752,50 @@ class Handler(BaseHTTPRequestHandler):
"receipt": service.receipt_summary(b), "receipt": service.receipt_summary(b),
"sync": sync}) "sync": sync})
# ---- 核对:逐行勾选 / 确认核对(确认后就允许发送该电费批次;允许部分核对)
m = re.match(r"^/api/batches/([^/]+)/verify$", path)
if m and method == "POST":
b, vs = service.set_verify(
m.group(1),
units=body.get("units"),
rows=body.get("rows"),
done=body.get("done"),
username=user["username"],
)
return self._send(200, {"ok": True, "batch": b, "verify": vs})
# ---- 账期级核对:一次把勾选/确认写进该账期的全部电费批次
if path == "/api/periods/verify" and method == "POST":
period = str(body.get("period") or "").strip()
if not period:
return self._send(400, {"ok": False, "error": "缺少账期。"})
ps, bs = service.set_period_verify(
period,
units=body.get("units"),
rows=body.get("rows"),
done=body.get("done"),
username=user["username"],
)
return self._send(200, {"ok": True, "period": ps,
"batches": [b.get("batch_id") for b in bs]})
m = re.match(r"^/api/batches/([^/]+)/send$", path) m = re.match(r"^/api/batches/([^/]+)/send$", path)
if m and method == "POST": if m and method == "POST":
bid = m.group(1) bid = m.group(1)
b = store.load_batch(bid)
if not b:
return self._send(404, {"ok": False, "error": "批次不存在。"})
# 电费批次:必须「确认核对」过才能发送(允许部分核对;房租车位费不受此限)
if (b.get("kind") or "elec") != service.RENT_KEY:
vs = service.verify_summary(b)
if not vs["ok"]:
tip = ("已核对 %d/%d 行" % (vs["verified"], vs["total"]))
if vs["missing"]:
tip += ",还差:" + "、".join(vs["missing"][:8])
return self._send(409, {"ok": False, "need_verify": True,
"verify": vs,
"error": "该批次还没确认核对,不能发送(%s)。"
"请在批次详情点「核对」,确认后即可发送。" % tip})
idx = body.get("indexes") idx = body.get("indexes")
res = sender.send_batch(bid, indexes=idx, force=bool(body.get("force"))) res = sender.send_batch(bid, indexes=idx, force=bool(body.get("force")))
return self._send(200, {"ok": True, "result": res, return self._send(200, {"ok": True, "result": res,
......
...@@ -25,6 +25,7 @@ from . import config as C ...@@ -25,6 +25,7 @@ from . import config as C
from . import directory from . import directory
from . import elec_parser as P from . import elec_parser as P
from . import elec_render as R from . import elec_render as R
from . import masters as MST
from . import plans from . import plans
from . import rent_parser as RP from . import rent_parser as RP
from . import rent_render as RR from . import rent_render as RR
...@@ -102,17 +103,17 @@ def _d(s): ...@@ -102,17 +103,17 @@ def _d(s):
# ---------------------------------------------------------------- 手工录入 # ---------------------------------------------------------------- 手工录入
def ingest_rows(rows, period_label=None, notice_date=None, due_date=None, def build_details(rows, cfg=None):
username="admin", meter_date=""): """录入行 → 计费明细(手工录入与「重新编辑」共用同一条计费链)。
"""手工录入的一期电费明细 → 计费 → 出单 → 存档。
rows = [{"unit","meter","prev","cur","qty","price","factor","company","note","images"}] rows = [{"unit","meter","prev","cur","qty","price","factor","company","note","images"}]
单元与公司会与「房租车位费」台账对齐(公司留空时自动带出现租客); 单元与公司会与「房租车位费」台账对齐(公司留空时自动带出现租客);
images 是备注栏挂的图片附件 id 列表(见 rentos/attachments.py)。 images 是备注栏挂的图片附件 id 列表(见 rentos/attachments.py)。
**车位会被丢弃**:电费只抄房间的表,车位的租金走「房租车位费」出单链路。 **车位会被丢弃**:电费只抄房间的表,车位的租金走「房租车位费」出单链路。
返回 (details, skipped_park),明细为空时抛 ValueError。
""" """
cfg = store.load_config() cfg = cfg or store.load_config()
el = cfg.get("elec") or {} el = cfg.get("elec") or {}
unit_map = P.load_unit_map() unit_map = P.load_unit_map()
owner_map = P.load_owner_map() owner_map = P.load_owner_map()
...@@ -160,6 +161,66 @@ def ingest_rows(rows, period_label=None, notice_date=None, due_date=None, ...@@ -160,6 +161,66 @@ def ingest_rows(rows, period_label=None, notice_date=None, due_date=None,
}) })
if not details: if not details:
raise ValueError("没有可计费的明细行:请至少填一行「房间号 + 上期/本期抄表数」。") raise ValueError("没有可计费的明细行:请至少填一行「房间号 + 上期/本期抄表数」。")
return details, skipped_park
def _elec_totals(details, docs_out, mrows=None):
"""电费批次的汇总口径(明细 + 单据 + 总表)。
总表(3F/6F/7F)只记账、不出单:金额单列 master_amount,
**不并入** document_amount(那是通知书的合计)。
"""
billable = [d for d in details if not d.get("pending")]
mt = MST.totals(mrows)
return {
"unit_count": len({d["unit"] for d in details}),
"billable_unit_count": len({d["unit"] for d in billable}),
"row_count": len(details),
"pending_count": len(details) - len(billable),
"image_count": sum(len(d.get("images") or []) for d in details),
"total_qty": round(sum(d["qty"] or 0 for d in billable), 2),
"total_amount": round(sum(d["amount"] or 0 for d in billable), 2),
"document_count": len(docs_out),
"document_amount": round(sum(d["total_amount"] for d in docs_out), 2),
"master_rows": mt["rows"],
"master_pending": mt["pending"],
"master_amount": mt["amount"],
}
def _render_elec(details, period_label, notice_date, due_date, cfg, batch_id, with_logo):
"""电费明细 → 按公司分组 → 逐份出单。返回 (owners, docs_out)。"""
owners = owners_config(cfg)
docs = P.group_by_company(details, owners=owners)
for d in docs:
d["period_label"] = period_label
docs_out = R.render_batch(docs, _d(notice_date), owners, batch_id,
due_date=_d(due_date), with_logo=with_logo)
annotate_rentfree(docs_out, notice_date, cfg)
return owners, docs_out
def aggregate_send_state(docs):
"""批次整体的发送状态(与 sender 回写口径一致)。"""
states = [d.get("send_state") for d in docs or []]
sent_n = states.count("已发送")
skip_n = states.count("已跳过")
if sent_n and all(s in ("已发送", "已跳过") for s in states):
return "已发送"
if sent_n:
return "部分已发送"
if skip_n:
return "免租期内全部跳过"
return "未发送"
def ingest_rows(rows, period_label=None, notice_date=None, due_date=None,
username="admin", meter_date="", masters=None):
"""手工录入的一期电费明细 → 计费 → 出单 → 存档,返回批次。
masters 是「总表」(3F/6F/7F)的录入行,**只记账不出单**,见 rentos/masters.py。
"""
details, skipped_park = build_details(rows)
if not period_label: if not period_label:
nd = _d(notice_date or dt.date.today()) nd = _d(notice_date or dt.date.today())
...@@ -167,7 +228,7 @@ def ingest_rows(rows, period_label=None, notice_date=None, due_date=None, ...@@ -167,7 +228,7 @@ def ingest_rows(rows, period_label=None, notice_date=None, due_date=None,
res = ingest_parts(details, period_label=period_label, res = ingest_parts(details, period_label=period_label,
notice_date=notice_date, due_date=due_date, notice_date=notice_date, due_date=due_date,
username=username, meter_date=meter_date) username=username, meter_date=meter_date, masters=masters)
if skipped_park: if skipped_park:
res["skipped_park"] = skipped_park res["skipped_park"] = skipped_park
res.setdefault("warnings", []).append( res.setdefault("warnings", []).append(
...@@ -178,7 +239,7 @@ def ingest_rows(rows, period_label=None, notice_date=None, due_date=None, ...@@ -178,7 +239,7 @@ def ingest_rows(rows, period_label=None, notice_date=None, due_date=None,
def ingest_parts(details, period_label, notice_date=None, due_date=None, def ingest_parts(details, period_label, notice_date=None, due_date=None,
username="admin", meter_date="", title=""): username="admin", meter_date="", title="", masters=None):
"""已经算好的明细 → 出单 → 存档。 """已经算好的明细 → 出单 → 存档。
留档只保留「原始明细 + 汇总 + 单据清单」:电费已改为页面手工录入, 留档只保留「原始明细 + 汇总 + 单据清单」:电费已改为页面手工录入,
...@@ -193,28 +254,10 @@ def ingest_parts(details, period_label, notice_date=None, due_date=None, ...@@ -193,28 +254,10 @@ def ingest_parts(details, period_label, notice_date=None, due_date=None,
due_date = (today + dt.timedelta(days=15)).strftime("%Y-%m-%d") due_date = (today + dt.timedelta(days=15)).strftime("%Y-%m-%d")
batch_id = make_batch_id() batch_id = make_batch_id()
owners, docs_out = _render_elec(
billable = [d for d in details if not d.get("pending")] details, period_label, notice_date, due_date, cfg, batch_id,
image_count = sum(len(d.get("images") or []) for d in details) bool((cfg.get("elec") or {}).get("show_logo", True)))
totals = { mrows = MST.build(masters, price=(cfg.get("elec") or {}).get("price"))
"unit_count": len({d["unit"] for d in details}),
"billable_unit_count": len({d["unit"] for d in billable}),
"row_count": len(details),
"pending_count": len(details) - len(billable),
"image_count": image_count,
"total_qty": round(sum(d["qty"] or 0 for d in billable), 2),
"total_amount": round(sum(d["amount"] or 0 for d in billable), 2),
}
owners = owners_config(cfg)
docs = P.group_by_company(details, owners=owners)
for d in docs:
d["period_label"] = period_label
docs_out = R.render_batch(docs, _d(notice_date), owners, batch_id,
due_date=_d(due_date),
with_logo=bool((cfg.get("elec") or {}).get("show_logo", True)))
annotate_rentfree(docs_out, notice_date, cfg)
batch = { batch = {
"batch_id": batch_id, "batch_id": batch_id,
...@@ -226,11 +269,11 @@ def ingest_parts(details, period_label, notice_date=None, due_date=None, ...@@ -226,11 +269,11 @@ def ingest_parts(details, period_label, notice_date=None, due_date=None,
"title": title or f"{period_label}电费统计表", "title": title or f"{period_label}电费统计表",
"notice_date": notice_date, "notice_date": notice_date,
"due_date": due_date, "due_date": due_date,
"totals": {**totals, "totals": _elec_totals(details, docs_out, mrows),
"document_count": len(docs_out),
"document_amount": round(sum(d["total_amount"] for d in docs_out), 2)},
"details": details, "details": details,
"documents": docs_out, "documents": docs_out,
"masters": mrows,
"verify": blank_verify(),
"send_state": "未发送", "send_state": "未发送",
"last_send_at": "", "last_send_at": "",
"last_send_target": "", "last_send_target": "",
...@@ -240,6 +283,315 @@ def ingest_parts(details, period_label, notice_date=None, due_date=None, ...@@ -240,6 +283,315 @@ def ingest_parts(details, period_label, notice_date=None, due_date=None,
return batch return batch
# ---------------------------------------------------------------- 重新编辑(重编)
def reingest(batch_id, rows, period_label=None, notice_date=None, due_date=None,
meter_date=None, username="admin", masters=None):
"""**重编**一期电费批次:用新录入的明细覆盖原批次,batch_id 不变。
口径:
· 只支持电费批次(房租车位费按台账出单,改数据请直接改台账);
· 单据按新明细重出,**发送状态按文件名沿用**(内容没变的文件不会退回待发送);
· **核对状态一律重置** —— 数据改过了,必须重新核对才能发送;
· 重出后清理该批次目录下不再使用的旧 PDF;
· masters 传 None 表示「没动总表」(保留原值),传数组则按新值覆盖。
"""
batch = store.load_batch(batch_id)
if not batch:
raise ValueError("批次不存在。")
if (batch.get("kind") or "elec") == RENT_KEY:
raise ValueError("房租车位费批次按台账出单,不支持重编。")
cfg = store.load_config()
details, skipped_park = build_details(rows, cfg=cfg)
today = dt.date.today()
nd = str(notice_date or batch.get("notice_date")
or today.strftime("%Y-%m-%d")).strip()[:10]
dd = str(due_date or batch.get("due_date")
or (today + dt.timedelta(days=15)).strftime("%Y-%m-%d")).strip()[:10]
plabel = (str(period_label or "").strip() or batch.get("period_label")
or f"{_d(nd).year}年{_d(nd).month}月")
mdate = str(meter_date if meter_date is not None else batch.get("meter_date") or "")
owners, docs_out = _render_elec(
details, plabel, nd, dd, cfg, batch_id,
bool((cfg.get("elec") or {}).get("show_logo", True)))
# 总表:没传就保留原批次的值(重编通常只改逐室明细)
mrows = (batch.get("masters") or [] if masters is None
else MST.build(masters, price=(cfg.get("elec") or {}).get("price")))
# 发送状态按文件名沿用(与 rerender 同策略)
old = {d.get("file"): d for d in batch.get("documents") or []}
for d in docs_out:
prev = old.get(d.get("file"))
if prev:
d["send_state"] = prev.get("send_state", "待发送")
d["sent_at"] = prev.get("sent_at", "")
d["send_error"] = prev.get("send_error", "")
batch.update({
"period_label": plabel,
"meter_date": mdate,
"title": f"{plabel}电费统计表",
"notice_date": nd,
"due_date": dd,
"totals": _elec_totals(details, docs_out, mrows),
"details": details,
"documents": docs_out,
"masters": mrows,
"verify": blank_verify(),
"send_state": aggregate_send_state(docs_out),
"reingest_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"reingest_by": username,
"reingest_count": int(batch.get("reingest_count") or 0) + 1,
"config_snapshot": {"owners": owners, "elec": cfg.get("elec")},
})
store.save_batch(batch)
prune_output(batch_id, [d["file"] for d in docs_out], kind="elec")
if skipped_park:
batch["skipped_park"] = skipped_park
return batch
# ---------------------------------------------------------------- 核对(出单前逐室确认)
def blank_verify():
"""核对状态:rows 是已核对的**行**(「单元|类目」),units 是旧口径的房间(整室全勾)。"""
return {"units": [], "rows": [], "done": False, "at": "", "by": "", "scope": []}
def _row_keys(batch):
"""明细行的核对键:「单元|类目」(如 302室|空调);空单元的行不参与核对。
核对粒度是**行**而不是房间:同一房间的照明、空调可以分别核对,
也可以只核一部分房间 / 只核对某一类目(部分核对)。
"""
out = []
for d in ((batch or {}).get("details") or []):
u = str(d.get("unit") or "").strip()
if not u:
continue
m = str(d.get("meter") or "").strip() or "照明"
k = u + "|" + m
if k not in out:
out.append(k)
return out
def _checked_rows(batch, v=None):
"""verify 里已核对的「行」集合(旧数据的 units 按「整室全勾」展开)。"""
keys = set(_row_keys(batch))
v = v if isinstance(v, dict) else ((batch or {}).get("verify") or {})
out = {str(k).strip() for k in (v.get("rows") or [])} & keys
for u in {str(x).strip() for x in (v.get("units") or [])}:
out |= {k for k in keys if k.split("|", 1)[0] == u}
return out & keys
def verify_summary(batch):
"""批次的核对进度(按**明细行**统计:单元 + 类目)。
→ {"total","verified","missing","missing_units","units","rows","done",
"complete","ok","partial","at","by"}
· total/verified/missing 按**行**统计(照明、空调各算一行);
· complete = 所有行都勾上了;
· ok = **已点过「确认核对」**(允许部分核对)—— 发送闸门看的就是它;
· partial = 已确认但仍有行没勾上(详情与发送时会给出醒目提示)。
"""
b = batch or {}
keys = _row_keys(b)
v = b.get("verify") if isinstance(b.get("verify"), dict) else {}
checked = _checked_rows(b, v)
done_rows = [k for k in keys if k in checked]
missing = [k for k in keys if k not in checked]
units = sorted({k.split("|", 1)[0] for k in keys})
full = [u for u in units
if all(k in checked for k in keys if k.split("|", 1)[0] == u)]
is_done = bool(v.get("done"))
return {
"total": len(keys),
"verified": len(done_rows),
"missing": missing,
"missing_units": [u for u in units if u not in set(full)],
"units": full,
"rows": done_rows,
"done": is_done,
"complete": not missing,
"ok": is_done,
"partial": bool(is_done and missing),
"at": v.get("at") or "",
"by": v.get("by") or "",
}
def set_verify(batch_id, units=None, rows=None, done=None, username=""):
"""保存核对进度 / 确认核对(按**行**:「单元|类目」)。
units 旧口径:房间列表(等价于把这些房间的所有行都勾上)
rows 新口径:行键列表「单元|类目」(整体替换;只保留该批次里真实存在的行)
done True = 确认核对(**允许部分核对**:没勾的行会在批次详情里标出来);
False = 取消确认;None = 只存勾选进度。
勾选比确认时少了 → 自动撤销确认(防止改完数据还挂着「已核对」)。
返回 (batch, verify 摘要)。
"""
batch = store.load_batch(batch_id)
if not batch:
raise ValueError("批次不存在。")
if (batch.get("kind") or "elec") == RENT_KEY:
raise ValueError("房租车位费批次不走核对流程。")
keys = set(_row_keys(batch))
v = batch.get("verify") if isinstance(batch.get("verify"), dict) else blank_verify()
if units is not None:
if not isinstance(units, list):
raise ValueError("units 必须是数组。")
v["units"] = sorted({str(u).strip() for u in units if str(u).strip()})
if rows is not None:
if not isinstance(rows, list):
raise ValueError("rows 必须是数组。")
v["rows"] = sorted({str(k).strip() for k in rows if str(k).strip()})
checked = _checked_rows(batch, v)
if done is not None:
want = bool(done)
v["done"] = want
v["at"] = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") if want else ""
v["by"] = (username or "") if want else ""
v["scope"] = sorted(checked) if want else []
elif v.get("done"):
# 勾选变了但没显式改确认状态:比确认时少勾了 → 自动撤销确认
if not set(v.get("scope") or []) <= checked:
v["done"] = False
v["at"] = ""
v["by"] = ""
v["scope"] = []
v["rows"] = sorted(checked)
# 归一后只留 rows:旧数据的 units 已展开进 rows,再写回去会把「取消某一行」抵消掉
v["units"] = []
batch["verify"] = v
batch["verify_updated_at"] = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
store.save_batch(batch)
return batch, verify_summary(batch)
# ---------------------------------------------------------------- 账期(按账期统一核对)
def period_batches(period, kind="elec"):
"""某个账期下的全部批次(按创建时间正序)。
账期 = 批次上的 `period_label`(如「2026年10月」);同一个月可能录了好几期,
核对按**账期**统一做,所以这里把同账期的批次一起取出来。
"""
p = str(period or "").strip()
bs = [b for b in store.load_all_batches(kind)
if str(b.get("period_label") or "").strip() == p]
bs.sort(key=lambda b: b.get("created_at") or "")
return bs
def period_summary(period, batches=None):
"""账期级核对进度(按**明细行**:单元|类目)。
→ {"period","batch_count","batch_ids","rooms","total","verified","checked","missing",
"complete","done","ok","partial","at","by","doc_count","total_amount",
"batches":[{每批的核对/发送/单据统计}]}
口径:行是该账期所有批次的并集;一行算「已核对」当且仅当
**含它的每一张批次都勾上了它**;ok = 每张批次都点了「确认核对」(允许部分核对)。
发送闸门看的是每张批次自己的 `verify_summary(batch)["ok"]`(见 server)。
"""
bs = list(batches if batches is not None else period_batches(period))
rows, row_batches = [], {}
for b in bs:
for k in _row_keys(b):
if k not in row_batches:
row_batches[k] = []
rows.append(k)
row_batches[k].append(b)
checked = [k for k in rows if all(k in _checked_rows(b) for b in row_batches[k])]
cset = set(checked)
missing = [k for k in rows if k not in cset]
infos, ats, bys = [], [], []
for b in bs:
vs = verify_summary(b)
if vs["at"]:
ats.append(vs["at"])
if vs["by"]:
bys.append(vs["by"])
infos.append({
"batch_id": b.get("batch_id"),
"period_label": b.get("period_label") or "",
"notice_date": b.get("notice_date") or "",
"created_at": b.get("created_at") or "",
"doc_count": len(b.get("documents") or []),
"amount": round(float((b.get("totals") or {}).get("document_amount") or 0), 2),
"send_state": b.get("send_state") or "未发送",
"rooms": sorted({k.split("|", 1)[0] for k in _row_keys(b)}),
"total": vs["total"], "verified": vs["verified"],
"done": vs["done"], "ok": vs["ok"], "partial": vs["partial"],
"at": vs["at"], "by": vs["by"],
})
done_all = bool(bs) and all(i["done"] for i in infos)
complete = not missing
return {
"period": str(period or "").strip(),
"batch_count": len(bs),
"batch_ids": [b.get("batch_id") for b in bs],
"doc_count": sum(i["doc_count"] for i in infos),
"total_amount": round(sum(i["amount"] for i in infos), 2),
"rooms": sorted({k.split("|", 1)[0] for k in rows}),
"total": len(rows),
"verified": len(checked),
"checked": checked,
"missing": missing,
"complete": complete,
"done": done_all,
"ok": bool(done_all and bs),
"partial": bool(done_all and missing),
"at": max(ats) if ats else "",
"by": bys[-1] if bys else "",
"batches": infos,
}
def list_periods(kind="elec"):
"""按账期倒序列出各账期的核对/出单概况(批次台账按它分组展示)。"""
groups, order = {}, []
for b in store.load_all_batches(kind): # 已按创建时间倒序
p = str(b.get("period_label") or "").strip() or "(未标账期)"
if p not in groups:
groups[p] = []
order.append(p)
groups[p].append(b)
return [period_summary(p, groups[p]) for p in order]
def set_period_verify(period, units=None, rows=None, done=None, username=""):
"""**账期级**核对:把勾选/确认一次性写进该账期的每一张批次。
units 旧口径房间列表;rows 新口径行键列表「单元|类目」
(并集口径,与 period_summary 的 checked 一致;某批次里没有的行自动忽略)
done True = 确认核对(**允许部分核对**,没勾的行会在批次详情里标出来)
返回 (账期摘要, 受影响批次列表)。
"""
bs = period_batches(period)
if not bs:
raise ValueError("该账期没有可核对的电费批次。")
for name, val in (("units", units), ("rows", rows)):
if val is not None and not isinstance(val, list):
raise ValueError("%s 必须是数组。" % name)
out = []
for b in bs:
bb, _ = set_verify(b["batch_id"], units=units, rows=rows, done=done,
username=username)
out.append(bb)
return period_summary(period, out), out
# ================================================================ 房租车位费出单 # ================================================================ 房租车位费出单
def rent_preview(from_date=None, to_date=None, show_all=False, cfg=None): def rent_preview(from_date=None, to_date=None, show_all=False, cfg=None):
"""房租车位费的出单预览:台账逐行计费 + 将生成的通知书分组。 """房租车位费的出单预览:台账逐行计费 + 将生成的通知书分组。
...@@ -821,8 +1173,42 @@ def export_batch_xlsx(batch_id): ...@@ -821,8 +1173,42 @@ def export_batch_xlsx(batch_id):
if isinstance(v, float): if isinstance(v, float):
c.number_format = "#,##0.00" c.number_format = "#,##0.00"
# ---- Sheet3 汇总 # ---- Sheet3 总表(3F/6F/7F,只记账不出单)
mrows = batch.get("masters") or []
if mrows:
ws4 = wb.create_sheet("总表")
ws4.cell(1, 1, "总表(3F / 6F / 7F)—— 只记账:不出通知书、不发送").font = F_T
c4 = ["序号", "楼层", "明细类目", "上期抄表数", "本期抄表数", "用量",
"电价", "系数", "金额", "状态"]
_head(ws4, 3, c4)
r4 = 4
for i, x in enumerate(mrows, start=1):
row = [i, x.get("floor"), x.get("label"), x.get("prev"), x.get("cur"),
x.get("qty"), x.get("price"), x.get("factor"), x.get("amount"),
"本期未抄表" if x.get("pending") else ""]
for j, v in enumerate(row, start=1):
c = ws4.cell(r4, j, v)
c.font = F_B
c.alignment = A_L
if isinstance(v, float):
c.number_format = "#,##0.00"
r4 += 1
mt = MST.totals(mrows)
tot_row = ["", "合计", "%d 行" % mt["rows"], "", "", mt["qty"], "",
"", mt["amount"], "%d 行未抄表" % mt["pending"] if mt["pending"] else ""]
for j, v in enumerate(tot_row, start=1):
c = ws4.cell(r4, j, v)
c.font = F_H
c.fill = FILL
if isinstance(v, float):
c.number_format = "#,##0.00"
# ---- Sheet4 汇总
ws3 = wb.create_sheet("汇总") ws3 = wb.create_sheet("汇总")
vs = verify_summary(batch)
v_txt = ("已核对 %d/%d 室" % (vs["verified"], vs["total"])) + \
(",已于 %s 由 %s 确认核对" % (vs["at"], vs["by"]) if vs["done"]
else ",未确认核对")
info = [ info = [
("批次号", batch.get("batch_id")), ("批次号", batch.get("batch_id")),
("账单类型", fee), ("账单类型", fee),
...@@ -840,6 +1226,14 @@ def export_batch_xlsx(batch_id): ...@@ -840,6 +1226,14 @@ def export_batch_xlsx(batch_id):
("已开票份数", summ["invoiced_count"]), ("已开票份数", summ["invoiced_count"]),
("发送状态", batch.get("send_state") or ""), ("发送状态", batch.get("send_state") or ""),
] ]
if not rent:
info.append(("核对状态", v_txt))
if mrows:
mt = MST.totals(mrows)
info.append(("总表行数", mt["rows"]))
info.append(("总表金额", mt["amount"]))
if batch.get("reingest_count"):
info.append(("重编次数", int(batch.get("reingest_count") or 0)))
for k, (a, b) in enumerate(info, start=1): for k, (a, b) in enumerate(info, start=1):
ws3.cell(k, 1, a).font = F_H ws3.cell(k, 1, a).font = F_H
ws3.cell(k, 1).fill = FILL ws3.cell(k, 1).fill = FILL
......
...@@ -118,6 +118,26 @@ def load_batch(batch_id): ...@@ -118,6 +118,26 @@ def load_batch(batch_id):
return _read(batch_path(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): def list_batches(kind=None):
"""按上传时间倒序列出全部批次(只返回摘要,不含明细)。 """按上传时间倒序列出全部批次(只返回摘要,不含明细)。
...@@ -138,6 +158,25 @@ def list_batches(kind=None): ...@@ -138,6 +158,25 @@ def list_batches(kind=None):
continue continue
tot = b.get("totals") or {} tot = b.get("totals") or {}
docs = b.get("documents") 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({ out.append({
"batch_id": b.get("batch_id"), "batch_id": b.get("batch_id"),
"kind": k, "kind": k,
...@@ -146,7 +185,7 @@ def list_batches(kind=None): ...@@ -146,7 +185,7 @@ def list_batches(kind=None):
"period_label": b.get("period_label"), "period_label": b.get("period_label"),
"meter_date": b.get("meter_date"), "meter_date": b.get("meter_date"),
"tenant_count": len(docs), "tenant_count": len(docs),
"unit_count": len({d["unit"] for d in b.get("details", [])}), "unit_count": len(rooms),
# document_amount = 实际出单合计;table_amount = 明细全部合计 # document_amount = 实际出单合计;table_amount = 明细全部合计
"total_amount": tot.get("document_amount", tot.get("total_amount", 0)), "total_amount": tot.get("document_amount", tot.get("total_amount", 0)),
"table_amount": tot.get("total_amount", 0), "table_amount": tot.get("total_amount", 0),
...@@ -154,6 +193,19 @@ def list_batches(kind=None): ...@@ -154,6 +193,19 @@ def list_batches(kind=None):
"due_date": b.get("due_date"), "due_date": b.get("due_date"),
"notice_date": b.get("notice_date"), "notice_date": b.get("notice_date"),
"send_state": b.get("send_state", "未发送"), "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) out.sort(key=lambda x: x.get("created_at") or "", reverse=True)
return out return out
......
...@@ -122,8 +122,9 @@ ...@@ -122,8 +122,9 @@
<!-- ==================== 电费通知单 ==================== --> <!-- ==================== 电费通知单 ==================== -->
<section class="page" id="page-elec"> <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> <h2>录入本期电费 <span class="hint">逐行手工录入,系统按规则计费并出单</span></h2>
<div id="eModeBar"></div>
<div class="desc"> <div class="desc">
计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>; 计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>;
<b>空调 = 用量 × 电价 × 12.25</b>、<b>照明 = 用量 × 电价</b>。 <b>空调 = 用量 × 电价 × 12.25</b>、<b>照明 = 用量 × 电价</b>。
...@@ -135,6 +136,12 @@ ...@@ -135,6 +136,12 @@
也可以从 也可以从
<a href="#/companies" style="color:var(--brand)">公司管理</a> <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>
<div class="row"> <div class="row">
...@@ -145,7 +152,7 @@ ...@@ -145,7 +152,7 @@
</div> </div>
<div class="spacer"></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-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-copy" title="重新从最近一期批次带入上期抄表数等信息">重新带入上期</button></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-e-clear">清空</button></div> <div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-e-clear">清空</button></div>
...@@ -157,22 +164,50 @@ ...@@ -157,22 +164,50 @@
<div class="grid-wrap" id="eWrap"> <div class="grid-wrap" id="eWrap">
<table class="edgrid" id="eGrid"> <table class="edgrid" id="eGrid">
<thead><tr> <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:120px">上期抄表数</th>
<th style="width:110px">本期抄表数</th> <th style="width:120px">本期抄表数</th>
<th style="width:100px">用量</th> <th style="width:110px">用量</th>
<th style="width:90px">电价</th> <th style="width:90px">电价</th>
<th style="width:80px">系数</th> <th style="width:90px">系数</th>
<th style="width:190px">单位名称</th> <th style="width:120px">金额</th>
<th style="width:110px">出租方</th>
<th style="width:200px">备注 / 图片</th>
<th style="width:80px">金额</th>
<th class="act" style="width:56px"></th>
</tr></thead> </tr></thead>
<tbody id="eBody"></tbody> <tbody id="mBody"></tbody>
</table> </table>
</div> </div>
</div>
<div id="uploadResult"></div> <div id="uploadResult"></div>
</div> </div>
...@@ -187,6 +222,8 @@ ...@@ -187,6 +222,8 @@
<h2>批次详情 <span class="hint" id="detailTitle"></span></h2> <h2>批次详情 <span class="hint" id="detailTitle"></span></h2>
<div class="row" style="margin-bottom:12px"> <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-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"><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-zip"><button>打包下载(zip)</button></a></div>
<div style="flex:0 0 auto"><a id="link-export"><button>导出批次数据(xlsx)</button></a></div> <div style="flex:0 0 auto"><a id="link-export"><button>导出批次数据(xlsx)</button></a></div>
...@@ -369,9 +406,10 @@ ...@@ -369,9 +406,10 @@
<!-- ==================== 设置 ==================== --> <!-- ==================== 设置 ==================== -->
<section class="page" id="page-settings"> <section class="page" id="page-settings">
<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>:触发日 = 该账单的<b>缴费期限</b> − <b>提前天数</b>,
当天到达 <b>发送时点</b> 后自动把「待发送」通知书推送到企业微信。
处于<b>免租期</b>内的单元按下面各自的开关决定是否跳过不通知。 处于<b>免租期</b>内的单元按下面各自的开关决定是否跳过不通知。
</div> </div>
<div class="row" style="align-items:flex-end"> <div class="row" style="align-items:flex-end">
...@@ -433,15 +471,6 @@ ...@@ -433,15 +471,6 @@
<div id="wecomHint" class="muted" style="font-size:12px"></div> <div id="wecomHint" class="muted" style="font-size:12px"></div>
</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> <div class="card" data-ro>
<h2>修改密码 <span class="hint">仅改管理员自己的登录密码</span></h2> <h2>修改密码 <span class="hint">仅改管理员自己的登录密码</span></h2>
<div class="row"> <div class="row">
......
...@@ -213,7 +213,7 @@ function applySideStatus(d) { ...@@ -213,7 +213,7 @@ function applySideStatus(d) {
sw.querySelector('span').textContent = w.available ? '企业微信已连接' : '企业微信未连接'; sw.querySelector('span').textContent = w.available ? '企业微信已连接' : '企业微信未连接';
const sc = d.scheduler || {}; const sc = d.scheduler || {};
const ps = sc.plans || []; const ps = (sc.plans || []).filter(p => p.auto !== false); // 只统计参与自动发送的账单
const onCount = ps.filter(p => p.enabled).length; const onCount = ps.filter(p => p.enabled).length;
const ss = $('#sfSched'); const ss = $('#sfSched');
const on = onCount > 0; const on = onCount > 0;
...@@ -251,7 +251,7 @@ async function loadOverview() { ...@@ -251,7 +251,7 @@ async function loadOverview() {
applySideStatus(d); applySideStatus(d);
const sc = d.scheduler || {}; const sc = d.scheduler || {};
const ps = sc.plans || []; const ps = (sc.plans || []).filter(p => p.auto !== false); // 电费已全手动,不再展示
$('#ov-scheduler').innerHTML = ` $('#ov-scheduler').innerHTML = `
<div style="margin-bottom:10px"> <div style="margin-bottom:10px">
总开关:<b>${sc.enabled ? '已启用' : '未启用'}</b> 总开关:<b>${sc.enabled ? '已启用' : '未启用'}</b>
...@@ -336,6 +336,21 @@ let FREE_TYPES = []; // 免租类型候选(来自 /api/units ...@@ -336,6 +336,21 @@ let FREE_TYPES = []; // 免租类型候选(来自 /api/units
let ELEC = []; // 待提交的明细行 let ELEC = []; // 待提交的明细行
let EDEF = { price: 1.2, ac: 12.25, room: 1.0 }; let EDEF = { price: 1.2, ac: 12.25, room: 1.0 };
/* 录入页的三种模式(2026-10-13):
new 新建一期(默认)
reingest 重新编辑某个已存在的电费批次 —— 保存后覆盖原批次
verify **按账期核对** —— 该账期下所有批次的明细一起列出(只读),逐室勾选 ✔,
确认后该账期每张批次都变成「已核对」,才能发送
EMODE 是当前模式,EBATCH 是重编目标批次,EBATCHES 是核对账期里的全部批次,
EPERIOD 是核对中的账期名,EVERIFY 是勾过的「室」。 */
let EMODE = 'new';
let EBATCH = null;
let EBATCHES = [];
let EPERIOD = null;
let PERIODS = []; // 账期核对概况(批次台账分组用)
let EVERIFY = new Set();
const E_MODE_TITLE = { new: '生成通知书', reingest: '保存重编', verify: '确认核对' };
$('#in-notice').value = todayStr(0); $('#in-notice').value = todayStr(0);
$('#in-due').value = todayStr(15); $('#in-due').value = todayStr(15);
$('#in-meter').value = todayStr(0); $('#in-meter').value = todayStr(0);
...@@ -421,20 +436,27 @@ function num(v) { ...@@ -421,20 +436,27 @@ function num(v) {
return isNaN(f) ? null : f; return isNaN(f) ? null : f;
} }
/** 与后端 elec_parser.compute_row 完全同口径的前端预估 */ /** 一行电费(房间 / 总表共用)的计费口径:与后端 compute_row 一致 */
function calcRow(r) { function calcLine(prevIn, curIn, qtyIn, priceIn, factorIn, defFactor) {
const meter = (r.meter || '照明').trim() || '照明'; const prev = num(prevIn), cur = num(curIn), qtyIn2 = num(qtyIn);
const prev = num(r.prev), cur = num(r.cur), qtyIn = num(r.qty); const price = num(priceIn) ?? EDEF.price;
const price = num(r.price) ?? EDEF.price; const factor = num(factorIn) ?? defFactor;
const factor = num(r.factor) ?? (meter === '空调' ? EDEF.ac : EDEF.room);
let qty, pending = false; let qty, pending = false;
if (prev !== null && cur !== null) qty = round4(cur - prev); if (prev !== null && cur !== null) qty = round4(cur - prev);
else if (prev !== null && cur === null) { qty = null; pending = true; } else if (prev !== null && cur === null) { qty = null; pending = true; }
else if (prev === null && cur !== null) qty = 0; else if (prev === null && cur !== null) qty = 0;
else if (qtyIn !== null) qty = qtyIn; else if (qtyIn2 !== null) qty = qtyIn2;
else { qty = null; pending = true; } else { qty = null; pending = true; }
const amount = pending ? null : round2((qty || 0) * price * factor); const amount = pending ? null : round2((qty || 0) * price * factor);
return { qty, amount, pending, price, factor, meter }; return { qty, amount, pending, price, factor };
}
/** 与后端 elec_parser.compute_row 完全同口径的前端预估 */
function calcRow(r) {
const meter = (r.meter || '照明').trim() || '照明';
const c = calcLine(r.prev, r.cur, r.qty, r.price, r.factor,
meter === '空调' ? EDEF.ac : EDEF.room);
return Object.assign({ meter }, c);
} }
const round4 = v => Math.round(v * 10000) / 10000; const round4 = v => Math.round(v * 10000) / 10000;
const round2 = v => Math.round(v * 100) / 100; const round2 = v => Math.round(v * 100) / 100;
...@@ -546,37 +568,206 @@ function bindImgEvents(root) { ...@@ -546,37 +568,206 @@ function bindImgEvents(root) {
}); });
} }
/** 核对模式下的「行」集合(行键 = 单元|类目):去重、保持录入顺序,排掉车位与空行 */
function verifyRows() {
const out = [];
ELEC.forEach(r => {
const u = String(r.unit || '').trim();
if (!u || isParkUnit(u)) return;
const k = rowKey(r);
if (out.includes(k)) return;
out.push(k);
});
return out;
}
/** 一行的核对键:单元|类目(照明 / 空调可以分别核对) */
function rowKey(r) {
return String(r.unit || '').trim() + '|' + (String(r.meter || '').trim() || '照明');
}
/** 该行是否已勾选核对 */
function rowChecked(k) { return EVERIFY.has(String(k || '').trim()); }
function renderElecRows() { function renderElecRows() {
const tb = $('#eBody'); const tb = $('#eBody');
const verify = EMODE === 'verify';
const dis = verify ? ' disabled' : '';
if (!ELEC.length) { if (!ELEC.length) {
tb.innerHTML = '<tr><td colspan="12" class="empty">还没有明细行,点「+ 添加一行」,或等待自动带入上一期</td></tr>'; tb.innerHTML = '<tr><td colspan="13" class="empty">还没有明细行,点「+ 添加一行」,或等待自动带入上一期</td></tr>';
updateElecSum(); updateElecSum();
return; return;
} }
let lastBid = null;
tb.innerHTML = ELEC.map((r, i) => { tb.innerHTML = ELEC.map((r, i) => {
const c = calcRow(r); const c = calcRow(r);
return `<tr data-i="${i}"> const u = String(r.unit || '').trim();
<td><input list="eUnits" data-i="${i}" data-k="unit" value="${esc(r.unit)}" placeholder="302室"></td> const rk = rowKey(r);
<td><input list="eMeters" data-i="${i}" data-k="meter" value="${esc(r.meter)}" placeholder="空调/照明"></td> // 账期核对模式:一个批次一组,组头写清是哪一张批次(同账期可能有好几张)
<td><input data-i="${i}" data-k="prev" value="${esc(r.prev)}" class="r"></td> let grp = '';
<td><input data-i="${i}" data-k="cur" value="${esc(r.cur)}" class="r"></td> if (verify && r._bid && r._bid !== lastBid) {
<td class="num mono-sm">${c.pending ? '<span class="tag warn">未抄表</span>' : (c.qty === null ? '—' : fmt0(c.qty))}</td> lastBid = r._bid;
<td><input data-i="${i}" data-k="price" value="${esc(r.price)}" class="r" placeholder="${EDEF.price}"></td> const b = (EBATCHES || []).find(x => x.batch_id === r._bid) || {};
<td><input data-i="${i}" data-k="factor" value="${esc(r.factor)}" class="r" placeholder="${defFactorOf(r.meter)}"></td> const n = ELEC.filter(x => x._bid === r._bid).length;
<td><input list="eCompanies" data-i="${i}" data-k="company" value="${esc(r.company)}" placeholder="留空自动带出"></td> grp = `<tr class="grp"><td colspan="13">
<td><input list="eOwners" data-i="${i}" data-k="owner" value="${esc(r.owner || '')}" placeholder="留空取台账"></td> <span class="grp-title">批次 ${esc(r._bid)}</span>
<td class="e-note"> <span class="muted mono-sm">${esc(b.notice_date || '')} 通知 · ${n} 行 · ${(b.documents || []).length} 份通知书</span>
<input data-i="${i}" data-k="note" value="${esc(r.note)}" placeholder="备注"> </td></tr>`;
${imgStripHtml(r.images, i)} }
return grp + `<tr data-i="${i}"${verify && rowChecked(rk) ? ' class="vok"' : ''}>
<td data-col="unit"><input list="eUnits" data-i="${i}" data-k="unit" value="${esc(r.unit)}" placeholder="302室"${dis}></td>
<td data-col="meter"><input list="eMeters" data-i="${i}" data-k="meter" value="${esc(r.meter)}" placeholder="空调/照明"${dis}></td>
<td data-col="prev"><input data-i="${i}" data-k="prev" value="${esc(r.prev)}" class="r"${dis}></td>
<td data-col="cur"><input data-i="${i}" data-k="cur" value="${esc(r.cur)}" class="r"${dis}></td>
<td class="num mono-sm" data-col="qty">${c.pending ? '<span class="tag warn">未抄表</span>' : (c.qty === null ? '—' : fmt0(c.qty))}</td>
<td data-col="price"><input data-i="${i}" data-k="price" value="${esc(r.price)}" class="r" placeholder="${EDEF.price}"${dis}></td>
<td data-col="factor"><input data-i="${i}" data-k="factor" value="${esc(r.factor)}" class="r" placeholder="${defFactorOf(r.meter)}"${dis}></td>
<td data-col="company"><input list="eCompanies" data-i="${i}" data-k="company" value="${esc(r.company)}" placeholder="留空自动带出"${dis}></td>
<td data-col="owner"><input list="eOwners" data-i="${i}" data-k="owner" value="${esc(r.owner || '')}" placeholder="留空取台账"${dis}></td>
<td class="e-note" data-col="note">
<input data-i="${i}" data-k="note" value="${esc(r.note)}" placeholder="备注"${dis}>
${verify ? (r.images || []).length ? imgStripHtml(r.images) : ''
: imgStripHtml(r.images, i)}
</td> </td>
<td class="num mono-sm">${c.amount === null ? '—' : '¥' + fmt(c.amount)}</td> <td class="num mono-sm" data-col="amt">${c.amount === null ? '—' : '¥' + fmt(c.amount)}</td>
<td class="act"><button class="sm ghost" data-delrow="${i}" title="删除本行">×</button></td> <td class="act" data-col="act">${verify ? '' : `<button class="sm ghost" data-delrow="${i}" title="删除本行">×</button>`}</td>
<td class="vcol" data-col="vcol">${verify && u
? `<label class="chk vchk" title="确认这一行的抄表数据正确(照明、空调可分别核对)">
<input type="checkbox" data-vrow="${esc(rk)}"${rowChecked(rk) ? ' checked' : ''}> ✔</label>`
: '<span class="muted">—</span>'}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
bindElecInputs(); bindElecInputs();
applyVCols();
updateElecSum(); updateElecSum();
renderMasterRows();
renderEModeBar();
}
/* ---------------- 核对模式:只显示想核对的列 ---------------- */
const VCOLS = [
{ k: 'unit', label: '房间号' },
{ k: 'meter', label: '明细类目' },
{ k: 'prev', label: '上期抄表数' },
{ k: 'cur', label: '本期抄表数' },
{ k: 'qty', label: '用量' },
{ k: 'price', label: '电价' },
{ k: 'factor', label: '系数' },
{ k: 'company', label: '单位名称' },
{ k: 'owner', label: '出租方' },
{ k: 'note', label: '备注 / 图片' },
{ k: 'amt', label: '金额' },
{ k: 'act', label: '操作' },
];
/** 列可见性(核对模式):记在 localStorage,切页面 / 换批次都不丢 */
function loadVCols() {
try {
const raw = localStorage.getItem('rentos_vcols');
if (raw) return new Set(JSON.parse(raw));
} catch (e) { /* 忽略坏值 */ }
return new Set(VCOLS.map(c => c.k));
}
let VCOL_SET = loadVCols();
function saveVCols() {
try { localStorage.setItem('rentos_vcols', JSON.stringify([...VCOL_SET])); }
catch (e) { /* 隐私模式下写不进去就算了 */ }
}
/** 按 VCOL_SET 显示 / 隐藏列(「核对」列永远留着) */
function applyVCols() {
const grid = $('#eGrid');
if (!grid) return;
VCOLS.forEach(c => {
const on = VCOL_SET.has(c.k);
grid.querySelectorAll(`[data-col="${c.k}"]`).forEach(el => {
el.style.display = on ? '' : 'none';
});
});
}
/** 列显示菜单的开关状态(模块级:模式条每次重渲染都要能恢复) */
let VCOLS_OPEN = false;
let VCOLS_DOC_BOUND = false;
function setVColsOpen(on) {
VCOLS_OPEN = !!on;
const menu = $('#vcolsMenu'), btn = $('#btn-vcols');
if (menu) {
menu.hidden = !VCOLS_OPEN;
// 下方空间不够就向上弹
if (VCOLS_OPEN && btn) {
const r = btn.getBoundingClientRect();
menu.classList.toggle('up', window.innerHeight - r.bottom < 320);
}
}
if (btn) btn.textContent = VCOLS_OPEN ? '收起列显示' : '列显示';
}
/** 列显示菜单(核对模式里点「列显示」弹出,再点按钮 / × / 点外部 / Esc 都能收起) */
function vcolsMenuHtml() {
return `<div class="colpick">
<button class="sm" id="btn-vcols" type="button">${VCOLS_OPEN ? '收起列显示' : '列显示'}</button>
<div class="colpick-menu" id="vcolsMenu"${VCOLS_OPEN ? '' : ' hidden'}>
<div class="colpick-title">只显示要核对的列
<button class="x" id="vcols-x" type="button" title="收起">×</button>
</div>
${VCOLS.map(c => `<label class="chk">
<input type="checkbox" data-vcol="${c.k}"${VCOL_SET.has(c.k) ? ' checked' : ''}> ${c.label}
</label>`).join('')}
<div class="colpick-act">
<button class="sm ghost" id="vcols-all" type="button">全选</button>
<button class="sm ghost" id="vcols-none" type="button">全不选</button>
</div>
</div>
</div>`;
}
function bindVCols(root) {
const btn = $('#btn-vcols');
if (!btn) return;
btn.addEventListener('click', ev => {
ev.stopPropagation();
setVColsOpen(!VCOLS_OPEN); // 再点一次就收起
});
(root || document).querySelectorAll('[data-vcol]').forEach(chk =>
chk.addEventListener('change', () => {
const k = chk.dataset.vcol;
if (chk.checked) VCOL_SET.add(k); else VCOL_SET.delete(k);
saveVCols();
applyVCols();
}));
const all = $('#vcols-all'), none = $('#vcols-none'), x = $('#vcols-x');
if (all) all.addEventListener('click', () => {
VCOL_SET = new Set(VCOLS.map(c => c.k)); saveVCols();
(root || document).querySelectorAll('[data-vcol]').forEach(c => { c.checked = true; });
applyVCols();
});
if (none) none.addEventListener('click', () => {
VCOL_SET = new Set(); saveVCols();
(root || document).querySelectorAll('[data-vcol]').forEach(c => { c.checked = false; });
applyVCols();
});
if (x) x.addEventListener('click', ev => { ev.stopPropagation(); setVColsOpen(false); });
// 「点外部收起」只绑一次 —— 模式条每勾选一行就重渲染一次,
// 绑在 bindVCols 里会越堆越多(旧的还持有已移除的节点)。
if (!VCOLS_DOC_BOUND) {
VCOLS_DOC_BOUND = true;
document.addEventListener('click', ev => {
if (!VCOLS_OPEN) return;
if (ev.target instanceof Element && ev.target.closest('.colpick')) return;
setVColsOpen(false);
});
document.addEventListener('keydown', ev => {
if (ev.key === 'Escape' && VCOLS_OPEN) setVColsOpen(false);
});
}
} }
/** 房间号的比较口径(与后端 normalize_unit 一致):去空格、纯数字补「室」 */ /** 房间号的比较口径(与后端 normalize_unit 一致):去空格、纯数字补「室」 */
function unitKey(v) { function unitKey(v) {
const s = String(v || '').replace(/\s+/g, ''); const s = String(v || '').replace(/\s+/g, '');
...@@ -662,6 +853,42 @@ function bindElecInputs() { ...@@ -662,6 +853,42 @@ function bindElecInputs() {
if (ELEC[i] && ELEC[i].images) { ELEC[i].images.splice(k, 1); renderElecRows(); } if (ELEC[i] && ELEC[i].images) { ELEC[i].images.splice(k, 1); renderElecRows(); }
})); }));
bindImgEvents($('#eBody')); bindImgEvents($('#eBody'));
// ---- 核对勾选(按行:单元|类目)—— 照明、空调可以分别核对,也可以只核一部分行
$$('#eBody [data-vrow]').forEach(chk => chk.addEventListener('change', () => {
const k = String(chk.dataset.vrow || '').trim();
if (!k) return;
if (chk.checked) EVERIFY.add(k); else EVERIFY.delete(k);
renderElecRows();
saveVerifyProgress();
}));
}
/* ---------------- 核对模式:进度保存 / 确认 ---------------- */
let VSAVE_T = null;
let VSAVE_SEQ = 0;
/** 勾选进度即时存到服务端(防手滑丢进度);不弹提示、失败只 console。
* 账期核对:一次写进该账期的**每一张**批次。 */
function saveVerifyProgress(done) {
if (EMODE !== 'verify' || !EPERIOD) return;
clearTimeout(VSAVE_T);
VSAVE_T = setTimeout(async () => {
const seq = ++VSAVE_SEQ;
try {
const d = await api('/api/periods/verify', {
method: 'POST', body: { period: EPERIOD, rows: Array.from(EVERIFY) },
});
if (seq === VSAVE_SEQ) applyPeriodSummary(d.period);
} catch (e) { console.warn('核对进度保存失败', e); }
}, done ? 0 : 400);
}
/** 把服务端回的账期摘要写回本地(勾选以服务端为准,会过滤掉不存在的房间) */
function applyPeriodSummary(ps) {
if (!ps) return;
EVERIFY = new Set(ps.checked || []);
const i = PERIODS.findIndex(x => x.period === ps.period);
if (i >= 0) PERIODS[i] = ps; else PERIODS.push(ps);
} }
function updateElecSum() { function updateElecSum() {
...@@ -672,11 +899,332 @@ function updateElecSum() { ...@@ -672,11 +899,332 @@ function updateElecSum() {
if (!r.unit && !r.prev && !r.cur) return; if (!r.unit && !r.prev && !r.cur) return;
n++; if (c.pending) pend++; else amt += (c.amount || 0); n++; if (c.pending) pend++; else amt += (c.amount || 0);
}); });
if (EMODE === 'verify') {
const rows = verifyRows();
const done = rows.filter(rowChecked).length;
const any = done > 0;
$('#eSum').innerHTML = `共 <b>${rows.length}</b> 行(照明 / 空调各算一行)| 已核对 <b>${done}</b>/${rows.length}`
+ (done >= rows.length && rows.length
? ' | <span class="tag ok">全部核对完成,请点「确认核对」</span>'
: any ? ' | <span class="tag wait">部分核对,可点「确认核对」</span>'
: ' | <span class="tag wait">至少勾一行才能确认</span>');
return;
}
$('#eSum').innerHTML = `已录 <b>${n}</b> 行|预计合计 <b>¥${fmt(round2(amt))}</b>` $('#eSum').innerHTML = `已录 <b>${n}</b> 行|预计合计 <b>¥${fmt(round2(amt))}</b>`
+ (pend ? `|<span style="color:#92400e">${pend} 行未抄表</span>` : '') + (pend ? `|<span style="color:#92400e">${pend} 行未抄表</span>` : '')
+ (img ? `|图片 <b>${img}</b> 张` : ''); + (img ? `|图片 <b>${img}</b> 张` : '');
} }
/* ---------------- 楼层总表(3F / 6F / 7F):与本期电费一并录入 ---------------- */
/** 规格由服务端 /api/masters 下发,避免前后端两套口径;拉不到就用这份兜底 */
let MSPEC = {
floors: ['3F', '6F', '7F'],
items: [{ label: '公共', factor: 12.25 }, { label: '套内', factor: 1.0 },
{ label: '空调总', factor: 12.25 }],
};
let MASTERS = []; // 待提交的总表行(楼层 × 类目)
function mval(v) { return (v === null || v === undefined) ? '' : v; }
/** 按 MSPEC 铺一张空表;已有值保留(切页面 / 载入批次时不会丢) */
function ensureMasters() {
const old = new Map((MASTERS || []).map(r => [r.floor + '|' + r.label, r]));
const out = [];
MSPEC.floors.forEach(f => MSPEC.items.forEach(it => {
const base = { floor: f, label: it.label, prev: '', cur: '', qty: '',
price: '', factor: it.factor };
const o = old.get(f + '|' + it.label);
if (o) {
['prev', 'cur', 'qty', 'price'].forEach(k => {
if (o[k] !== undefined && o[k] !== null) base[k] = o[k];
});
if (o.factor !== undefined && o.factor !== null && o.factor !== '') base.factor = o.factor;
}
out.push(base);
}));
MASTERS = out;
}
/** 清空总表(保留行结构,系数回到类目默认值) */
function clearMasters() {
ensureMasters();
MASTERS.forEach(r => {
const it = MSPEC.items.find(x => x.label === r.label);
r.prev = ''; r.cur = ''; r.qty = ''; r.price = '';
r.factor = it ? it.factor : r.factor;
});
}
async function loadMasterSpec() {
try {
const d = await api('/api/masters');
if ((d.floors || []).length && (d.items || []).length) {
MSPEC = { floors: d.floors.slice(), items: d.items.map(x => ({
label: x.label, factor: num(x.factor) ?? 1 })) };
}
} catch (e) { /* 拉不到就用兜底规格 */ }
ensureMasters();
renderMasterRows();
}
/** 总表一行:用量 = 本期 − 上期、金额 = 用量 × 电价 × 系数(与房间同口径) */
function calcMaster(r) {
const it = MSPEC.items.find(x => x.label === r.label);
return calcLine(r.prev, r.cur, r.qty, r.price, r.factor, it ? it.factor : 1);
}
function renderMasterRows() {
const tb = $('#mBody');
if (!tb) return;
const verify = EMODE === 'verify';
const block = $('#masterBlock');
if (block) block.style.display = verify ? 'none' : ''; // 核对模式下不展示总表
if (verify) return;
if (!MASTERS.length) ensureMasters();
const dis = '';
const spans = MASTERS.map((r, i) =>
(i === 0 || MASTERS[i - 1].floor !== r.floor)
? MASTERS.filter(x => x.floor === r.floor).length : 0);
tb.innerHTML = MASTERS.map((r, i) => {
const c = calcMaster(r);
const fl = spans[i] ? `<td class="mono-sm" rowspan="${spans[i]}">${esc(r.floor)}</td>` : '';
return `<tr data-mi="${i}">
${fl}
<td>${esc(r.label)}</td>
<td><input data-mi="${i}" data-mk="prev" value="${esc(mval(r.prev))}" class="r"${dis}></td>
<td><input data-mi="${i}" data-mk="cur" value="${esc(mval(r.cur))}" class="r"${dis}></td>
<td class="num mono-sm" data-mqty>${c.pending
? '<span class="tag warn">未填本期</span>' : (c.qty === null ? '—' : fmt0(c.qty))}</td>
<td><input data-mi="${i}" data-mk="price" value="${esc(mval(r.price))}"
class="r" placeholder="${EDEF.price}"${dis}></td>
<td><input data-mi="${i}" data-mk="factor" value="${esc(mval(r.factor))}" class="r"${dis}></td>
<td class="num mono-sm" data-mamt>${c.amount === null ? '—' : '¥' + fmt(c.amount)}</td>
</tr>`;
}).join('');
bindMasterInputs();
updateMasterSum();
}
function bindMasterInputs() {
$$('#mBody input[data-mk]').forEach(inp => {
inp.addEventListener('input', () => {
const i = +inp.dataset.mi, k = inp.dataset.mk;
if (!MASTERS[i]) return;
MASTERS[i][k] = inp.value;
const tr = inp.closest('tr');
const c = calcMaster(MASTERS[i]);
const q = tr.querySelector('[data-mqty]'), a = tr.querySelector('[data-mamt]');
if (q) q.innerHTML = c.pending ? '<span class="tag warn">未填本期</span>'
: (c.qty === null ? '—' : fmt0(c.qty));
if (a) a.textContent = c.amount === null ? '—' : '¥' + fmt(c.amount);
updateMasterSum();
});
});
}
function updateMasterSum() {
const box = $('#mSum');
if (!box) return;
let n = 0, amt = 0, pend = 0;
MASTERS.forEach(r => {
const c = calcMaster(r);
if (num(r.prev) === null && num(r.cur) === null && num(r.qty) === null) return;
n++; if (c.pending) pend++; else amt += (c.amount || 0);
});
box.innerHTML = n
? `总表已录 <b>${n}</b> 行|合计 <b>¥${fmt(round2(amt))}</b>`
+ (pend ? `|<span style="color:#92400e">${pend} 行未填本期</span>` : '')
: '总表未填(可整栏留空)';
}
/** 提交字段:只送填过的行,服务端再按 楼层×类目 归一 */
function masterRowsForSubmit() {
return MASTERS
.filter(r => num(r.prev) !== null || num(r.cur) !== null || num(r.qty) !== null)
.map(r => ({ floor: r.floor, label: r.label, prev: r.prev, cur: r.cur, qty: r.qty,
price: r.price, factor: r.factor }));
}
/** 批次里存的总表 → 录入表格(按 楼层×类目 归位,缺的补空行) */
function fillMastersFromBatch(b) {
const map = new Map((b.masters || []).map(x => [String(x.floor) + '|' + String(x.label), x]));
MASTERS = [];
MSPEC.floors.forEach(f => MSPEC.items.forEach(it => {
const x = map.get(f + '|' + it.label) || {};
MASTERS.push({
floor: f, label: it.label,
prev: mval(x.prev), cur: mval(x.cur), qty: mval(x.qty), price: mval(x.price),
factor: (x.factor === null || x.factor === undefined || x.factor === '')
? it.factor : x.factor,
});
}));
}
if ($('#btn-m-clear')) $('#btn-m-clear').addEventListener('click', () => {
clearMasters();
renderMasterRows();
});
/* ---------------- 录入页模式条(新建 / 重编 / 核对) ---------------- */
function renderEModeBar() {
const box = $('#eModeBar');
if (!box) return;
const b = EBATCH || {};
const vs = b.verify || {};
if (EMODE === 'new') {
box.innerHTML = '';
$('#eActions').style.display = '';
if ($('#btn-e-submit')) $('#btn-e-submit').textContent = E_MODE_TITLE.new;
return;
}
if (EMODE === 'reingest') {
box.innerHTML = `<div class="banner warn">
<b>重新编辑中</b>:批次 <span class="mono-sm">${esc(b.batch_id)}</span>
(账期 ${esc(b.period_label || '—')})。
改完点「保存重编」会<b>覆盖原批次</b>并重出单据;<b>核对状态会重置</b>,需重新核对后才能发送。
<div class="spacer"></div>
<button class="sm" id="e-mode-cancel">取消重编</button>
</div>`;
$('#eActions').style.display = '';
if ($('#btn-e-submit')) $('#btn-e-submit').textContent = E_MODE_TITLE.reingest;
} else {
const rows = verifyRows(), done = rows.filter(rowChecked).length;
const all = rows.length && done >= rows.length;
const canConfirm = done > 0; // 部分核对也允许确认(至少勾一行)
const bs = EBATCHES || [];
box.innerHTML = `<div class="banner${all ? ' ok' : ''}">
<b>账期核对中</b>:<b>${esc(EPERIOD || '—')}</b> | ${bs.length} 个批次
<span class="mono-sm">${bs.map(x => esc(x.batch_id)).join('、')}</span>
| 已核对 <b>${done}</b>/${rows.length} 行(照明 / 空调各算一行)
${all ? '<span class="tag ok">已全部核对</span>'
: canConfirm ? '<span class="tag wait">部分核对</span>'
: '<span class="tag wait">待核对</span>'}
<br>核对列<b>按行勾选</b>:可以只核一部分房间,也可以只核对照明或空调;
<b>同一房间在多个批次里共用一次核对</b>。数据只读 —— 要改数据请先「退出核对」,
到批次详情里点「重新编辑」。<b>确认核对后该账期每张批次都能发送账单。</b>
<div class="spacer"></div>
<div class="row" style="align-items:center;gap:8px;flex-wrap:wrap">
<div style="flex:0 0 auto">${vcolsMenuHtml()}</div>
<div style="flex:0 0 auto">
<button class="sm" id="e-vf-all">全部核对</button>
<button class="sm" id="e-vf-none">全部取消</button>
<button class="sm primary" id="e-vf-done"${canConfirm ? '' : ' disabled'}>确认核对</button>
<button class="sm" id="e-mode-cancel">退出核对</button>
</div>
</div>
</div>`;
$('#eActions').style.display = 'none';
bindVCols(box);
}
const c = $('#e-mode-cancel');
if (c) c.addEventListener('click', () => cancelElecMode());
const a = $('#e-vf-all');
if (a) a.addEventListener('click', () => {
verifyRows().forEach(k => EVERIFY.add(k)); renderElecRows(); saveVerifyProgress();
});
const n2 = $('#e-vf-none');
if (n2) n2.addEventListener('click', () => {
EVERIFY.clear(); renderElecRows(); saveVerifyProgress();
});
const dn = $('#e-vf-done');
if (dn) dn.addEventListener('click', confirmVerify);
}
/** 退出重编 / 核对,回到新建模式 */
function cancelElecMode(silent) {
const was = EMODE;
if (was === 'new') return;
if (!silent) {
const msg = was === 'verify' ? '退出核对?已勾选的核对进度会保存。'
: '取消重新编辑?未保存的改动会丢失。';
if (!confirm(msg)) return;
}
if (was === 'verify') saveVerifyProgress(true); // 进度落盘
setVColsOpen(false); // 收起列显示菜单,别带到下一轮
EMODE = 'new'; EBATCH = null; EBATCHES = []; EPERIOD = null; EVERIFY = new Set();
ELEC = [];
clearMasters();
renderElecRows();
renderEModeBar();
toast(was === 'verify' ? '已退出核对' : '已回到新建录入', 'ok');
}
/** 进入「重新编辑」:把批次明细灌回录入表格 */
async function enterReingest(id) {
try {
const d = await api('/api/batches/' + id);
const b = d.batch || {};
if ((b.kind || 'elec') !== 'elec') return toast('只有电费批次支持重新编辑', 'err');
fillElecFromBatch(b);
fillMastersFromBatch(b);
EMODE = 'reingest'; EBATCH = b; EVERIFY = new Set();
PREV_DONE = true;
renderElecRows();
navTo('elec');
$('#elecCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
toast(`已载入批次 ${b.batch_id} 的明细,改完点「保存重编」`, 'ok');
} catch (e) { toast(e.message, 'err'); }
}
/** 进入「账期核对」:把该账期**所有电费批次**的明细一起载入,数据只读,逐室勾选 */
async function enterPeriodVerify(period) {
const p = String(period || '').trim();
if (!p) return toast('该批次没有账期信息,无法核对', 'err');
try {
let ps = PERIODS.find(x => x.period === p);
if (!ps) {
const d = await api('/api/periods?kind=elec');
PERIODS = d.periods || [];
ps = PERIODS.find(x => x.period === p);
}
if (!ps || !(ps.batch_ids || []).length) return toast('该账期没有电费批次', 'err');
const bs = await Promise.all((ps.batch_ids || []).map(
id => api('/api/batches/' + id).then(r => r.batch)));
ELEC = [];
bs.forEach(b => (b.details || []).forEach(x => ELEC.push(detailToRow(x, b.batch_id))));
if (!ELEC.length) return toast('该账期的批次没有明细行', 'err');
EBATCHES = bs; EPERIOD = p; EBATCH = null;
EMODE = 'verify';
setVColsOpen(false); // 每次进核对都是收起的
EVERIFY = new Set((ps.checked || []).map(String)); // 已核对的「行」(单元|类目)
PREV_DONE = true;
renderElecRows();
navTo('elec');
$('#elecCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
toast(`账期核对 ${p}:${bs.length} 个批次、${ps.total} 行,逐行勾选 ✔(可只核一部分)`,
'ok');
} catch (e) { toast(e.message, 'err'); }
}
/** 一条明细 → 录入行(重编 / 账期核对共用;bid 是账期核对时的批次标记) */
function detailToRow(x, bid) {
return {
unit: x.unit || '',
meter: x.meter || '照明',
prev: (x.prev === null || x.prev === undefined) ? '' : x.prev,
cur: (x.cur === null || x.cur === undefined) ? '' : x.cur,
qty: (x.qty === null || x.qty === undefined) ? '' : x.qty,
price: (x.price === null || x.price === undefined) ? '' : x.price,
factor: (x.factor === null || x.factor === undefined) ? '' : x.factor,
company: (x.company && x.company !== '(空置)') ? x.company : '',
owner: x.owner || '',
note: x.note || '',
images: (x.images || []).slice(),
_autoCompany: true, _autoOwner: true,
_bid: bid || '',
};
}
/** 批次明细 → 录入行 */
function fillElecFromBatch(b) {
ELEC = (b.details || []).map(x => detailToRow(x));
$('#in-period').value = b.period_label || $('#in-period').value;
$('#in-meter').value = (b.meter_date || '').slice(0, 10);
$('#in-notice').value = (b.notice_date || '').slice(0, 10);
$('#in-due').value = (b.due_date || '').slice(0, 10);
}
$('#btn-e-row').addEventListener('click', () => elecAddRow()); $('#btn-e-row').addEventListener('click', () => elecAddRow());
$('#btn-e-clear').addEventListener('click', () => { $('#btn-e-clear').addEventListener('click', () => {
...@@ -791,14 +1339,15 @@ async function fillFromPrev(auto) { ...@@ -791,14 +1339,15 @@ async function fillFromPrev(auto) {
} }
} }
/** 进入电费页时静默带入一次;已有录入内容则不动 */ /** 进入电费页时静默带入一次;已有录入内容 / 正在重编或核对则不动 */
async function autoFillFromPrev() { async function autoFillFromPrev() {
if (PREV_DONE || ELEC.length) return; if (PREV_DONE || ELEC.length || EMODE !== 'new') return;
PREV_DONE = true; PREV_DONE = true;
await fillFromPrev(true); await fillFromPrev(true);
} }
$('#btn-e-copy').addEventListener('click', async () => { $('#btn-e-copy').addEventListener('click', async () => {
if (EMODE !== 'new') return toast('重编 / 核对中不能带入上一期,请先退出当前模式', 'err');
if (ELEC.length && !confirm('当前已有录入内容,用上一期的数据覆盖?')) return; if (ELEC.length && !confirm('当前已有录入内容,用上一期的数据覆盖?')) return;
await fillFromPrev(false); await fillFromPrev(false);
}); });
...@@ -825,14 +1374,18 @@ $('#btn-e-submit').addEventListener('click', async () => { ...@@ -825,14 +1374,18 @@ $('#btn-e-submit').addEventListener('click', async () => {
return o; return o;
}); });
if (!rows.length) return toast('请至少录入一行「房间号 + 抄表数」', 'err'); if (!rows.length) return toast('请至少录入一行「房间号 + 抄表数」', 'err');
const editing = EMODE === 'reingest' && EBATCH;
if (editing && !confirm(`用当前明细覆盖批次 ${EBATCH.batch_id}?(核对状态会重置,需重新核对后才能发送)`)) return;
const btn = $('#btn-e-submit'); const btn = $('#btn-e-submit');
btn.disabled = true; btn.textContent = '生成中…'; const label = btn.textContent;
btn.disabled = true; btn.textContent = editing ? '保存中…' : '生成中…';
$('#uploadResult').innerHTML = ''; $('#uploadResult').innerHTML = '';
try { try {
const d = await api('/api/batches', { const d = await api(editing ? `/api/batches/${EBATCH.batch_id}` : '/api/batches', {
method: 'POST', method: editing ? 'PUT' : 'POST',
body: { body: {
rows, period_label: $('#in-period').value.trim(), rows, masters: masterRowsForSubmit(),
period_label: $('#in-period').value.trim(),
meter_date: $('#in-meter').value, meter_date: $('#in-meter').value,
notice_date: $('#in-notice').value, due_date: $('#in-due').value notice_date: $('#in-notice').value, due_date: $('#in-due').value
} }
...@@ -841,24 +1394,78 @@ $('#btn-e-submit').addEventListener('click', async () => { ...@@ -841,24 +1394,78 @@ $('#btn-e-submit').addEventListener('click', async () => {
const pend = b.totals.pending_count || 0; const pend = b.totals.pending_count || 0;
const imgs = b.totals.image_count || 0; const imgs = b.totals.image_count || 0;
const parks = (b.skipped_park || []).length; const parks = (b.skipped_park || []).length;
const mt = Number(b.totals.master_rows || 0);
$('#uploadResult').innerHTML = ` $('#uploadResult').innerHTML = `
<div class="banner ok"> <div class="banner ok">
已生成:账期 <b>${esc(b.period_label)}</b>,抄表日期 ${esc(b.meter_date || '—')}, 已${editing ? '<b>重编</b>' : '生成'}:账期 <b>${esc(b.period_label)}</b>,抄表日期 ${esc(b.meter_date || '—')},
明细 <b>${b.totals.row_count}</b> 行,识别单元 <b>${b.totals.unit_count}</b> 个, 明细 <b>${b.totals.row_count}</b> 行,识别单元 <b>${b.totals.unit_count}</b> 个,
通知书 <b>${b.documents.length}</b> 份,合计 <b>¥${fmt(b.totals.total_amount)}</b>${imgs ? `,图片附件 <b>${imgs}</b> 张` : ''}。 通知书 <b>${b.documents.length}</b> 份,合计 <b>¥${fmt(b.totals.total_amount)}</b>${imgs ? `,图片附件 <b>${imgs}</b> 张` : ''}。
${mt ? `<br>楼层总表已存 <b>${mt}</b> 行,合计 <b>¥${fmt(b.totals.master_amount)}</b>
<span class="muted">(只记账:不出通知书、不发送,可在批次详情与导出里查看)</span>` : ''}
${editing ? '<br><span style="color:#92400e">核对状态已重置:请在批次台账点「核对」逐室确认后再发送。</span>' : ''}
${pend ? `<br><span style="color:#92400e">其中 ${pend} 行「本期抄表数」为空,已按未抄表处理、未参与出单。</span>` : ''} ${pend ? `<br><span style="color:#92400e">其中 ${pend} 行「本期抄表数」为空,已按未抄表处理、未参与出单。</span>` : ''}
${parks ? `<br><span style="color:#92400e">已忽略 ${parks} 行车位(电费只算房间): ${parks ? `<br><span style="color:#92400e">已忽略 ${parks} 行车位(电费只算房间):
${esc((b.skipped_park || []).slice(0, 6).join('、'))}${parks > 6 ? '…' : ''}</span>` : ''} ${esc((b.skipped_park || []).slice(0, 6).join('、'))}${parks > 6 ? '…' : ''}</span>` : ''}
</div>`; </div>`;
toast('已生成 ' + b.documents.length + ' 份通知书', 'ok'); toast(`已${editing ? '重编' : '生成'} ${b.documents.length} 份通知书`, 'ok');
loadBatches('elec'); EMODE = 'new'; EBATCH = null; EVERIFY = new Set(); ELEC = [];
clearMasters();
renderElecRows(); renderEModeBar();
await loadBatches('elec');
openBatch(b.batch_id, 'elec'); openBatch(b.batch_id, 'elec');
} catch (e) { } catch (e) {
$('#uploadResult').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; $('#uploadResult').innerHTML = `<div class="banner err">${esc(e.message)}</div>`;
toast(e.message, 'err'); toast(e.message, 'err');
} finally { btn.disabled = false; btn.textContent = '生成通知书'; } } finally { btn.disabled = false; btn.textContent = label || '生成通知书'; }
}); });
/** 核对模式:确认核对(账期级)—— 勾了任意一行就能确认,支持部分核对 */
async function confirmVerify() {
if (EMODE !== 'verify' || !EPERIOD) return;
const rows = verifyRows(), done = rows.filter(rowChecked).length;
if (!done) {
return toast('至少勾选一行再确认核对', 'err');
}
const n = (EBATCHES || []).length;
const miss = rows.filter(k => !rowChecked(k));
const partial = miss.length > 0;
const rows_txt = `${done}/${rows.length} 行`;
const ok = await confirmBox({
title: partial ? '确认核对(部分核对)' : '确认核对(按账期)',
rows: [
{ k: '账期', v: esc(EPERIOD) },
{ k: '覆盖批次', v: `全部 <b>${n}</b> 个批次:${esc((EBATCHES || []).map(b => b.batch_id).join('、'))}` },
{ k: '核对范围', v: partial
? `已逐行勾选 <b>${rows_txt}</b>(还有 <b>${miss.length}</b> 行未核对)`
: `该账期全部 <b>${rows_txt}</b> 已逐行勾选确认` },
{ k: '影响', v: '确认后该账期下每一张批次都变为「已核对」,才允许发送账单' },
],
warn: partial
? `这是<b>部分核对</b>:还有 ${miss.length} 行没核对(${esc(miss.slice(0, 6).join('、'))}`
+ `${miss.length > 6 ? ' …' : ''})。确认后这些行也会按已核对处理、可以发送。`
+ '若之后取消勾选,确认会自动撤销。'
: '确认即表示该账期电费数据已人工核对无误;若之后取消勾选或「重新编辑」改动数据,核对状态会自动重置。',
okText: partial ? '确认(部分核对)' : '确认核对',
});
if (!ok) return;
try {
const first = (EBATCHES[0] || {}).batch_id;
const d = await api('/api/periods/verify', {
method: 'POST', body: { period: EPERIOD, rows: Array.from(EVERIFY), done: true },
});
applyPeriodSummary(d.period);
toast(`账期 ${EPERIOD} 已完成核对(${(d.batches || []).length} 个批次`
+ (partial ? `,部分核对 ${rows_txt}` : '') + '),现在可以发送账单了', 'ok');
EMODE = 'new'; EBATCH = null; EBATCHES = []; EPERIOD = null; EVERIFY = new Set();
ELEC = [];
clearMasters();
renderElecRows(); renderEModeBar();
await loadBatches('elec');
if (first) openBatch(first, 'elec');
} catch (e) { toast(e.message, 'err'); }
}
/* ================================================================ /* ================================================================
批次视图(电费 / 房租车位费共用同一套渲染,只换容器与文案) 批次视图(电费 / 房租车位费共用同一套渲染,只换容器与文案)
================================================================ */ ================================================================ */
...@@ -891,16 +1498,27 @@ let BKIND = 'elec'; // 当前打开的批次属于哪张账单 ...@@ -891,16 +1498,27 @@ let BKIND = 'elec'; // 当前打开的批次属于哪张账单
async function loadBatches(kind) { async function loadBatches(kind) {
const C0 = BV[kind || 'elec']; const C0 = BV[kind || 'elec'];
try { try {
const d = await api('/api/batches?kind=' + C0.kind); const [d, pd] = await Promise.all([
api('/api/batches?kind=' + C0.kind),
api('/api/periods?kind=' + C0.kind),
]);
PERIODS = pd.periods || [];
renderMonths(d.months || [], C0.months); renderMonths(d.months || [], C0.months);
const box = $(C0.list); const box = $(C0.list);
box.innerHTML = d.batches.length box.innerHTML = d.batches.length
? renderBatchTable(d.batches, true, C0.kind) ? renderBatchTable(d.batches, true, C0.kind, PERIODS)
: `<div class="empty">${C0.emptyList}</div>`; : `<div class="empty">${C0.emptyList}</div>`;
box.querySelectorAll('[data-open]').forEach(el => box.querySelectorAll('[data-open]').forEach(el =>
el.addEventListener('click', () => openBatch(el.dataset.open, C0.kind))); el.addEventListener('click', () => openBatch(el.dataset.open, C0.kind)));
box.querySelectorAll('[data-del]').forEach(el => box.querySelectorAll('[data-del]').forEach(el =>
el.addEventListener('click', async ev => { ev.stopPropagation(); delBatch(el.dataset.del, C0); })); el.addEventListener('click', async ev => { ev.stopPropagation(); delBatch(el.dataset.del, C0); }));
// 账期汇总分组:点汇总头 / 「展开批次」折叠或展开该账期的批次行
box.querySelectorAll('tbody.pgrp').forEach(tb => {
const tr = tb.querySelector('tr.grp');
const btn = tb.querySelector('[data-ptoggle]');
if (btn) btn.addEventListener('click', ev => { ev.stopPropagation(); togglePgrp(tb); });
if (tr) tr.addEventListener('click', () => togglePgrp(tb));
});
} catch (e) { toast(e.message, 'err'); } } catch (e) { toast(e.message, 'err'); }
} }
...@@ -974,29 +1592,126 @@ function renderMonths(months, sel) { ...@@ -974,29 +1592,126 @@ function renderMonths(months, sel) {
</tbody></table></div>`; </tbody></table></div>`;
} }
function renderBatchTable(list, withAction, kind) { /** 批次列表的核对状态标签(只对电费批次有意义;按「行」统计) */
const elec = (kind || 'elec') !== 'rent_parking'; function verifyTag(b) {
const capAttr = elec ? ' data-cap="elec"' : ' data-ro'; if ((b.kind || 'elec') !== 'elec') return '<span class="muted">—</span>';
return `<div class="table-wrap"><table> const t = Number(b.verify_rows || 0), n = Number(b.verify_rows_done || 0);
<thead><tr> if (b.verify_ok) {
<th>账单</th><th>批次</th><th>账期(月)</th><th class="num">单元</th> return b.verify_partial
<th class="num">单据</th><th class="num">合计金额</th><th>通知日期</th><th>发送状态</th> ? `<span class="tag wait" title="已于 ${esc(b.verify_at || '')} 由 ${esc(b.verify_by || '')} 确认核对(部分核对 ${n}/${t} 行)">已核对·部分 ${n}/${t}</span>`
${withAction ? '<th></th>' : ''} : `<span class="tag ok" title="已于 ${esc(b.verify_at || '')} 由 ${esc(b.verify_by || '')} 确认核对">已核对</span>`;
</tr></thead> }
<tbody>${list.map(b => ` if (!t) return '<span class="tag gray">无明细</span>';
<tr ${withAction ? `data-open="${esc(b.batch_id)}" style="cursor:pointer"` : ''}> return `<span class="tag wait" title="确认核对后才允许发送">${n}/${t} 行</span>`;
}
/** 账期汇总分组头(与上面的「按账期月份留档统计」同口径)。
* 一个账期只汇总成一行,批次明细默认折叠;核对 / 重新编辑统一在批次详情里做。 */
function periodRowHtml(p, colSpan) {
const tag = p.ok
? (p.partial
? `<span class="tag wait" title="已于 ${esc(p.at || '')} 由 ${esc(p.by || '')} 确认核对(部分核对 ${p.verified}/${p.total} 行)">已核对·部分 ${p.verified}/${p.total}</span>`
: `<span class="tag ok" title="已于 ${esc(p.at || '')} 由 ${esc(p.by || '')} 确认核对">已核对</span>`)
: (p.verified
? `<span class="tag wait" title="已勾选 ${p.verified}/${p.total} 行,尚未确认核对">未确认 ${p.verified}/${p.total} 行</span>`
: '<span class="tag wait">未核对</span>');
return `<tr class="grp" data-pgrp-row="${esc(p.period)}">
<td colspan="${colSpan}">
<span class="grp-toggle">▸</span>
<span class="grp-title">账期 ${esc(p.period)}</span>
<span class="muted mono-sm">${p.batch_count} 个批次 · 单元 ${(p.rooms || []).length} 个 ·
单据 ${p.doc_count || 0} 份 · ¥${fmt(p.total_amount || 0)} | 核对 ${p.verified}/${p.total} 行</span>
${tag}
<span class="grp-act">
<button class="sm ghost" type="button" data-ptoggle="${esc(p.period)}">展开批次</button>
</span>
</td></tr>`;
}
function batchRowHtml(b, withAction, elec, capAttr) {
return `
<tr class="prow" ${withAction ? `data-open="${esc(b.batch_id)}" style="cursor:pointer"` : ''}>
<td>${b.kind === 'rent_parking' ? '房租车位费' : '电费'}</td> <td>${b.kind === 'rent_parking' ? '房租车位费' : '电费'}</td>
<td class="mono-sm">${esc(b.batch_id)}</td> <td class="mono-sm">${esc(b.batch_id)}${b.reingest_count
<td>${esc(b.period_label || '—')}</td> ? ` <span class="tag gray" title="已于 ${esc(b.reingest_at || '')} 重编">重编×${b.reingest_count}</span>` : ''}</td>
<td>${esc(b.period_label || '—')}${b.master_rows
? ` <span class="tag gray" title="楼层总表 ${b.master_rows} 行,合计 ¥${fmt(b.master_amount)}(只记账、不出单、不发送)">总表×${b.master_rows}</span>` : ''}</td>
<td class="num">${b.unit_count}</td> <td class="num">${b.unit_count}</td>
<td class="num">${b.tenant_count}</td> <td class="num">${b.tenant_count}</td>
<td class="num">¥${fmt(b.total_amount)}</td> <td class="num">¥${fmt(b.total_amount)}</td>
<td class="mono-sm">${esc(b.notice_date || '')}</td> <td class="mono-sm">${esc(b.notice_date || '')}</td>
<td>${stateTag(b.send_state)}</td> <td>${stateTag(b.send_state)} ${verifyTag(b)}</td>
${withAction ? `<td><button class="sm ghost" data-open="${esc(b.batch_id)}">详情</button> ${withAction ? `<td class="batch-act">
<button class="sm ghost" data-open="${esc(b.batch_id)}">详情</button>
<button class="sm danger" data-del="${esc(b.batch_id)}"${capAttr}>删除</button></td>` : ''} <button class="sm danger" data-del="${esc(b.batch_id)}"${capAttr}>删除</button></td>` : ''}
</tr>`).join('')} </tr>`;
</tbody></table></div>`; }
function renderBatchTable(list, withAction, kind, periods) {
const elec = (kind || 'elec') !== 'rent_parking';
const capAttr = elec ? ' data-cap="elec"' : ' data-ro';
const ncol = 8 + (withAction ? 1 : 0);
const head = `<thead><tr>
<th>账单</th><th>批次</th><th>账期(月)</th><th class="num">单元</th>
<th class="num">单据</th><th class="num">合计金额</th><th>通知日期</th>
<th>发送状态 / 核对</th>
${withAction ? '<th></th>' : ''}
</tr></thead>`;
// 按账期(月)汇总分组:一个账期一个 <tbody>(汇总头 + 该账期的批次行,默认折叠)
let body;
if ((periods || []).length) {
const ps = periods.filter(p => (p.batch_ids || []).some(id => list.some(b => b.batch_id === id)));
body = ps.map(p => {
const ids = new Set(p.batch_ids || []);
const mine = list.filter(b => ids.has(b.batch_id));
if (!mine.length) return '';
return `<tbody class="pgrp collapsed" data-pgrp="${esc(p.period)}">`
+ periodRowHtml(p, ncol)
+ mine.map(b => batchRowHtml(b, withAction, elec, capAttr)).join('')
+ `</tbody>`;
}).join('');
const known = new Set(ps.flatMap(p => p.batch_ids || []));
const rest = list.filter(b => !known.has(b.batch_id));
if (rest.length) body += `<tbody>` + rest.map(b => batchRowHtml(b, withAction, elec, capAttr)).join('') + `</tbody>`;
} else {
body = `<tbody>` + list.map(b => batchRowHtml(b, withAction, elec, capAttr)).join('') + `</tbody>`;
}
return `<div class="table-wrap"><table>${head}${body}</table></div>`;
}
/** 展开 / 收起某个账期的批次行 */
function togglePgrp(tb) {
const on = tb.classList.toggle('collapsed');
const btn = tb.querySelector('[data-ptoggle]');
if (btn) btn.textContent = on ? '展开批次' : '收起批次';
const tg = tb.querySelector('.grp-toggle');
if (tg) tg.textContent = on ? '▸' : '▾';
}
/** 未完成核对时返回核对摘要(电费批次);房租车位费或已核对返回 null */
function sendBlocked() {
if (BKIND === 'rent_parking') return null;
const v = (BATCH && BATCH.verify) || null;
if (!v || v.ok) return null;
return v;
}
/** 按核对状态同步所有「发送」入口的可用性(服务端另有 409 硬拦截兜底) */
function syncSendButtons() {
const v = sendBlocked();
const tip = '该批次还没确认核对,不能发送(请先点「核对」确认)';
const C0 = BV[BKIND] || BV.elec;
[$('#btn-send-all'), $(C0.sendAll), $('#rp-btn-send-all')].forEach(btn => {
if (!btn || !btn.offsetParent) return; // 隐藏的其它栏目按钮不动
btn.disabled = !!v;
btn.title = v ? tip : '';
});
const body = $((C0 && C0.body) || '#detailBody');
if (body) body.querySelectorAll('[data-send],[data-force]').forEach(btn => {
btn.disabled = !!v;
btn.title = v ? tip : '';
});
} }
async function openBatch(id, kind) { async function openBatch(id, kind) {
...@@ -1006,6 +1721,7 @@ async function openBatch(id, kind) { ...@@ -1006,6 +1721,7 @@ async function openBatch(id, kind) {
const d = await api('/api/batches/' + id); const d = await api('/api/batches/' + id);
BATCH = d.batch; BATCH = d.batch;
if (d.receipt) BATCH.receipt = d.receipt; // 收款 / 开票统计(接口放在顶层) if (d.receipt) BATCH.receipt = d.receipt; // 收款 / 开票统计(接口放在顶层)
if (d.verify) BATCH.verify = d.verify; // 核对进度(接口放在顶层)
$(C0.card).style.display = 'block'; $(C0.card).style.display = 'block';
$(C0.title).textContent = $(C0.title).textContent =
`${BATCH.batch_id} | ${BATCH.period_label} | 通知日期 ${BATCH.notice_date} | 缴费期限 ${BATCH.due_date}`; `${BATCH.batch_id} | ${BATCH.period_label} | 通知日期 ${BATCH.notice_date} | 缴费期限 ${BATCH.due_date}`;
...@@ -1057,6 +1773,36 @@ function docRowsHtml(docs) { ...@@ -1057,6 +1773,36 @@ function docRowsHtml(docs) {
</tr>`).join(''); </tr>`).join('');
} }
/** 核对状态条(仅电费批次)—— 未确认核对时醒目提示且不允许发送
* 核对按**账期**统一做,入口在批次详情的「核对」按钮。 */
function verifyBarHtml() {
if (BKIND === 'rent_parking') return '';
const v = (BATCH && BATCH.verify) || null;
if (!v) return '';
const per = (BATCH && BATCH.period_label) ? `账期 <b>${esc(BATCH.period_label)}</b>(按账期统一核对)` : '';
if (v.ok) {
return `<div class="banner ${v.partial ? 'warn' : 'ok'}">
核对状态:<b>已核对</b> <b>${v.verified}/${v.total}</b> 行${v.partial ? '(<b>部分核对</b>)' : ''}
| 由 <b>${esc(v.by || '—')}</b> 于 ${esc(v.at || '—')} 确认核对
${per ? `| ${per}` : ''}
| <span class="muted">可以发送账单</span>
${v.partial && v.missing && v.missing.length
? `<br><span class="muted mono-sm">未核对 ${v.missing.length} 行:${esc(v.missing.slice(0, 14).join('、'))}${v.missing.length > 14 ? ' …' : ''}</span>`
: ''}
</div>`;
}
const miss = (v.missing || []);
return `<div class="banner warn">
<b>核对状态:未确认 —— 不能发送账单</b> | 已核对 <b>${v.verified}/${v.total}</b> 行
${per ? `| ${per}` : ''}
${miss.length ? `<br>还差 ${miss.length} 行:<span class="mono-sm">${esc(miss.slice(0, 14).join('、'))}${miss.length > 14 ? ' …' : ''}</span>` : ''}
<br>请点上方<b>「核对」</b>,进入录入页按<b>账期</b>逐行勾选 ✔(可只核一部分),
勾完点「确认核对」后才可发送。
<div class="spacer"></div>
<button class="sm primary" id="btn-verify-go">去核对</button>
</div>`;
}
/** 收款 / 开票统计条(仅房租车位费批次) */ /** 收款 / 开票统计条(仅房租车位费批次) */
function receiptBarHtml() { function receiptBarHtml() {
const r = (BATCH && BATCH.receipt) || null; const r = (BATCH && BATCH.receipt) || null;
...@@ -1146,6 +1892,28 @@ function rawDetailHtml(det) { ...@@ -1146,6 +1892,28 @@ function rawDetailHtml(det) {
</tbody></table></div>`; </tbody></table></div>`;
} }
/** 楼层总表(3F/6F/7F):批次详情里的只读展示(只记账,不出单) */
function masterTableHtml(rows) {
const ok = (rows || []).filter(x => !x.pending);
const amt = round2(ok.reduce((s, x) => s + (x.amount || 0), 0));
return `<div class="table-wrap"><table>
<thead><tr><th>楼层</th><th>明细类目</th>
<th class="num">上期抄表数</th><th class="num">本期抄表数</th><th class="num">用量</th>
<th class="num">电价</th><th class="num">系数</th><th class="num">金额</th><th>状态</th></tr></thead>
<tbody>${(rows || []).map(x => `
<tr><td>${esc(x.floor)}</td><td>${esc(x.label)}</td>
<td class="num">${x.prev === null || x.prev === undefined ? '–' : x.prev}</td>
<td class="num">${x.cur === null || x.cur === undefined ? '–' : x.cur}</td>
<td class="num">${x.qty === null || x.qty === undefined ? '–' : fmt0(x.qty)}</td>
<td class="num">${x.price === null || x.price === undefined ? '–' : x.price}</td>
<td class="num">${x.factor === null || x.factor === undefined ? '–' : x.factor}</td>
<td class="num">${x.amount === null || x.amount === undefined ? '–' : fmt(x.amount)}</td>
<td>${x.pending ? '<span class="tag wait">未填本期</span>' : ''}</td></tr>`).join('')}
<tr><td colspan="7" style="text-align:right"><b>总表合计</b></td>
<td class="num"><b>${fmt(amt)}</b></td><td></td></tr>
</tbody></table></div>`;
}
function renderDetail() { function renderDetail() {
BKIND = (BATCH && BATCH.kind) || 'elec'; // 以批次自身的类型为准,避免跨栏目串台 BKIND = (BATCH && BATCH.kind) || 'elec'; // 以批次自身的类型为准,避免跨栏目串台
const C0 = BV[BKIND] || BV.elec; const C0 = BV[BKIND] || BV.elec;
...@@ -1167,6 +1935,7 @@ function renderDetail() { ...@@ -1167,6 +1935,7 @@ function renderDetail() {
: ''; : '';
$(C0.body).innerHTML = ` $(C0.body).innerHTML = `
${verifyBarHtml()}
<div class="table-wrap"><table> <div class="table-wrap"><table>
<thead><tr><th>#</th><th>出租方</th><th>公司</th><th>单元</th> <thead><tr><th>#</th><th>出租方</th><th>公司</th><th>单元</th>
${rent ? '<th>账期</th>' : ''} ${rent ? '<th>账期</th>' : ''}
...@@ -1177,6 +1946,9 @@ function renderDetail() { ...@@ -1177,6 +1946,9 @@ function renderDetail() {
</table></div> </table></div>
${rent ? `<div class="spacer"></div><div id="rcBar">${receiptBarHtml()}</div>` : ''} ${rent ? `<div class="spacer"></div><div id="rcBar">${receiptBarHtml()}</div>` : ''}
${extra} ${extra}
${(!rent && (BATCH.masters || []).length) ? `<div class="spacer"></div>
<details><summary style="cursor:pointer;color:var(--text-2)">查看楼层总表(${(BATCH.masters || []).length} 行,只记账 · 不出单 · 不发送)</summary>
<div class="spacer"></div>${masterTableHtml(BATCH.masters)}</details>` : ''}
<div class="spacer"></div> <div class="spacer"></div>
<details> <details>
<summary style="cursor:pointer;color:var(--text-2)">查看原始明细(${det.length} 行,留档于 JSON)</summary> <summary style="cursor:pointer;color:var(--text-2)">查看原始明细(${det.length} 行,留档于 JSON)</summary>
...@@ -1184,6 +1956,10 @@ function renderDetail() { ...@@ -1184,6 +1956,10 @@ function renderDetail() {
<div id="rawDet">${rawDetailHtml(det)}</div> <div id="rawDet">${rawDetailHtml(det)}</div>
</details>`; </details>`;
syncSendButtons();
const vgo = $('#btn-verify-go');
if (vgo) vgo.addEventListener('click', () => enterPeriodVerify(BATCH.period_label));
const root = $(C0.body); const root = $(C0.body);
root.querySelectorAll('[data-send]').forEach(b => root.querySelectorAll('[data-send]').forEach(b =>
b.addEventListener('click', () => sendOne(parseInt(b.dataset.send, 10), b, false))); b.addEventListener('click', () => sendOne(parseInt(b.dataset.send, 10), b, false)));
...@@ -1205,6 +1981,11 @@ function renderDetail() { ...@@ -1205,6 +1981,11 @@ function renderDetail() {
async function sendOne(i, btn, force) { async function sendOne(i, btn, force) {
const doc = (BATCH.documents || [])[i]; const doc = (BATCH.documents || [])[i];
const v = sendBlocked();
if (v) {
return toast(`该批次还没确认核对(已核对 ${v.verified}/${v.total} 行),不能发送`, 'err');
}
if (!await partialVerifyWarn()) return; // 部分核对:发送前再提醒一次
const rf = (doc.rentfree || []); const rf = (doc.rentfree || []);
if (force && rf.length) { if (force && rf.length) {
const ok = await confirmBox({ const ok = await confirmBox({
...@@ -1244,8 +2025,32 @@ function batchOf(kind) { ...@@ -1244,8 +2025,32 @@ function batchOf(kind) {
return BATCH; return BATCH;
} }
/** 部分核对提醒:已确认核对但还有行没勾 → 发送前再问一次(返回 false 则中止发送) */
async function partialVerifyWarn() {
const v = (BATCH && BATCH.verify) || null;
if (!v || !v.ok || !v.partial) return true;
const miss = (v.missing || []);
const ok = await confirmBox({
title: '该批次只是「部分核对」',
rows: [
{ k: '批次', v: esc(BATCH.batch_id) },
{ k: '核对进度', v: `已核对 <b>${v.verified}/${v.total}</b> 行,还有 <b>${miss.length}</b> 行未核对` },
{ k: '未核对的行', v: `<span class="mono-sm">${esc(miss.slice(0, 12).join('、'))}${miss.length > 12 ? ' …' : ''}</span>` },
],
warn: '这些未核对的行也会一起按现数据发送。若要补核对,请先「取消」,点上方「核对」勾完再确认。',
okText: '仍要发送',
danger: true,
});
return ok;
}
async function sendAll(sel, label, kind) { async function sendAll(sel, label, kind) {
if (!batchOf(kind)) return; if (!batchOf(kind)) return;
const v = sendBlocked();
if (v) {
return toast(`该批次还没确认核对(已核对 ${v.verified}/${v.total} 行),不能发送`, 'err');
}
if (!await partialVerifyWarn()) return; // 部分核对:发送前再提醒一次
const todo = (BATCH.documents || []).filter(d => d.send_state !== '已发送').length; const todo = (BATCH.documents || []).filter(d => d.send_state !== '已发送').length;
if (!todo) return toast('没有待发送的单据', 'err'); if (!todo) return toast('没有待发送的单据', 'err');
if (!confirm(`确认发送 ${todo} 份通知书到企业微信?`)) return; if (!confirm(`确认发送 ${todo} 份通知书到企业微信?`)) return;
...@@ -1288,6 +2093,12 @@ async function rerenderBatch(btn, kind) { ...@@ -1288,6 +2093,12 @@ async function rerenderBatch(btn, kind) {
$('#btn-send-all').addEventListener('click', () => sendAll('#btn-send-all', '发送全部待发', 'elec')); $('#btn-send-all').addEventListener('click', () => sendAll('#btn-send-all', '发送全部待发', 'elec'));
$('#btn-rerender').addEventListener('click', ev => rerenderBatch(ev.currentTarget, 'elec')); $('#btn-rerender').addEventListener('click', ev => rerenderBatch(ev.currentTarget, 'elec'));
$('#btn-detail-verify').addEventListener('click', () => {
if (batchOf('elec')) enterPeriodVerify(BATCH.period_label);
});
$('#btn-detail-reingest').addEventListener('click', () => {
if (batchOf('elec')) enterReingest(BATCH.batch_id);
});
$('#rp-btn-send-all').addEventListener('click', () => sendAll('#rp-btn-send-all', '发送全部待发', 'rent_parking')); $('#rp-btn-send-all').addEventListener('click', () => sendAll('#rp-btn-send-all', '发送全部待发', 'rent_parking'));
$('#rp-btn-rerender').addEventListener('click', ev => rerenderBatch(ev.currentTarget, 'rent_parking')); $('#rp-btn-rerender').addEventListener('click', ev => rerenderBatch(ev.currentTarget, 'rent_parking'));
...@@ -1471,9 +2282,10 @@ const PLAN_STATE = { ...@@ -1471,9 +2282,10 @@ const PLAN_STATE = {
off: ['gray', '总开关关闭'], off: ['gray', '总开关关闭'],
paused: ['gray', '未启用'], paused: ['gray', '未启用'],
idle: ['gray', '无待发账单'], idle: ['gray', '无待发账单'],
wait: ['info', '待发送'], wait: ['info', '等待中'],
due: ['wait', '到点待发'], due: ['wait', '到点待发'],
fired: ['ok', '今日已触发'], fired: ['ok', '今日已触发'],
manual: ['info', '手动发送'],
}; };
function planStateHtml(p) { function planStateHtml(p) {
...@@ -1676,7 +2488,8 @@ document.addEventListener('change', e => { ...@@ -1676,7 +2488,8 @@ document.addEventListener('change', e => {
let PLANS = []; // 两张账单的计划(含运行状态) let PLANS = []; // 两张账单的计划(含运行状态)
function renderPlans(list) { function renderPlans(list) {
PLANS = list || []; // 只呈现「参与定时自动发送」的账单:电费通知单已改全手动,不在这里配置(不重复展示)
PLANS = (list || []).filter(p => p.auto !== false);
const box = $('#planRows'); const box = $('#planRows');
if (!PLANS.length) { box.innerHTML = '<tr><td colspan="7" class="empty">暂无计划</td></tr>'; return; } if (!PLANS.length) { box.innerHTML = '<tr><td colspan="7" class="empty">暂无计划</td></tr>'; return; }
box.innerHTML = PLANS.map(p => ` box.innerHTML = PLANS.map(p => `
...@@ -1686,7 +2499,7 @@ function renderPlans(list) { ...@@ -1686,7 +2499,7 @@ function renderPlans(list) {
<td class="mono-sm">${esc(p.column)}</td> <td class="mono-sm">${esc(p.column)}</td>
<td><label class="sw"><input type="checkbox" data-f="enabled" ${p.enabled ? 'checked' : ''}></label></td> <td><label class="sw"><input type="checkbox" data-f="enabled" ${p.enabled ? 'checked' : ''}></label></td>
<td><input type="number" min="0" max="60" step="1" data-f="advance_days" <td><input type="number" min="0" max="60" step="1" data-f="advance_days"
value="${Number(p.advance_days) || 0}" title="缴费期限前 N 天"></td> value="${Number(p.advance_days) || 0}" title="台账「下一账单日」前 N 天"></td>
<td><input type="time" data-f="send_time" value="${esc(p.send_time || '09:00')}"></td> <td><input type="time" data-f="send_time" value="${esc(p.send_time || '09:00')}"></td>
<td><label class="sw"><input type="checkbox" data-f="skip_rentfree" <td><label class="sw"><input type="checkbox" data-f="skip_rentfree"
${p.skip_rentfree ? 'checked' : ''}> 跳过</label></td> ${p.skip_rentfree ? 'checked' : ''}> 跳过</label></td>
...@@ -2108,8 +2921,20 @@ $('#btn-ow-reset').addEventListener('click', async () => { ...@@ -2108,8 +2921,20 @@ $('#btn-ow-reset').addEventListener('click', async () => {
$('#btn-save-notify').addEventListener('click', async () => { $('#btn-save-notify').addEventListener('click', async () => {
const plans = {}; const plans = {};
$$('#planRows tr[data-plan]').forEach(tr => { $$('#planRows tr[data-plan]').forEach(tr => {
const key = tr.dataset.plan;
const meta = PLANS.find(p => p.key === key) || {};
const f = k => tr.querySelector(`[data-f="${k}"]`); const f = k => tr.querySelector(`[data-f="${k}"]`);
plans[tr.dataset.plan] = { const auto = meta.auto !== false;
if (!auto) { // 手动发送的账单:保留原值,只带免租期开关
plans[key] = {
enabled: false,
advance_days: Number(meta.advance_days) || 0,
send_time: meta.send_time || '09:00',
skip_rentfree: f('skip_rentfree') ? f('skip_rentfree').checked : !!meta.skip_rentfree,
};
return;
}
plans[key] = {
enabled: f('enabled').checked, enabled: f('enabled').checked,
advance_days: Number(f('advance_days').value || 0), advance_days: Number(f('advance_days').value || 0),
send_time: f('send_time').value || '09:00', send_time: f('send_time').value || '09:00',
...@@ -2124,11 +2949,11 @@ $('#btn-save-notify').addEventListener('click', async () => { ...@@ -2124,11 +2949,11 @@ $('#btn-save-notify').addEventListener('click', async () => {
rows: Object.entries(plans).map(([k, v]) => ({ rows: Object.entries(plans).map(([k, v]) => ({
k: nameOf(k).slice(0, 6), k: nameOf(k).slice(0, 6),
v: v.enabled v: v.enabled
? `缴费期限前 <b>${v.advance_days}</b> 天,<b>${esc(v.send_time)}</b> 自动发送` ? `「下一账单日」前 <b>${v.advance_days}</b> 天,<b>${esc(v.send_time)}</b> 自动发送`
+ (v.skip_rentfree ? ',免租期内跳过' : '') + (v.skip_rentfree ? ',免租期内跳过' : '')
: '<span class="muted">不自动发送</span>', : '<span class="muted">不自动发送</span>',
})), })),
warn: '保存后立即生效:后台每 30 秒巡检一次,触发日 = 缴费期限 − 提前天数。', warn: '保存后立即生效:后台每 30 秒巡检一次,触发日 = 台账「下一账单日」− 提前天数。',
okText: '确认保存', okText: '确认保存',
}); });
if (!ok) return; if (!ok) return;
...@@ -2145,9 +2970,10 @@ $('#btn-save-notify').addEventListener('click', async () => { ...@@ -2145,9 +2970,10 @@ $('#btn-save-notify').addEventListener('click', async () => {
} }
}); });
CFG = d.config; CFG = d.config;
const autoN = Object.entries(plans).filter(([, p]) => p.enabled).length;
$('#planMsg').innerHTML = `<div class="banner ok" style="margin-top:12px"> $('#planMsg').innerHTML = `<div class="banner ok" style="margin-top:12px">
已保存:总开关 <b>${CFG.notify.enabled ? '启用' : '关闭'}</b>,共 已保存:总开关 <b>${CFG.notify.enabled ? '启用' : '关闭'}</b>;
<b>${Object.values(plans).filter(p => p.enabled).length}</b> 张账单参与自动发送。</div>`; <b>${autoN}</b> 张账单参与自动发送。</div>`;
toast('发送计划已保存', 'ok'); toast('发送计划已保存', 'ok');
await refreshPlans(true); await refreshPlans(true);
refreshSideStatus(); refreshSideStatus();
...@@ -2875,6 +3701,7 @@ function viewerSuffix(name) { ...@@ -2875,6 +3701,7 @@ function viewerSuffix(name) {
: '已以只读访客身份登录:只能浏览,不能修改', ''); : '已以只读访客身份登录:只能浏览,不能修改', '');
} }
renderElecRows(); renderElecRows();
loadMasterSpec(); // 楼层总表(3F/6F/7F)的录入规格
loadUnits(); loadUnits();
loadElecDefaults(); loadElecDefaults();
loadDirectory(); // 名册候选(台账 / 电费录入的联想都靠它) loadDirectory(); // 名册候选(台账 / 电费录入的联想都靠它)
......
...@@ -460,6 +460,19 @@ body:not(.cap-elec) [data-cap="elec"] { display: none !important; } ...@@ -460,6 +460,19 @@ body:not(.cap-elec) [data-cap="elec"] { display: none !important; }
} }
.chk input { width: auto; margin: 0; } .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 { .grid-wrap {
overflow: auto; overflow: auto;
max-height: 62vh; max-height: 62vh;
...@@ -653,6 +666,48 @@ td.e-note > input:focus { border-color: var(--brand); outline: none; } ...@@ -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; 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 { white-space: nowrap; vertical-align: middle; }
td.rc-cell .chk { font-size: 12px; margin: 0; } 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