diff --git a/README.md b/README.md index c6c57a2..ec3cda5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ account_config + accounting_config 记账配置 + currency + exchange_rate 初 - `currency_create/update/delete.dspy` — 币种维护 - `exchange_rate_create/update/delete.dspy` — 汇率维护 - `fetch_forex_rates.dspy` — 拉取外部外汇牌价 +- `dayend.dspy` — 日终切日(localhost 门禁 + 当日幂等守卫)。三步一个事务: + `dayend_balance`(上一营业日余额快照进 acc_balance)→ `accounting_ledger`(总账) + → `new_business_date`(params.business_date +1 天)。供宿主应用 crontab 每日调用 + (pipeline-app 生产:`5 0 * * * curl -s http://127.0.0.1:9090/accounting/api/dayend.dspy`)。 + ⚠️ 依赖 params 表已有 business_date 行,缺失会抛 BusinessDateParamsError——生产播种 + 见 pipeline-app 迁移 m0017。历史 bug(2026-09-09 修复):`dayend_balance.py`/`ledger.py` + 曾写 `from accounting.businessdate import ...`(实际模块在 appbase),导入即 + ModuleNotFoundError,日结/总账从未可运行。 **credit_limit/(信用额度管理)**: diff --git a/accounting/dayend_balance.py b/accounting/dayend_balance.py index 9e785ef..d6a5e39 100644 --- a/accounting/dayend_balance.py +++ b/accounting/dayend_balance.py @@ -1,18 +1,30 @@ from datetime import datetime from sqlor.dbpools import DBPools from appPublic.uniqueID import getID -from accounting.businessdate import previous_business_date +from appbase.businessdate import previous_business_date from accounting.const import * -async def dayend_balance(): - dat = await previous_business_date() - ts = datetime.now() - sql = """select a.* from (select accountid, max(acc_date) as acc_date, balance from acc_balance where accountid is not null group by accountid) a where acc_date < ${acc_date}$""" - db = DBPools() - async with db.sqlorContext(DBNAME()) as sor: +async def dayend_balance(sor=None): + """日结:把「上一营业日」之前没有余额行的账户快照成 acc_balance 行。 + + 返回快照的营业日(dat)。可注入 sor(切日端点在同一事务上下文里串 + new_business_date → dayend_balance → accounting_ledger 三步)。 + 历史 bug 修复(2026-09-09):原 import accounting.businessdate 是错的 + (businessdate 在 appbase 模块),导入即 ModuleNotFoundError——本函数 + 从未被任何入口调用过,切日链路整体缺位。 + """ + async def _f(sor): + dat = await previous_business_date(sor=sor) + sql = """select a.* from (select accountid, max(acc_date) as acc_date, balance from acc_balance where accountid is not null group by accountid) a where acc_date < ${acc_date}$""" recs = await sor.sqlExe(sql, {'acc_date':dat}) for r in recs: r['id'] = getID() r['acc_date'] = dat await sor.C('acc_balance', r) + return dat + if sor: + return await _f(sor) + db = DBPools() + async with db.sqlorContext(DBNAME()) as sor: + return await _f(sor) diff --git a/accounting/ledger.py b/accounting/ledger.py index 121401e..8e08340 100644 --- a/accounting/ledger.py +++ b/accounting/ledger.py @@ -1,7 +1,7 @@ from datetime import datetime from appPublic.uniqueID import getID from appPublic.timeUtils import strdate_add -from accounting.businessdate import get_business_date +from appbase.businessdate import get_business_date async def accounting_ledger(sor): rd = await get_business_date(sor) diff --git a/wwwroot/api/dayend.dspy b/wwwroot/api/dayend.dspy new file mode 100644 index 0000000..550719d --- /dev/null +++ b/wwwroot/api/dayend.dspy @@ -0,0 +1,48 @@ +# 日终切日任务端点:日结余额快照 + 日结总账 + 营业日期推进 +# GET /accounting/api/dayend.dspy +# +# 安全:本端点是定时任务入口(写库),只允许本机调用——与 fetch_forex_rates.dspy 同模式。 +# 判定依据 client_ip:nginx 用 $proxy_add_x_forwarded_for 追加模式、中间件取链尾值, +# 外部伪造 X-Forwarded-For 会被追加真实 IP 到链尾,伪造不成立;且应用端口不对外开放。 +_ip = request.get('client_ip') or '' +if _ip not in ('127.0.0.1', '::1', 'localhost'): + return json.dumps({'success': False, 'message': '仅允许本机调用(定时任务入口)'}, + ensure_ascii=False) + +from sqlor.dbpools import get_sor_context + +# 切日三步(同一 sor 上下文,全成才提交): +# 1. dayend_balance —— 上一营业日余额快照(acc_balance 补齐无当日行的账户) +# 2. accounting_ledger —— 上一营业日总账(delete+insert 幂等重建 ledger 当日行) +# 3. new_business_date —— params.business_date 推进 +1 天 +# 顺序不可颠倒:前两步都读「上一营业日」,推进日期必须在最后。 +async with get_sor_context(request._run_ns, 'accounting') as sor: + try: + from datetime import date + from accounting.dayend_balance import dayend_balance + from accounting.ledger import accounting_ledger + from appbase.businessdate import get_business_date, new_business_date + + old_bd = await get_business_date(sor) + # 幂等守卫:business_date 已到今天则跳过(cron 重跑/手工重放不会多跳日期) + today = date.today().isoformat() + if old_bd >= today: + return json.dumps({ + 'success': True, 'skipped': True, + 'business_date': old_bd, + 'message': '已是当日营业日期,跳过切日(幂等)', + }, ensure_ascii=False) + + snap_date = await dayend_balance(sor) + await accounting_ledger(sor) + await new_business_date(sor) + new_bd = await get_business_date(sor) + await sor.sqlExe("COMMIT", {}) + return json.dumps({ + 'success': True, + 'business_date': '%s -> %s' % (old_bd, new_bd), + 'snapshot_date': snap_date, + }, ensure_ascii=False) + except Exception as e: + return json.dumps({'success': False, 'message': 'dayend failed: %s' % str(e)[:200]}, + ensure_ascii=False)