Commit 9f0dd9ea authored by wenwen.tang's avatar wenwen.tang 😕

feat(elec): 录入新增「临时保存」草稿,核对态改由前端统一存盘

- config: 新增 DRAFT_DIR=data/drafts,纳入 ensure_dirs 初始化
- store: 草稿读写删 draft_path/save_draft/load_draft/delete_draft(按账号隔离)
- server: 新增 GET|PUT|DELETE /api/drafts/elec;去掉核对 keep_verify 透传
- service: 删除 keep_verify 指纹比对那套(reingest 恢复为「核对一律重置」),
  核对状态不再由服务端猜,改由前端「确认核对」时统一提交
- gitignore: data/drafts/ 属运行时状态,不入库

单测 test_rules/test_round6/test_directory/test_rent/test_round5/test_accounts/test_masters 全绿
parent 7709e427
......@@ -20,6 +20,7 @@ data/scheduler_state.json
data/send_log.json
data/config.json.before-round3
data/batches/
data/drafts/
data/attachments/
logs/
......
......@@ -13,6 +13,7 @@ DATA_DIR = os.path.join(BASE, "data")
BATCH_DIR = os.path.join(DATA_DIR, "batches")
TABLES_DIR = os.path.join(DATA_DIR, "tables") # 台账类表格存档(汇总表 / 合同收入明细)
ATTACH_DIR = os.path.join(DATA_DIR, "attachments") # 电费录入「备注」栏上传的图片附件
DRAFT_DIR = os.path.join(DATA_DIR, "drafts") # 电费录入「临时保存」的草稿(按账号一份)
UPLOAD_DIR = os.path.join(BASE, "uploads")
OUTPUT_DIR = os.path.join(BASE, "output", "elec") # 电费收费通知书产出
OUTPUT_RENT_DIR = os.path.join(BASE, "output", "rent") # 房租车位费收费通知书产出
......@@ -141,6 +142,6 @@ PORT = 8899
def ensure_dirs():
for d in (DATA_DIR, BATCH_DIR, TABLES_DIR, ATTACH_DIR, UPLOAD_DIR, OUTPUT_DIR,
for d in (DATA_DIR, BATCH_DIR, TABLES_DIR, ATTACH_DIR, DRAFT_DIR, UPLOAD_DIR, OUTPUT_DIR,
OUTPUT_RENT_DIR, TABLE_EXPORT_DIR, BATCH_EXPORT_DIR, LOG_DIR, WEB_DIR):
os.makedirs(d, exist_ok=True)
......@@ -36,6 +36,8 @@ Python 标准库 http.server,零外部依赖。启动:
POST /api/periods/verify 账期级核对:{period, units:[室...], done:true}
GET /api/batches/{id} 批次详情(含明细与单据 + 收款/开票统计 + 核对进度)
PUT /api/batches/{id} 重新编辑(重编)该电费批次:覆盖明细并重出单据
(核对状态一律重置;核对中改数据由前端在确认时补一次
账期核对,见 POST /api/periods/verify)
DELETE /api/batches/{id} 删除批次(body.rollback=true 时回滚账期推进)
POST /api/batches/{id}/rerender 按最新配置重出单据
POST /api/batches/{id}/verify 核对:{units:[室...]} 存进度、{done:true} 确认核对
......@@ -57,6 +59,9 @@ Python 标准库 http.server,零外部依赖。启动:
GET /api/rentfree 免租期窗口 / 当前处于免租期的单元
GET /api/units 台账单元 → 现租客(手工录入时自动带出)
units/unit_owners 含车位;rooms/room_owners 已排车位(电费页用)
GET /api/drafts/elec 「临时保存」的录入草稿(当前账号的;没有则 null)
PUT /api/drafts/elec 临时保存本期电费录入(落盘 data/drafts/,按账号隔离)
DELETE /api/drafts/elec 清掉草稿(生成通知书 / 清空录入后自动调用)
GET /api/send-log 发送流水
房租车位费台账
......@@ -106,6 +111,11 @@ def _confirmed(body):
return v is True or str(v).lower() == "true"
def _draft_key(username):
"""电费草稿按**账号**隔离:每个账号各自一份,互不覆盖。"""
return "elec__" + str(username or "anon")
# ------------------------------------------------------------------ 路由
class Handler(BaseHTTPRequestHandler):
server_version = "RentOS/0.1"
......@@ -191,6 +201,8 @@ class Handler(BaseHTTPRequestHandler):
"""
if method == "POST" and path in ("/api/batches", "/api/attachments"):
return auth.CAP_ELEC
if method in ("PUT", "DELETE") and path == "/api/drafts/elec":
return auth.CAP_ELEC # 电费录入草稿(临时保存)
m = re.match(r"^/api/attachments/([A-Za-z0-9]+)$", path)
if m and method == "DELETE":
return auth.CAP_ELEC
......@@ -404,6 +416,11 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/companies":
return self._send(200, {"ok": True, **directory.summary()})
# ---- 电费录入的「临时保存」草稿(按账号隔离,落盘 data/drafts/)
if path == "/api/drafts/elec":
d = store.load_draft(_draft_key(user["username"]))
return self._send(200, {"ok": True, "draft": d or None})
# ---- 电费总表(3F/6F/7F)的录入规格:楼层 / 类目 / 默认系数 / 默认电价
if path == "/api/masters":
cfg = store.load_config()
......@@ -724,6 +741,29 @@ class Handler(BaseHTTPRequestHandler):
ok = store.delete_batch(bid)
return self._send(200, {"ok": ok, "rollback": rollback})
# ---- 电费录入的「临时保存」草稿
if path == "/api/drafts/elec" and method == "PUT":
rows = body.get("rows")
if not isinstance(rows, list):
return self._send(400, {"ok": False, "error": "rows 必须是数组。"})
import datetime as _dt
d = {
"rows": rows,
"period_label": str(body.get("period_label") or ""),
"meter_date": str(body.get("meter_date") or ""),
"notice_date": str(body.get("notice_date") or ""),
"due_date": str(body.get("due_date") or ""),
"count": len(rows),
"updated_at": _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"updated_by": user["username"],
}
store.save_draft(_draft_key(user["username"]), d)
return self._send(200, {"ok": True, "draft": d})
if path == "/api/drafts/elec" and method == "DELETE":
cleared = store.delete_draft(_draft_key(user["username"]))
return self._send(200, {"ok": True, "cleared": bool(cleared)})
# ---- 备注栏图片附件
if path == "/api/attachments" and method == "POST":
meta = dict(attachments.save(body.get("data"), body.get("filename"),
......
......@@ -291,7 +291,10 @@ def reingest(batch_id, rows, period_label=None, notice_date=None, due_date=None,
口径:
· 只支持电费批次(房租车位费按台账出单,改数据请直接改台账);
· 单据按新明细重出,**发送状态按文件名沿用**(内容没变的文件不会退回待发送);
· **核对状态一律重置** —— 数据改过了,必须重新核对才能发送;
· **核对状态一律重置** —— 数据改过了,必须重新核对才能发送。
前端「核对中就地改数据」是**先重编、再补一次账期确认**(见 web/app.js
confirmVerify):重编这一步照旧清空核对,紧接着的确认会把用户勾选的行重新写上,
所以「改数据不影响核对状态」是由调用方保证的,服务端不猜哪些行变了;
· 重出后清理该批次目录下不再使用的旧 PDF;
· masters 传 None 表示「没动总表」(保留原值),传数组则按新值覆盖。
"""
......@@ -354,7 +357,7 @@ def reingest(batch_id, rows, period_label=None, notice_date=None, due_date=None,
return batch
# ---------------------------------------------------------------- 核对(出单前逐室确认)
# ---------------------------------------------------------------- 核对(出单前逐行确认)
def blank_verify():
"""核对状态:rows 是已核对的**行**(「单元|类目」),units 是旧口径的房间(整室全勾)。"""
return {"units": [], "rows": [], "done": False, "at": "", "by": "", "scope": []}
......
......@@ -8,6 +8,7 @@
data/attachments/index.json 备注栏图片附件的索引(本体在 data/attachments/)
data/send_log.json 发送记录(谁、何时、发给谁、成功/失败)
data/sessions.json 登录会话
data/drafts/elec__{账号}.json 电费录入的「临时保存」草稿(按账号隔离)
设计约定:
- 全部 UTF-8、缩进 2、保留中文(ensure_ascii=False)
......@@ -309,3 +310,30 @@ def save_unit_map(m):
def load_unit_map():
with _LOCK:
return _read(C.UNIT_MAP_FILE, {}) or {}
# ---------------------------------------------------------------- 录入草稿(临时保存)
def draft_path(name):
"""草稿文件名:账号名里可能有 @ . 等字符,统一换成 _ 再落盘。"""
safe = re.sub(r"[^0-9A-Za-z_\-]", "_", str(name or "default"))
return os.path.join(C.DRAFT_DIR, f"{safe}.json")
def save_draft(name, obj):
with _LOCK:
_write(draft_path(name), obj)
return obj
def load_draft(name):
with _LOCK:
return _read(draft_path(name))
def delete_draft(name):
with _LOCK:
p = draft_path(name)
if os.path.exists(p):
os.remove(p)
return True
return False
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