From c40f7f665a97ab784883e94c43768b3868e58cdd Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 9 Sep 2026 13:45:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(businessdate):=20=E4=BF=AE=20dayend=5Fbalan?= =?UTF-8?q?ce/ledger=20=E9=94=99=E8=AF=AF=20import=20accounting.businessda?= =?UTF-8?q?te(=E5=AE=9E=E9=99=85=E5=9C=A8=20appbase)=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=8D=B3=20ModuleNotFoundError,=E5=88=87?= =?UTF-8?q?=E6=97=A5/=E6=97=A5=E7=BB=93=E9=93=BE=E8=B7=AF=E4=BB=8E?= =?UTF-8?q?=E6=9C=AA=E5=8F=AF=E7=94=A8;dayend=5Fbalance=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=B3=A8=E5=85=A5=20sor=20=E5=A4=8D=E7=94=A8=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E4=BA=8B=E5=8A=A1=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?+=E8=BF=94=E5=9B=9E=E5=BF=AB=E7=85=A7=E6=97=A5;=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20wwwroot/api/dayend.dspy=20=E5=88=87=E6=97=A5?= =?UTF-8?q?=E7=AB=AF=E7=82=B9(localhost=20=E9=97=A8=E7=A6=81+=E5=BD=93?= =?UTF-8?q?=E6=97=A5=E5=B9=82=E7=AD=89=E5=AE=88=E5=8D=AB,=E4=B8=89?= =?UTF-8?q?=E6=AD=A5:=E6=97=A5=E7=BB=93=E4=BD=99=E9=A2=9D=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E2=86=92=E6=80=BB=E8=B4=A6=E2=86=92business=5Fdate+1)?= =?UTF-8?q?=E4=BE=9B=E7=94=9F=E4=BA=A7=20cron=20=E8=B0=83=E7=94=A8(2026-09?= =?UTF-8?q?-09=20=E7=94=9F=E4=BA=A7=E5=A2=9E=E9=87=8F=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E5=89=8D=E7=BD=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++++ accounting/dayend_balance.py | 26 +++++++++++++------ accounting/ledger.py | 2 +- wwwroot/api/dayend.dspy | 48 ++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 wwwroot/api/dayend.dspy 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)