Compare commits

..

No commits in common. "main" and "feat/modern-ui-accounting" have entirely different histories.

75 changed files with 552 additions and 6152 deletions

17
.gitignore vendored
View File

@ -1,17 +0,0 @@
*.swp
# CRUD auto-generated directories
wwwroot/account/
wwwroot/subject/
wwwroot/acc_balance/
wwwroot/acc_detail/
wwwroot/credit_limit/index.ui
wwwroot/credit_limit/get_credit_limit.dspy
wwwroot/credit_limit/add_credit_limit.dspy
wwwroot/credit_limit/update_credit_limit.dspy
wwwroot/credit_limit/delete_credit_limit.dspy
__pycache__/
*.pyc
*.pyo
*.egg-info/
.DS_Store

153
README.md
View File

@ -1,153 +1,2 @@
# accounting — 记账模块 # accounting
## 模块定位
Sage 平台的**核心记账引擎**:以复式记账(借/贷双腿)为基础,围绕
「科目(subject)→ 账户(account)→ 明细(acc_detail)/ 余额(acc_balance)/ 流水
(accounting_log)」的账务体系,向上提供开户、充值、消费、账单、总账、日结、信用额度、
多币种汇率等能力。
记账的定义 = **写分录明细 + 写账务日志 + 修改账户余额**,三步缺一不可,且余额更新与
透支/信用额度校验必须和明细写入在**同一数据库事务上下文**内完成(原子性铁律)。
模块形态:Python 包 `accounting`(业务逻辑)+ `models/`(表定义)+ `json/`(CRUD 定义)
+ `wwwroot/`(页面/dspy 端点)+ `init/data.json`(种子数据)+ `scripts/`(RBAC 注册)。
宿主应用(sage 主应用)通过 `load_accounting()` 把所有类与函数注入 `ServerEnv`,
供其他模块(product、supplychain、unipay 等)跨模块调用。
核心业务文件:`accounting_config.py`(Accounting 配置类)/ `openaccount.py`(开户)/
`recharge.py`(充值 RechargeBiz)/ `consume.py`(消费记账)/ `bill.py`(write_bill 账单)/
`ledger.py`(总账)/ `dayend_balance.py`(日结) / `settle.py`+`settledate.py`(结算)/
`creditlimit.py`(信用额度)/ `exchange.py`(多币种汇率换算)/ `stats.py`(统计)/
`order_to_bill.py`(订单转账单)。
## 表清单(models/*.json)
| 表名 | 说明 | 关键字段 |
|---|---|---|
| `subject` | 科目表 | name, balance_side(余额方向), subjecttype |
| `account` | 机构账户表 | accounting_orgid, orgid, org1id, currency, subjectid, balance_at, max_detailno |
| `acc_balance` | 账户余额表 | accountid, acc_date, balance |
| `acc_detail` | 账户明细表(分录) | accountid, acc_no, acc_date, acc_timestamp, acc_dir(借/贷), summary, amount |
| `accounting_log` | 账务流水表 | accountid, acc_date, acc_timestamp, acc_dir, summary, amount, billid |
| `account_config` | 账户配置表(参与方类型→科目) | subjectid, partytype, party1type |
| `accounting_config` | 记账配置表(驱动分录双腿) | action, accounting_orgtype, accounting_dir, orgtype, org1type, subjectid, amt_pattern |
| `bill` | 账单 | customerid, resellerid, productid, resourceid, orderid, business_op, amount |
| `bill_detail` | 账单明细 | accounting_orgid, billid, description, participantid, participanttype, subjectname, accounting_dir |
| `biz_order` | 业务订单 | customerid, resellerid, order_date, order_status, business_op, amount, currency |
| `biz_orderdetail` | 业务订单明细 | orderid, productid, product_cnt, prod_config, list_amount, trans_amount, currency |
| `ledger` | 总账表 | accounting_orgid, subjectid, acc_date, d_balance(借方), c_balance(贷方), currency, exchange_rate |
| `credit_limit` | 信用额度表 | accountid, orgid, grant_orgid(授信方,多租户), credit_limit, used_credit, available_credit, valid_from |
| `currency` | 币种 | name, symbol, decimal_places, is_base, status |
| `exchange_rate` | 汇率表 | from_currency, to_currency, buy_rate, sell_rate, mid_rate, effective_date |
种子数据(`init/data.json`):appcodes/appcodes_kv 码表 + subject 科目 +
account_config + accounting_config 记账配置 + currency + exchange_rate 初始汇率。
信用额度建表 SQL 另见 `sql/credit_limit.sql`;多币种迁移见
`scripts/multi_currency_migration.sql`。
## 对外 API / dspy 端点(wwwroot/)
**api/(多币种管理端)**:
- `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/(信用额度管理)**:
- `add/update/delete/get_credit_limit.dspy` — 额度 CRUD
- `credit_manage.ui` / `credit_overview.ui` / `hub.ui` — 管理/总览页面
- `api/credit_summary.dspy`、`api/set_customer_credit.dspy`(+`set_credit_form.ui`) —
额度汇总与客户授信设置
**账务页面端点(wwwroot/ 根)**:
- `myaccounts.dspy/.ui`、`mybalance.dspy`、`accdetail.dspy/.ui` — 我的账户/余额/明细
- `billing.dspy/.ui`、`billing_download.dspy` — 账单查询与下载
- `get_user_balance.dspy`、`oca.dspy`、`usermenu.ui`、`error_accounting.ui`
- 开户端点:`open_owner_accounts.dspy` / `open_provider_accounts.dspy` /
`open_reseller_accounts.dspy` / `open_reseller_provider_accounts.dspy` /
`open_customer_accounts.dspy` / `open_customer_accounts_with_orgid.dspy`
- 代充值:`proxy_recharge.ui` + `proxy_recharge_submit.dspy`
- 主题约定:账务页面(代客充值/我的信用额度/充值错帐处理)**不得硬编码暗色配色**
(#1E293B/#334155/#F1F5F9 等),卡片用 `"css": "card"`、嵌套小盒用 `"css": "subcard"`、
文字颜色继承主题,跟随宿主应用(pipeline-app/sage)的 data-theme 暗亮双主题切换;
语义色(状态徽章/阈值进度条)可保留。2026-09-08 已按此修复。
- 统计 widget:`stat_account_count.ui` / `stat_month_consumption.ui` /
`stat_today_consumption.ui` / `stat_total_balance.ui`
CRUD 定义在 `json/`(account/acc_balance/acc_detail/accounting_log/accounting_config/
account_config/subject/credit_limit/currency_list/exchange_rate_list),其中
`json/build.sh` 用 `xls2ui -m ../models -o ../wwwroot accounting *.json` 从模型生成
wwwroot 页面——**改 CRUD 走 json 定义重新生成,勿手改产物**。
## load 注册函数
宿主在初始化时调用:
```python
from accounting.init import load_accounting
load_accounting()
```
`load_accounting()` 把以下能力注入 `ServerEnv`(跨模块可见):
- **配置/业务类**:`Accounting`(记账配置类)、`RechargeBiz`(充值业务)
- **记账函数**:`consume_accounting`、`recharge_accounting`、`write_bill`
- **开户函数**:`openOwnerAccounts` / `openProviderAccounts` / `openResellerAccounts` /
`openCustomerAccounts` / `openRetailRelationshipAccounts`
- **查询函数**:`getAccountBalance`、`getCustomerBalance`、`getAccountByName`、
`get_account_total_amount`、`get_accdetail`、`all_my_accounts`、`get_accounting_stats`
- **信用额度**:`get_credit_limit_for_account`、`update_used_credit`、`set_credit_limit`、
`get_credit_stats`、`get_my_credit_list`、`get_all_customer_credits` + 三个 web 包装
(`get_credit_stats_web` / `get_my_credits_web` / `get_all_credits_web`,供 Jinja2 .ui 用)
- **多币种**:`get_exchange_rate`、`convert_currency`、`convert_to_base`、
`get_user_currency`、`BASE_CURRENCY`
数据库名通过宿主注册的 `get_module_dbname('accounting')` 解析(跨宿主复用时表前缀/库名
映射的关键钩子)。独立调试可跑 `app/acc.py`(自带 webapp + RegisterFunction 的最小宿主)。
## 宿主集成(部署)
1. **安装**:`./py3/bin/pip install pkgs/accounting`(setup.py 打包,包名 accounting,
版本见 `accounting/version.py`)。宿主 `import_init.py` 的 INIT_MODULES 需包含本模块
以导入 `init/data.json` 种子数据(码表/科目/记账配置)。
2. **入口调用**:宿主初始化处 `import` 后显式调用 `load_accounting()`。
3. **RBAC 注册**:`./py3/bin/python pkgs/accounting/scripts/load_path.py [--add-only]`
(自动定位宿主根:含 py3+wwwroot;把 wwwroot 全部 .ui/.dspy 路径注册进 permission
表并给角色授权)。注册后 redis db0 FLUSHDB 刷 RBAC 缓存即生效。
4. **建表**:models/*.json 经 json2ddl 生成 DDL 部署期执行;运行期禁止 schema 变更。
信用额度增量见 `sql/credit_limit.sql`,多币种迁移见 `scripts/multi_currency_migration.sql`。
5. **菜单**:新页面需在 sage 主仓库 `wwwroot/global_menu.ui` 加菜单入口。
6. **i18n**:词条在 `i18n/{zh,en,jp,ko}`,部署时合并进宿主。
新功能四件套核查(缺一即不可见/不可用):`init.py` ServerEnv 暴露 →
`scripts/load_path.py` RBAC 路径 → 宿主 `global_menu.ui` 菜单 → `json/<表>.json` CRUD 定义。
## 部署注意
1. **余额更新不是可选项**:只写 acc_detail 不改 account/acc_balance 的实现按定义就是
不完整记账;透支/信用额度校验必须与明细写入同一事务上下文。
2. **数据隔离**:查询类端点(如 get_accdetail)必须按 `orgid` 过滤,防止按 accountid
越权枚举他人账务——新增查询端点照抄该模式。
3. **信用额度多租户**:`grant_orgid` 区分授信方;管理端读全部、客户端只读本机构视图,
迁移按 `sql/credit_limit.sql` 执行。
4. **pip 非 editable 安装**:改码后必须重新 `pip install` 到宿主 py3 再重启,
只 git pull 仓库不会更新 site-packages(可用文件 md5 对比确认加载版本)。
5. **sage 核心记账引擎 vs sageapi 网关是两层**:本仓库是核心引擎(复式记账/双腿/科目),
sageapi 只是轻量 API 网关;credit_limit 等逻辑两层都可能需要但实现方式不同,勿混淆。
6. **先读全模块再改**:PFBiz → Accounting → leg_accounting 是完整既有体系,加功能前先读
`accounting_config.py` / `creditlimit.py` / `consume.py`,勿发明平行实现。
7. RBAC 精确 path 匹配,父路径不覆盖子路径;新增 dspy 必须逐条注册,否则 403。
设计文档见 `docs/平台类系统记账子系统.docx`;测试脚本见 `test/`
(open_account.py / recharge.py / run_test.py)。

View File

@ -1,282 +1,272 @@
import asyncio import asyncio
from traceback import format_exc from traceback import format_exc
import re import re
from operator import itemgetter from operator import itemgetter
from .const import * from .const import *
from .accountingnode import get_parent_orgid from .accountingnode import get_parent_orgid
from .excep import * from .excep import *
from .getaccount import get_account, getAccountByName from .getaccount import get_account, getAccountByName
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from appPublic.log import debug, exception from appPublic.log import debug, exception
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
from appPublic.timeUtils import curDateString from appPublic.timeUtils import curDateString
# from .argsconvert import ArgsConvert # from .argsconvert import ArgsConvert
from appPublic.argsConvert import ArgsConvert from appPublic.argsConvert import ArgsConvert
from datetime import datetime from datetime import datetime
from .creditlimit import get_credit_limit_for_account, update_used_credit
accounting_config = None
accounting_config = None
class PFBiz:
class PFBiz: async def get_orgid_by_trans_role(self, sor, leg, role):
async def get_orgid_by_trans_role(self, sor, leg, role): pass
pass
async def get_accounting_config(sor):
async def get_accounting_config(sor): global accounting_config
global accounting_config if accounting_config:
if accounting_config: return accounting_config
return accounting_config recs = await sor.R('accounting_config', {})
recs = await sor.R('accounting_config', {}) if len(recs) > 0:
if len(recs) > 0: accounting_config = recs
accounting_config = recs return accounting_config
return accounting_config return None
return None
class Accounting:
class Accounting: """
""" 需要caller功能:
需要caller功能: caller中要有分录中的变量
caller中要有分录中的变量 get_accounting_orgid(leg) 获得记账机构
get_accounting_orgid(leg) 获得记账机构 get_account(leg,accounting_orgid) 获得记账账号(通过科目,机构类型,账务机构确定一个唯一的账号)
get_account(leg,accounting_orgid) 获得记账账号(通过科目,机构类型,账务机构确定一个唯一的账号) """
""" def __init__(self, caller):
def __init__(self, caller): debug(f'caller={caller}')
debug(f'caller={caller}') if isinstance(caller, list):
if isinstance(caller, list): self.callers = caller
self.callers = caller caller = self.callers[0]
caller = self.callers[0] else:
else: self.callers = [caller]
self.callers = [caller] self.caller = caller
self.caller = caller
async def setup_all_accounting_legs(self):
async def setup_all_accounting_legs(self): self.accounting_legs = []
self.accounting_legs = [] debug(f'{self.callers=}')
debug(f'{self.callers=}') for i, caller in enumerate(self.callers):
for i, caller in enumerate(self.callers): self.caller = caller
self.caller = caller self.curdate = caller.curdate
self.curdate = caller.curdate self.realtimesettled = False
self.realtimesettled = False self.timestamp = caller.timestamp
self.timestamp = caller.timestamp self.billid = caller.billid
self.billid = caller.billid self.action = caller.action
self.action = caller.action self.summary = f'{self.caller.orderid}:{self.caller.billid}'
self.summary = f'{self.caller.orderid}:{self.caller.billid}' self.providerid = caller.providerid
self.providerid = caller.providerid self.productid = caller.productid
self.productid = caller.productid self.resellerid = caller.resellerid
self.resellerid = caller.resellerid self.customerid = caller.customerid
self.customerid = caller.customerid self.own_salemode = None
self.own_salemode = None self.reseller_salemode = None
self.reseller_salemode = None self.variable = caller.variable
self.variable = caller.variable await self.setup_accounting_legs(i)
await self.setup_accounting_legs(i) try:
try: legs = sorted(
legs = sorted( self.accounting_legs,
self.accounting_legs, key=lambda x: (
key=lambda x: ( x.get('accounting_orgid','0'),
x.get('accounting_orgid','0'), x.get('orgid', ''),
x.get('orgid', ''), x.get('subjectid', ''),
x.get('subjectid', ''), 0 if x.get('acc_dir', '0') == x.get('balance_at', '0') else 1
0 if x.get('acc_dir', '0') == x.get('balance_at', '0') else 1 )
) )
) self.accounting_legs = legs
self.accounting_legs = legs except Exception as e:
except Exception as e: exception(f'{self.accounting_legs=}, {e=}\n{format_exc()}')
exception(f'{self.accounting_legs=}, {e=}\n{format_exc()}')
await self.get_legs_account()
await self.get_legs_account()
async def setup_accounting_legs(self, pos):
async def setup_accounting_legs(self, pos): sor = self.sor
sor = self.sor action = self.action.split('_')[0]
action = self.action.split('_')[0] acfg = await get_accounting_config(self.sor)
acfg = await get_accounting_config(self.sor) legs = [r.copy() for r in acfg
legs = [r.copy() for r in acfg if r.action == action ]
if r.action == action ] debug(f'{legs=}')
debug(f'{legs=}') rev = self.action.endswith('_REVERSE')
rev = self.action.endswith('_REVERSE') for l in legs:
for l in legs: l['position'] = pos
l['position'] = pos if rev:
if rev: l['acc_dir'] = DEBT if l['accounting_dir'] == CREDIT else CREDIT
l['acc_dir'] = DEBT if l['accounting_dir'] == CREDIT else CREDIT else:
else: l['acc_dir'] = l['accounting_dir']
l['acc_dir'] = l['accounting_dir'] ac = ArgsConvert('${', '}$')
ac = ArgsConvert('${', '}$') try:
try: amtstr = ac.convert(l['amt_pattern'],
amtstr = ac.convert(l['amt_pattern'], self.variable.copy()
self.variable.copy() )
) debug(f'{l["amt_pattern"]=}, {amtstr=}, {self.variable=}')
debug(f'{l["amt_pattern"]=}, {amtstr=}, {self.variable=}') if isinstance(amtstr, str):
if isinstance(amtstr, str): l['amount'] = eval(amtstr)
l['amount'] = eval(amtstr) else:
else: l['amount'] = amtstr
l['amount'] = amtstr
except Exception as e:
except Exception as e: exception(f"{e=}, {l['amt_pattern']}, {self.variable=}")
exception(f"{e=}, {l['amt_pattern']}, {self.variable=}") raise e
raise e
if l['amount'] is None:
if l['amount'] is None: debug(f'amount is None:{l["amt_pattern"]}, {self.variable=},{self.caller.billid=}')
debug(f'amount is None:{l["amt_pattern"]}, {self.variable=},{self.caller.billid=}') raise AccountingAmountIsNone(self.caller.billid)
raise AccountingAmountIsNone(self.caller.billid) accounting_orgid = await self.caller.get_orgid_by_trans_role(sor, l, l.accounting_orgtype)
accounting_orgid = await self.caller.get_orgid_by_trans_role(sor, l, l.accounting_orgtype) orgid = await self.caller.get_orgid_by_trans_role(sor, l, l.orgtype)
orgid = await self.caller.get_orgid_by_trans_role(sor, l, l.orgtype) org1id = None if l.org1type is None else \
org1id = None if l.org1type is None else \ await self.caller.get_orgid_by_trans_role(sor, l, l.org1type)
await self.caller.get_orgid_by_trans_role(sor, l, l.org1type) l['accounting_orgid'] = accounting_orgid
l['accounting_orgid'] = accounting_orgid l['orgid'] = orgid
l['orgid'] = orgid l['org1id'] = org1id
l['org1id'] = org1id self.accounting_legs += legs
self.accounting_legs += legs
async def get_legs_account(self):
async def get_legs_account(self): sor = self.sor
sor = self.sor oldk = ''
oldk = '' acc = None
acc = None for l in self.accounting_legs:
for l in self.accounting_legs: k = f'{l.accounting_orgid}|{l.orgid}|{l.subjectid}|{l.org1id}'
k = f'{l.accounting_orgid}|{l.orgid}|{l.subjectid}|{l.org1id}' if oldk != k:
if oldk != k: acc = await get_account(sor, l.accounting_orgid, l.orgid,
acc = await get_account(sor, l.accounting_orgid, l.orgid, l.subjectid, org1id=l.org1id, update=True)
l.subjectid, org1id=l.org1id, update=True) if acc is None:
if acc is None: debug(f'can not get accountid {l.accounting_orgid=}, {l.orgid=},{l.subjectid=}, {l.org1id=}, {self.customerid=},{self.resellerid=},{self.providerid=}')
debug(f'can not get accountid {l.accounting_orgid=}, {l.orgid=},{l.subjectid=}, {l.org1id=}, {self.customerid=},{self.resellerid=},{self.providerid=}') raise AccountIdNone(l.accounting_orgid, l.orgid, l.subjectid)
raise AccountIdNone(l.accounting_orgid, l.orgid, l.subjectid) oldk = k
oldk = k l['accid'] = acc.id
l['accid'] = acc.id l['balance_at'] = acc.balance_at
l['balance_at'] = acc.balance_at l['acc'] = acc
l['acc'] = acc
def check_accounting_balance(self, legs):
def check_accounting_balance(self, legs): debt_balance = 0.0
debt_balance = 0.0 credit_balance = 0.0
credit_balance = 0.0 for l in legs:
for l in legs: if l['acc_dir'] != l['balance_at']:
if l['acc_dir'] != l['balance_at']: l['balance_amount'] = -l['amount']
l['balance_amount'] = -l['amount'] else:
else: l['balance_amount'] = l['amount']
l['balance_amount'] = l['amount'] if l['acc_dir'] == DEBT:
if l['acc_dir'] == DEBT: debt_balance += l['amount']
debt_balance += l['amount'] else:
else: credit_balance += l['amount']
credit_balance += l['amount'] if abs(credit_balance - debt_balance) >= 0.00001:
if abs(credit_balance - debt_balance) >= 0.0001: e = Exception('accounting legs not balance')
e = Exception('accounting legs not balance') exception(f'{legs=}, {e=}')
exception(f'{legs=}, {e=}') raise e
raise e
async def do_accounting(self, sor):
async def do_accounting(self, sor): self.sor = sor
self.sor = sor await self.setup_all_accounting_legs()
await self.setup_all_accounting_legs() # debug(f'do_accounting() ...{self.accounting_legs=}')
# debug(f'do_accounting() ...{self.accounting_legs=}') legs = [ l for l in self.accounting_legs if l['amount'] > 0.0001 ]
legs = [ l for l in self.accounting_legs if l['amount'] > 0.0001 ] self.accounting_legs = legs
self.accounting_legs = legs self.check_accounting_balance(self.accounting_legs)
self.check_accounting_balance(self.accounting_legs) for leg in self.accounting_legs:
for leg in self.accounting_legs: await self.leg_accounting(sor, leg)
await self.leg_accounting(sor, leg)
async def write_settle_log(self):
async def write_settle_log(self): sale_mode = {
sale_mode = { SALEMODE_DISCOUNT:'0',
SALEMODE_DISCOUNT:'0', SALEMODE_REBATE:'1',
SALEMODE_REBATE:'1', SALEMODE_FLOORPRICE:'2'
SALEMODE_FLOORPRICE:'2' }
} ns = {
ns = { 'id':getID(),
'id':getID(), 'accounting_orgid':self.accounting_orgid,
'accounting_orgid':self.accounting_orgid, 'providerid':self.providerid,
'providerid':self.providerid, 'sale_mode':sale_mode.get(self.own_salemode),
'sale_mode':sale_mode.get(self.own_salemode), 'settle_date':self.curdate,
'settle_date':self.curdate, 'settle_amt':self.accounting_legs[-1]['amount']
'settle_amt':self.accounting_legs[-1]['amount'] }
}
sor = self.sor
sor = self.sor await sor.C('settle_log', ns)
await sor.C('settle_log', ns)
async def leg_accounting(self, sor, leg):
async def leg_accounting(self, sor, leg): # print(f'leg_accounting(), {leg=}')
# print(f'leg_accounting(), {leg=}') if leg['amount'] < 0.00001:
if leg['amount'] < 0.00001: return
return accid = leg['accid']
accid = leg['accid'] sql = "select * from account where id=${accid}$ for update"
sql = "select * from account where id=${accid}$ for update" accounts = await sor.sqlExe(sql, {'accid': accid})
accounts = await sor.sqlExe(sql, {'accid': accid}) if len(accounts) == 0:
if len(accounts) == 0: e = Exception(f'{accid} account not exist')
e = Exception(f'{accid} account not exist') exception(f'{e}')
exception(f'{e}') raise e
raise e account = accounts[0]
account = accounts[0] new_balance = account.balance + leg['balance_amount']
new_balance = account.balance + leg['balance_amount'] if new_balance < -0.0000001:
e = AccountOverDraw(accid, account.balance, leg['amount'])
# Check credit limit if balance goes negative exception(f'{e},{leg=}')
if new_balance < -0.0000001: raise e
credit_limit = await get_credit_limit_for_account(sor, accid)
if credit_limit is None or credit_limit['available_credit'] < abs(new_balance): subjects = await sor.R('subject', {'id': leg['subjectid']})
e = AccountOverDraw(accid, account.balance, leg['amount']) if len(subjects) > 0:
exception(f'{e},{leg=}') leg['subjectname'] = subjects[0].name
raise e # write acc_balance
# Update used credit sql = """select * from acc_balance
await update_used_credit(sor, accid, abs(new_balance)) where accountid=${accid}$
else: and acc_date = ${curdate}$ for update"""
# Balance is non-negative, reset used credit if any recs = await sor.sqlExe(sql, {'accid':accid, 'curdate':self.curdate})
await update_used_credit(sor, accid, 0) if len(recs) == 0:
ns = {
subjects = await sor.R('subject', {'id': leg['subjectid']}) 'id':getID(),
if len(subjects) > 0: 'accountid':accid,
leg['subjectname'] = subjects[0].name 'acc_date':self.curdate,
# write acc_balance 'balance': new_balance
sql = """select * from acc_balance }
where accountid=${accid}$ await sor.C('acc_balance', ns.copy())
and acc_date = ${curdate}$ for update""" else:
recs = await sor.sqlExe(sql, {'accid':accid, 'curdate':self.curdate}) ns = recs[0]
if len(recs) == 0: ns['balance'] = new_balance
ns = { await sor.U('acc_balance', ns.copy())
'id':getID(),
'accountid':accid, # summary = self.summary
'acc_date':self.curdate, ns = {
'balance': new_balance 'id':getID(),
} 'accounting_orgid' : leg['accounting_orgid'],
await sor.C('acc_balance', ns.copy()) 'billid' : self.billid,
else: 'description' : self.summary,
ns = recs[0] 'participantid' : leg['orgid'],
ns['balance'] = new_balance 'participant1id' : leg['org1id'],
await sor.U('acc_balance', ns.copy()) 'participanttype' : leg['orgtype'],
'participant1type' : leg['org1type'],
# summary = self.summary 'subjectname' : leg['subjectname'],
ns = { 'accounting_dir': leg['accounting_dir'],
'id':getID(), 'amount' : leg['amount']
'accounting_orgid' : leg['accounting_orgid'], }
'billid' : self.billid, await sor.C('bill_detail', ns)
'description' : self.summary, logid = getID()
'participantid' : leg['orgid'], ns = {
'participant1id' : leg['org1id'], 'id':logid,
'participanttype' : leg['orgtype'], 'accountid':accid,
'participant1type' : leg['org1type'], 'acc_date':self.curdate,
'subjectname' : leg['subjectname'], 'acc_timestamp':self.timestamp,
'accounting_dir': leg['accounting_dir'], 'acc_dir':leg['acc_dir'],
'amount' : leg['amount'] 'summary':self.summary,
} 'amount':leg['amount'],
await sor.C('bill_detail', ns) 'billid':self.billid
logid = getID() }
ns = { await sor.C('accounting_log', ns.copy())
'id':logid, ns = {
'accountid':accid, 'id':getID(),
'acc_date':self.curdate, 'accountid':accid,
'acc_timestamp':self.timestamp, 'acc_no': account.max_detailno + 1,
'acc_dir':leg['acc_dir'], 'acc_date':self.curdate,
'summary':self.summary, 'acc_timestamp':self.timestamp,
'amount':leg['amount'], 'acc_dir':leg['acc_dir'],
'billid':self.billid 'summary':self.summary,
} 'amount':leg['amount'],
await sor.C('accounting_log', ns.copy()) 'balance': new_balance,
ns = { 'acclogid':logid
'id':getID(), }
'accountid':accid, await sor.C('acc_detail', ns.copy())
'acc_no': account.max_detailno + 1, await sor.U('account', {
'acc_date':self.curdate, 'id': accid,
'acc_timestamp':self.timestamp, 'max_detailno': account.max_detailno + 1,
'acc_dir':leg['acc_dir'], 'balance': new_balance
'summary':self.summary, })
'amount':leg['amount'],
'balance': new_balance,
'acclogid':logid
}
await sor.C('acc_detail', ns.copy())
await sor.U('account', {
'id': accid,
'max_detailno': account.max_detailno + 1,
'balance': new_balance
})

View File

@ -40,9 +40,6 @@ class ConsumeBiz(PFBiz):
self.providerid = od['providerid'] self.providerid = od['providerid']
self.summary = f'{self.action}|{self.customerid}|{self.resellerid}|{self.productid}' self.summary = f'{self.action}|{self.customerid}|{self.resellerid}|{self.productid}'
self.variable = od.variable self.variable = od.variable
self.currency = getattr(od, 'currency', 'CNY')
transamt = getattr(od, 'transamt', 0)
self.base_amount = getattr(od, 'base_amount', transamt)
async def get_orgid_by_trans_role(self, sor, leg, role): async def get_orgid_by_trans_role(self, sor, leg, role):
if role == 'owner': if role == 'owner':

View File

@ -1,198 +0,0 @@
from appPublic.log import debug, exception
from appPublic.uniqueID import getID
from datetime import datetime
async def get_credit_stats(sor, orgid):
"""
Get credit summary statistics for an organization.
Returns total_credit, total_used, total_available, usage_pct, customer_count.
"""
sql = """
SELECT
COALESCE(SUM(credit_limit), 0) as total_credit,
COALESCE(SUM(used_credit), 0) as total_used,
COALESCE(SUM(available_credit), 0) as total_available,
COUNT(*) as customer_count,
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_count,
COUNT(CASE WHEN status = 'expired' THEN 1 END) as expired_count
FROM credit_limit
WHERE grant_orgid = ${orgid}$
"""
recs = await sor.sqlExe(sql, {'orgid': orgid})
if recs and len(recs) > 0:
r = recs[0]
total_credit = float(r.total_credit or 0)
total_used = float(r.total_used or 0)
total_available = float(r.total_available or 0)
usage_pct = round((total_used / total_credit * 100), 1) if total_credit > 0 else 0
return {
'total_credit': total_credit,
'total_used': total_used,
'total_available': total_available,
'usage_pct': usage_pct,
'customer_count': int(r.customer_count or 0),
'active_count': int(r.active_count or 0),
'expired_count': int(r.expired_count or 0)
}
return {
'total_credit': 0, 'total_used': 0, 'total_available': 0,
'usage_pct': 0, 'customer_count': 0, 'active_count': 0, 'expired_count': 0
}
async def get_my_credit_list(sor, orgid):
"""
Get all credit limit records for the current user's organization,
with organization name and account info for display.
"""
sql = """
SELECT
cl.*,
org.orgname as orgname_text,
sub.name as subject_name,
CASE
WHEN cl.credit_limit > 0 THEN ROUND(cl.used_credit / cl.credit_limit * 100, 1)
ELSE 0
END as usage_pct
FROM credit_limit cl
LEFT JOIN organization org ON cl.orgid = org.id COLLATE utf8mb4_unicode_ci
LEFT JOIN account acc ON cl.accountid = acc.id COLLATE utf8mb4_unicode_ci
LEFT JOIN subject sub ON acc.subjectid = sub.id COLLATE utf8mb4_unicode_ci
WHERE cl.orgid = ${orgid}$
ORDER BY cl.created_at DESC
"""
recs = await sor.sqlExe(sql, {'orgid': orgid})
return recs
async def get_all_customer_credits(sor, orgid, status_filter=None):
"""
Get all customer credit limits for management view.
For distributor sales to see all their customers' credit status.
"""
where_clause = "WHERE cl.grant_orgid = ${orgid}$"
params = {'orgid': orgid, 'sort': 'update_at desc'}
if status_filter and status_filter != 'all':
where_clause += " AND cl.status = ${status}$"
params['status'] = status_filter
sql = f"""
SELECT
cl.*,
org.orgname as orgname_text,
sub.name as subject_name,
acc.balance as account_balance,
CASE
WHEN cl.credit_limit > 0 THEN ROUND(cl.used_credit / cl.credit_limit * 100, 1)
ELSE 0
END as usage_pct
FROM credit_limit cl
LEFT JOIN organization org ON cl.orgid = org.id COLLATE utf8mb4_unicode_ci
LEFT JOIN account acc ON cl.accountid = acc.id COLLATE utf8mb4_unicode_ci
LEFT JOIN subject sub ON acc.subjectid = sub.id COLLATE utf8mb4_unicode_ci
{where_clause}
"""
recs = await sor.sqlExe(sql, params.copy())
if len(recs) == 0:
debug(f'{sql=}, {params=} get no data')
return recs
async def get_credit_limit_for_account(sor, accid):
"""
Get active credit limit for an account.
Returns credit_limit record if active and valid, None otherwise.
"""
sql = """
SELECT * FROM credit_limit
WHERE accountid = ${accid}$
AND status = 'active'
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_to IS NULL OR valid_to >= CURRENT_DATE)
ORDER BY created_at DESC
LIMIT 1
"""
recs = await sor.sqlExe(sql, {'accid': accid})
if len(recs) == 0:
return None
return recs[0]
async def update_used_credit(sor, accid, new_used_amount):
"""
Update used_credit and available_credit for an account.
new_used_amount is the absolute value of negative balance.
"""
credit = await get_credit_limit_for_account(sor, accid)
if credit is None:
return
new_used = new_used_amount
new_available = credit['credit_limit'] - new_used
sql = """
UPDATE credit_limit
SET used_credit = ${used}$,
available_credit = ${available}$,
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}$
"""
await sor.sqlExe(sql, {
'used': new_used,
'available': new_available,
'id': credit['id']
})
debug(f'Updated credit for {accid}: used={new_used}, available={new_available}')
async def set_credit_limit(sor, accountid, orgid, credit_limit_amount,
grant_orgid='0', valid_from=None, valid_to=None, created_by=None, remark=None):
"""
Set or update credit limit for an account.
If a credit limit already exists, update it; otherwise create new.
"""
# Check if credit limit exists
existing = await get_credit_limit_for_account(sor, accountid)
if existing:
# Update existing
sql = """
UPDATE credit_limit
SET credit_limit = ${credit_limit}$,
available_credit = ${credit_limit}$ - used_credit,
valid_from = ${valid_from}$,
valid_to = ${valid_to}$,
remark = ${remark}$,
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}$
"""
await sor.sqlExe(sql, {
'credit_limit': credit_limit_amount,
'valid_from': valid_from,
'valid_to': valid_to,
'remark': remark,
'id': existing['id']
})
debug(f'Updated credit limit for {accountid}: {credit_limit_amount}')
return existing['id']
else:
# Create new
new_id = getID()
ns = {
'id': new_id,
'accountid': accountid,
'orgid': orgid,
'grant_orgid': grant_orgid or '0',
'credit_limit': credit_limit_amount,
'used_credit': 0,
'available_credit': credit_limit_amount,
'valid_from': valid_from,
'valid_to': valid_to,
'status': 'active',
'created_at': datetime.now(),
'updated_at': datetime.now(),
'created_by': created_by,
'remark': remark
}
await sor.C('credit_limit', ns)
debug(f'Created credit limit for {accountid}: {credit_limit_amount}')
return new_id

View File

@ -1,30 +1,18 @@
from datetime import datetime from datetime import datetime
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from appbase.businessdate import previous_business_date from accounting.businessdate import previous_business_date
from accounting.const import * from accounting.const import *
async def dayend_balance(sor=None): async def dayend_balance():
"""日结:把「上一营业日」之前没有余额行的账户快照成 acc_balance 行。 dat = await previous_business_date()
ts = datetime.now()
返回快照的营业日(dat)。可注入 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}$"""
new_business_date → dayend_balance → accounting_ledger 三步)。 db = DBPools()
历史 bug 修复(2026-09-09):原 import accounting.businessdate 是错的 async with db.sqlorContext(DBNAME()) as sor:
(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}) recs = await sor.sqlExe(sql, {'acc_date':dat})
for r in recs: for r in recs:
r['id'] = getID() r['id'] = getID()
r['acc_date'] = dat r['acc_date'] = dat
await sor.C('acc_balance', r) 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)

View File

@ -1,103 +0,0 @@
"""Multi-currency exchange rate lookup and currency conversion."""
from datetime import datetime
from sqlor.dbpools import get_sor_context
from ahserver.serverenv import ServerEnv
from appPublic.log import debug, exception
# Fixed base currency (本位币)
BASE_CURRENCY = 'CNY'
async def get_exchange_rate(from_currency: str, to_currency: str,
rate_type: str = 'sell_rate') -> float:
"""Get real-time exchange rate for currency pair.
Args:
from_currency: source currency (e.g. 'USD')
to_currency: target currency (e.g. 'CNY')
rate_type: 'buy_rate' | 'sell_rate' | 'mid_rate'
Returns:
float exchange rate, or 1.0 if same currency
"""
if from_currency == to_currency:
return 1.0
env = ServerEnv()
today = datetime.now().strftime('%Y-%m-%d')
async with get_sor_context(env, 'accounting') as sor:
sql = """SELECT %s FROM exchange_rate
WHERE from_currency = ${from_cur}$
AND to_currency = ${to_cur}$
AND effective_date <= ${today}$
ORDER BY effective_date DESC
LIMIT 1""" % rate_type
ns = {
'from_cur': from_currency,
'to_cur': to_currency,
'today': today,
}
recs = await sor.sqlExe(sql, ns)
if recs:
rate = getattr(recs[0], rate_type, 1.0)
debug(f'exchange_rate: {from_currency}→{to_currency} {rate_type}={rate}')
return float(rate)
# Fallback: try reverse rate
reverse_type = 'sell_rate' if rate_type == 'buy_rate' else 'buy_rate' if rate_type == 'sell_rate' else 'mid_rate'
async with get_sor_context(env, 'accounting') as sor:
sql = """SELECT %s FROM exchange_rate
WHERE from_currency = ${to_cur}$
AND to_currency = ${from_cur}$
AND effective_date <= ${today}$
ORDER BY effective_date DESC
LIMIT 1""" % reverse_type
ns = {
'from_cur': to_currency,
'to_cur': from_currency,
'today': today,
}
recs = await sor.sqlExe(sql, ns)
if recs:
rate = 1.0 / float(getattr(recs[0], reverse_type, 1.0))
debug(f'exchange_rate(reverse): {from_currency}→{to_currency} {rate_type}={rate}')
return rate
exception(f'No exchange rate found: {from_currency}→{to_currency}')
return 1.0
async def convert_currency(amount: float, from_currency: str, to_currency: str,
rate_type: str = 'sell_rate') -> float:
"""Convert amount between currencies at real-time rate."""
if from_currency == to_currency:
return amount
rate = await get_exchange_rate(from_currency, to_currency, rate_type)
return round(amount * rate, 2)
async def convert_to_base(amount: float, from_currency: str,
rate_type: str = 'sell_rate') -> float:
"""Convert amount to base currency (CNY)."""
return await convert_currency(amount, from_currency, BASE_CURRENCY, rate_type)
async def get_user_currency(userorgid: str) -> str:
"""Get user organization's preferred billing currency.
Checks account table — prefers consume account's currency.
Falls back to CNY.
"""
env = ServerEnv()
async with get_sor_context(env, 'accounting') as sor:
# Check consume account currency
sql = """SELECT a.currency FROM account a
JOIN subject s ON a.subjectid = s.id
WHERE a.orgid = ${orgid}$ AND s.name = 'consume'
LIMIT 1"""
recs = await sor.sqlExe(sql, {'orgid': userorgid})
if recs:
return recs[0].currency or BASE_CURRENCY
return BASE_CURRENCY

View File

@ -7,11 +7,8 @@ from .accounting_config import Accounting
from .bill import write_bill from .bill import write_bill
from .openaccount import openOwnerAccounts, openProviderAccounts, openResellerAccounts, openCustomerAccounts, openRetailRelationshipAccounts from .openaccount import openOwnerAccounts, openProviderAccounts, openResellerAccounts, openCustomerAccounts, openRetailRelationshipAccounts
from .getaccount import getAccountBalance, getCustomerBalance, getAccountByName, get_account_total_amount from .getaccount import getAccountBalance, getCustomerBalance, getAccountByName, get_account_total_amount
from .stats import get_accounting_stats
from .recharge import RechargeBiz, recharge_accounting from .recharge import RechargeBiz, recharge_accounting
from .consume import consume_accounting from .consume import consume_accounting
from .creditlimit import get_credit_limit_for_account, update_used_credit, set_credit_limit, get_credit_stats, get_my_credit_list, get_all_customer_credits
from .exchange import get_exchange_rate, convert_currency, convert_to_base, get_user_currency, BASE_CURRENCY
async def all_my_accounts(request): async def all_my_accounts(request):
env = request._run_ns env = request._run_ns
@ -37,18 +34,15 @@ async def get_accdetail(request, accountid, page=1):
env = request._run_ns env = request._run_ns
userorgid = await env.get_userorgid() userorgid = await env.get_userorgid()
async with get_sor_context(env, 'accounting') as sor: async with get_sor_context(env, 'accounting') as sor:
# 数据隔离:只允许查询本机构名下账户的明细,防止按 accountid 越权枚举他人账务
sql = """select a.*, sql = """select a.*,
c.name c.name
from acc_detail a, account b, subject c from acc_detail a, account b, subject c
where b.subjectid = c.id where b.subjectid = c.id
and a.accountid = b.id and a.accountid = b.id
and b.id = ${accountid}$ and b.id = ${accountid}$
and b.orgid = ${orgid}$
""" """
ns = { ns = {
'accountid': accountid, 'accountid': accountid,
'orgid': userorgid,
'page': page, 'page': page,
'sort': 'acc_date desc' 'sort': 'acc_date desc'
} }
@ -77,43 +71,3 @@ def load_accounting():
g.get_accdetail = get_accdetail g.get_accdetail = get_accdetail
g.all_my_accounts = all_my_accounts g.all_my_accounts = all_my_accounts
g.openRetailRelationshipAccounts = openRetailRelationshipAccounts g.openRetailRelationshipAccounts = openRetailRelationshipAccounts
g.get_accounting_stats = get_accounting_stats
g.get_credit_limit_for_account = get_credit_limit_for_account
g.update_used_credit = update_used_credit
g.set_credit_limit = set_credit_limit
g.get_credit_stats = get_credit_stats
g.get_my_credit_list = get_my_credit_list
g.get_all_customer_credits = get_all_customer_credits
g.get_credit_stats_web = get_credit_stats_web
g.get_my_credits_web = get_my_credits_web
g.get_all_credits_web = get_all_credits_web
g.get_exchange_rate = get_exchange_rate
g.convert_currency = convert_currency
g.convert_to_base = convert_to_base
g.get_user_currency = get_user_currency
g.BASE_CURRENCY = BASE_CURRENCY
async def get_credit_stats_web(request):
"""Web wrapper for get_credit_stats - used in Jinja2 .ui templates"""
env = request._run_ns
userorgid = await env.get_userorgid()
async with get_sor_context(env, 'accounting') as sor:
return await get_credit_stats(sor, userorgid)
async def get_my_credits_web(request):
"""Web wrapper for get_my_credit_list - used in Jinja2 .ui templates"""
env = request._run_ns
userorgid = await env.get_userorgid()
async with get_sor_context(env, 'accounting') as sor:
return await get_my_credit_list(sor, userorgid)
async def get_all_credits_web(request):
"""Web wrapper for get_all_customer_credits - used in Jinja2 .ui templates"""
env = request._run_ns
userorgid = await env.get_userorgid()
status_filter = env.params_kw.get('status', None)
async with get_sor_context(env, 'accounting') as sor:
return await get_all_customer_credits(sor, userorgid, status_filter)

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from appPublic.timeUtils import strdate_add from appPublic.timeUtils import strdate_add
from appbase.businessdate import get_business_date from accounting.businessdate import get_business_date
async def accounting_ledger(sor): async def accounting_ledger(sor):
rd = await get_business_date(sor) rd = await get_business_date(sor)

View File

@ -19,8 +19,7 @@ async def openAccount(sor, accounting_orgid, orgid, account_config, org1id=None)
'orgid':orgid, 'orgid':orgid,
'subjectid':account_config['subjectid'], 'subjectid':account_config['subjectid'],
'balance_at':account_config['balance_side'], 'balance_at':account_config['balance_side'],
'max_detailno':0, 'max_detailno':0
'balance': 0.0
} }
if org1id: if org1id:
ns['org1id'] = org1id; ns['org1id'] = org1id;

View File

@ -62,12 +62,6 @@ class RechargeBiz(PFBiz):
ao = Accounting(self) ao = Accounting(self)
await self.write_bill(sor) await self.write_bill(sor)
await ao.do_accounting(sor) await ao.do_accounting(sor)
# Invalidate llmage Redis balance cache so reserve sees new balance
try:
from llmage.balance import invalidate_balance_cache
await invalidate_balance_cache(self.customerid)
except Exception as e:
debug(f'llmage balance cache invalidate skipped: {e}')
return True return True
async def write_bill(self, sor): async def write_bill(self, sor):
@ -79,18 +73,10 @@ async def recharge_accounting(sor, customerid, action, orderid,
""" """
summary:recharge channe(handly, wechat, alipay) summary:recharge channe(handly, wechat, alipay)
""" """
# 2026-09-08 修拼写 bug:原白名单写的是 'RECHARGE_REVESE'(漏 R),而 const.py 的 if action not in ['RECHARGE', 'RECHARGE_REVESE']:
# ACTION_RECHARGE_REVERSE='RECHARGE_REVERSE'、accounting_config.py 判冲正用
# endswith('_REVERSE')、unipay 支付宝退款传 'RECHARGE_REVERSE'——正确值一直被白名单
# 拒绝,错拼值又不被引擎识别为冲正(按普通充值方向记账,错上加错)。
# 错帐冲正与支付宝退款冲正因此长期不可用。改回正确值;兼容旧错拼防存量调用方。
if action not in ['RECHARGE', ACTION_RECHARGE_REVERSE, 'RECHARGE_REVESE']:
e = Exception(f'get a wrong recharge action({action})') e = Exception(f'get a wrong recharge action({action})')
exception(f'{e}') exception(f'{e}')
raise e raise e
# 旧错拼归一为正确值(引擎按 endswith('_REVERSE') 判冲正方向)
if action == 'RECHARGE_REVESE':
action = ACTION_RECHARGE_REVERSE
recharge_log = { recharge_log = {
"customerid":customerid, "customerid":customerid,
"transdate":transdate, "transdate":transdate,

View File

@ -1,77 +0,0 @@
from appPublic.log import debug, exception
from sqlor.dbpools import get_sor_context
from datetime import datetime, timedelta
async def get_accounting_stats(request):
"""Get accounting statistics for the current user's organization"""
env = request._run_ns
userorgid = await env.get_userorgid()
now = datetime.now()
today_start = now.strftime('%Y-%m-%d')
month_start = now.strftime('%Y-%m-01')
tomorrow = (now + timedelta(days=1)).strftime('%Y-%m-%d')
stats = {
'total_balance': 0,
'today_amount': 0,
'month_amount': 0,
'account_count': 0
}
async with get_sor_context(request._run_ns, 'accounting') as sor:
# Total balance across all accounts
sql_balance = """
SELECT COALESCE(SUM(CASE WHEN balance_at = '1' THEN balance ELSE -balance END), 0) as total
FROM account
WHERE orgid = ${orgid}$
"""
recs = await sor.sqlExe(sql_balance, {'orgid': userorgid})
if recs:
stats['total_balance'] = float(recs[0].total or 0)
# Account count
sql_count = """
SELECT COUNT(*) as cnt FROM account WHERE orgid = ${orgid}$
"""
recs = await sor.sqlExe(sql_count, {'orgid': userorgid})
if recs:
stats['account_count'] = int(recs[0].cnt or 0)
# Today's consumption (acc_dir=1 means debit/consumption)
sql_today = """
SELECT COALESCE(SUM(amount), 0) as total
FROM acc_detail a
JOIN account b ON a.accountid = b.id COLLATE utf8mb4_unicode_ci
WHERE b.orgid = ${orgid}$
AND a.acc_dir = 1
AND a.acc_date >= ${from_date}$
AND a.acc_date < ${to_date}$
"""
recs = await sor.sqlExe(sql_today, {
'orgid': userorgid,
'from_date': today_start,
'to_date': tomorrow
})
if recs:
stats['today_amount'] = float(recs[0].total or 0)
# This month's consumption
sql_month = """
SELECT COALESCE(SUM(amount), 0) as total
FROM acc_detail a
JOIN account b ON a.accountid = b.id COLLATE utf8mb4_unicode_ci
WHERE b.orgid = ${orgid}$
AND a.acc_dir = 1
AND a.acc_date >= ${from_date}$
AND a.acc_date < ${to_date}$
"""
recs = await sor.sqlExe(sql_month, {
'orgid': userorgid,
'from_date': month_start,
'to_date': tomorrow
})
if recs:
stats['month_amount'] = float(recs[0].total or 0)
return stats

View File

@ -1,174 +0,0 @@
余额: Balance
明细: Details
科目: Account
账户: Account
会计科目管理: Chart of Accounts Management
科目编码: Account Code
科目名称: Account Name
科目类型: Account Type
上级科目: Parent Account
科目级别: Account Level
辅助核算: Auxiliary Accounting
状态: Status
启用: Enable
停用: Disable
新增科目: Add Account
编辑科目: Edit Account
删除科目: Delete Account
搜索: Search
科目编码或名称: Account Code or Name
资产: Assets
负债: Liabilities
所有者权益: Owner's Equity
成本: Cost
损益: Profit & Loss
借方: Debit
贷方: Credit
方向: Direction
期初余额: Opening Balance
期末余额: Closing Balance
本期借方: Current Period Debit
本期贷方: Current Period Credit
累计借方: Cumulative Debit
累计贷方: Cumulative Credit
凭证日期: Voucher Date
凭证号: Voucher No.
摘要: Summary
金额: Amount
合计: Total
制单人: Prepared By
审核人: Reviewed By
记账人: Posted By
未记账: Unposted
已记账: Posted
已审核: Approved
未审核: Unapproved
新增凭证: Add Voucher
编辑凭证: Edit Voucher
删除凭证: Delete Voucher
审核凭证: Review Voucher
记账: Post
反记账: Unpost
凭证类型: Voucher Type
收款凭证: Receipt Voucher
付款凭证: Payment Voucher
转账凭证: Transfer Voucher
记账凭证: Journal Voucher
附件数: Attachments
总账: General Ledger
明细账: Subsidiary Ledger
科目余额表: Trial Balance of Accounts
资产负债表: Balance Sheet
利润表: Income Statement
现金流量表: Cash Flow Statement
试算平衡: Trial Balance
会计期间: Accounting Period
年度: Year
月份: Month
期初: Period Start
期末: Period End
本年累计: YTD
本月合计: Monthly Total
过账: Posting
结账: Closing
反结账: Unclosing
凭证查询: Voucher Query
科目查询: Account Query
辅助核算项目: Auxiliary Accounting Item
客户: Customer
供应商: Supplier
部门: Department
项目: Project
员工: Employee
核算类别: Accounting Category
新增: Add
保存: Save
取消: Cancel
确认: Confirm
删除: Delete
编辑: Edit
查看: View
导出: Export
打印: Print
刷新: Refresh
返回: Back
提交: Submit
重置: Reset
Conform: Conform
Discard: Discard
Submit: Submit
Reset: Reset
Cancel: Cancel
凭证: Voucher
总分类账: General Ledger
核算项目余额: Accounting Item Balance
科目汇总表: Account Summary
数量: Quantity
单价: Unit Price
外币: Foreign Currency
汇率: Exchange Rate
原币: Original Currency
本位币: Local Currency
辅助账: Auxiliary Ledger
日记账: Journal
多栏账: Multi-column Ledger
核算项目明细账: Accounting Item Detail Ledger
数量金额明细账: Quantity-Amount Detail Ledger
数量金额总账: Quantity-Amount General Ledger
固定资产: Fixed Assets
工资: Salary
往来: Current Account
自定义辅助核算: Custom Auxiliary Accounting
核算项目: Accounting Item
核算类别名称: Accounting Category Name
辅助核算编码: Auxiliary Accounting Code
辅助核算名称: Auxiliary Accounting Name
余额方向: Balance Direction
余额方向(借/贷): Balance Direction (Debit/Credit)
借方发生额: Debit Amount
贷方发生额: Credit Amount
借方累计: Cumulative Debit
贷方累计: Cumulative Credit
年初余额: Year Opening Balance
年累计借方: YTD Debit
年累计贷方: YTD Credit
年累计余额: YTD Balance
本月借方发生额: Monthly Debit Amount
本月贷方发生额: Monthly Credit Amount
本年借方发生额: Annual Debit Amount
本年贷方发生额: Annual Credit Amount
年初借方余额: Year Opening Debit Balance
年初贷方余额: Year Opening Credit Balance
期初借方余额: Opening Debit Balance
期初贷方余额: Opening Credit Balance
期末借方余额: Closing Debit Balance
期末贷方余额: Closing Credit Balance
损益结转: P&L Carry-forward
结转损益: Carry Forward P&L
凭证字号: Voucher Prefix No.
凭证字: Voucher Prefix
记账日期: Posting Date
制单日期: Preparation Date
记账状态: Posting Status
审核状态: Review Status
作废: Void
恢复: Restore
冲销: Reverse
红冲: Red Reverse
反审核: Unapprove
全部: All
已作废: Voided
已冲销: Reversed
操作: Action
备注: Remarks
日期: Date
描述: Description
类型: Type
名称: Name
编码: Code
编号: Number
金额(借方): Amount (Debit)
金额(贷方): Amount (Credit)
金额方向: Amount Direction
记账金额: Posting Amount
凭证编号: Voucher Number

View File

@ -1,174 +0,0 @@
余额: 残高
明细: 明細
科目: 科目
账户: 口座
会计科目管理: 勘定科目管理
科目编码: 科目コード
科目名称: 科目名
科目类型: 科目タイプ
上级科目: 上位科目
科目级别: 科目レベル
辅助核算: 補助核算
状态: ステータス
启用: 有効化
停用: 無効化
新增科目: 科目追加
编辑科目: 科目編集
删除科目: 科目削除
搜索: 検索
科目编码或名称: 科目コードまたは名称
资产: 資産
负债: 負債
所有者权益: 純資産
成本: コスト
损益: 損益
借方: 借方
贷方: 貸方
方向: 方向
期初余额: 期首残高
期末余额: 期末残高
本期借方: 当期借方
本期贷方: 当期貸方
累计借方: 累計借方
累计贷方: 累計貸方
凭证日期: 伝票日付
凭证号: 伝票番号
摘要: 摘要
金额: 金額
合计: 合計
制单人: 作成者
审核人: 承認者
记账人: 記帳者
未记账: 未記帳
已记账: 記帳済み
已审核: 承認済み
未审核: 未承認
新增凭证: 伝票追加
编辑凭证: 伝票編集
删除凭证: 伝票削除
审核凭证: 伝票承認
记账: 記帳
反记账: 記帳取消
凭证类型: 伝票タイプ
收款凭证: 入金伝票
付款凭证: 出金伝票
转账凭证: 振替伝票
记账凭证: 仕訳伝票
附件数: 添付数
总账: 総勘定元帳
明细账: 補助元帳
科目余额表: 科目残高一覧
资产负债表: 貸借対照表
利润表: 損益計算書
现金流量表: キャッシュフロー計算書
试算平衡: 試算表
会计期间: 会計期間
年度: 年度
月份: 月
期初: 期首
期末: 期末
本年累计: 本年累計
本月合计: 当月合計
过账: 転記
结账: 決算
反结账: 決算取消
凭证查询: 伝票照会
科目查询: 科目照会
辅助核算项目: 補助核算項目
客户: 顧客
供应商: 仕入先
部门: 部門
项目: プロジェクト
员工: 従業員
核算类别: 核算カテゴリ
新增: 追加
保存: 保存
取消: キャンセル
确认: 確認
删除: 削除
编辑: 編集
查看: 表示
导出: エクスポート
打印: 印刷
刷新: 更新
返回: 戻る
提交: 送信
重置: リセット
Conform: 確認
Discard: 破棄
Submit: 送信
Reset: リセット
Cancel: キャンセル
凭证: 伝票
总分类账: 総分類帳
核算项目余额: 核算項目残高
科目汇总表: 科目集計表
数量: 数量
单价: 単価
外币: 外貨
汇率: 為替レート
原币: 原通貨
本位币: 自国通貨
辅助账: 補助台帳
日记账: 仕訳帳
多栏账: 多欄帳
核算项目明细账: 核算項目明細帳
数量金额明细账: 数量金額明細帳
数量金额总账: 数量金額総帳
固定资产: 固定資産
工资: 給与
往来: 取引
自定义辅助核算: カスタム補助核算
核算项目: 核算項目
核算类别名称: 核算カテゴリ名
辅助核算编码: 補助核算コード
辅助核算名称: 補助核算名
余额方向: 残高方向
余额方向(借/贷): 残高方向(借方/貸方)
借方发生额: 借方発生額
贷方发生额: 貸方発生額
借方累计: 借方累計
贷方累计: 貸方累計
年初余额: 年初残高
年累计借方: 年間累計借方
年累计贷方: 年間累計貸方
年累计余额: 年間累計残高
本月借方发生额: 当月借方発生額
本月贷方发生额: 当月貸方発生額
本年借方发生额: 本年借方発生額
本年贷方发生额: 本年貸方発生額
年初借方余额: 年初借方残高
年初贷方余额: 年初貸方残高
期初借方余额: 期首借方残高
期初贷方余额: 期首貸方残高
期末借方余额: 期末借方残高
期末贷方余额: 期末貸方残高
损益结转: 損益振替
结转损益: 損益を振り替える
凭证字号: 伝票字番
凭证字: 伝票字
记账日期: 記帳日付
制单日期: 作成日付
记账状态: 記帳ステータス
审核状态: 承認ステータス
作废: 無効
恢复: 復元
冲销: 取消
红冲: 赤取消
反审核: 承認取消
全部: 全部
已作废: 無効済み
已冲销: 取消済み
操作: 操作
备注: 備考
日期: 日付
描述: 説明
类型: タイプ
名称: 名称
编码: コード
编号: 番号
金额(借方): 金額(借方)
金额(贷方): 金額(貸方)
金额方向: 金額方向
记账金额: 記帳金額
凭证编号: 伝票番号

View File

@ -1,174 +0,0 @@
余额: 잔액
明细: 내역
科目: 계정
账户: 계좌
会计科目管理: 회계과목관리
科目编码: 과목코드
科目名称: 과목명
科目类型: 과목유형
上级科目: 상위과목
科目级别: 과목레벨
辅助核算: 보조핵산
状态: 상태
启用: 활성화
停用: 비활성화
新增科目: 과목추가
编辑科目: 과목편집
删除科目: 과목삭제
搜索: 검색
科目编码或名称: 과목코드 또는 명칭
资产: 자산
负债: 부채
所有者权益: 자본
成本: 비용
损益: 손익
借方: 차변
贷方: 대변
方向: 방향
期初余额: 기초잔액
期末余额: 기말잔액
本期借方: 당기차변
本期贷方: 당기대변
累计借方: 누적차변
累计贷方: 누적대변
凭证日期: 전표일자
凭证号: 전표번호
摘要: 적요
金额: 금액
合计: 합계
制单人: 작성자
审核人: 승인자
记账人: 기장자
未记账: 미기장
已记账: 기장완료
已审核: 승인완료
未审核: 미승인
新增凭证: 전표추가
编辑凭证: 전표편집
删除凭证: 전표삭제
审核凭证: 전표승인
记账: 기장
反记账: 기장취소
凭证类型: 전표유형
收款凭证: 수금전표
付款凭证: 지급전표
转账凭证: 대체전표
记账凭证: 분개전표
附件数: 첨부수
总账: 총계정원장
明细账: 보조원장
科目余额表: 과목잔액표
资产负债表: 대차대조표
利润表: 손익계산서
现金流量表: 현금흐름표
试算平衡: 합계잔액표
会计期间: 회계기간
年度: 연도
月份: 월
期初: 기초
期末: 기말
本年累计: 연간누적
本月合计: 당월합계
过账: 전기
结账: 결산
反结账: 결산취소
凭证查询: 전표조회
科目查询: 과목조회
辅助核算项目: 보조핵산항목
客户: 고객
供应商: 공급업체
部门: 부서
项目: 프로젝트
员工: 직원
核算类别: 핵산유형
新增: 추가
保存: 저장
取消: 취소
确认: 확인
删除: 삭제
编辑: 편집
查看: 보기
导出: 내보내기
打印: 인쇄
刷新: 새로고침
返回: 뒤로
提交: 제출
重置: 초기화
Conform: 확인
Discard: 폐기
Submit: 제출
Reset: 초기화
Cancel: 취소
凭证: 전표
总分类账: 총분류원장
核算项目余额: 핵산항목잔액
科目汇总表: 과목집계표
数量: 수량
单价: 단가
外币: 외화
汇率: 환율
原币: 원화통화
本位币: 기준통화
辅助账: 보조원장
日记账: 분개장
多栏账: 다단원장
核算项目明细账: 핵산항목명세장
数量金额明细账: 수량금액명세장
数量金额总账: 수량금액총장
固定资产: 고정자산
工资: 급여
往来: 거래
自定义辅助核算: 사용자정의보조핵산
核算项目: 핵산항목
核算类别名称: 핵산유형명
辅助核算编码: 보조핵산코드
辅助核算名称: 보조핵산명
余额方向: 잔액방향
余额方向(借/贷): 잔액방향(차/대)
借方发生额: 차변발생액
贷方发生额: 대변발생액
借方累计: 차변누적
贷方累计: 대변누적
年初余额: 연초잔액
年累计借方: 연간누적차변
年累计贷方: 연간누적대변
年累计余额: 연간누적잔액
本月借方发生额: 당월차변발생액
本月贷方发生额: 당월대변발생액
本年借方发生额: 연간차변발생액
本年贷方发生额: 연간대변발생액
年初借方余额: 연초차변잔액
年初贷方余额: 연초대변잔액
期初借方余额: 기초차변잔액
期初贷方余额: 기초대변잔액
期末借方余额: 기말차변잔액
期末贷方余额: 기말대변잔액
损益结转: 손익대체
结转损益: 손익대체하기
凭证字号: 전표자번
凭证字: 전표자
记账日期: 기장일자
制单日期: 작성일자
记账状态: 기장상태
审核状态: 승인상태
作废: 무효
恢复: 복원
冲销: 상계
红冲: 적자상계
反审核: 승인취소
全部: 전체
已作废: 무효완료
已冲销: 상계완료
操作: 조작
备注: 비고
日期: 날짜
描述: 설명
类型: 유형
名称: 명칭
编码: 코드
编号: 번호
金额(借方): 금액(차변)
金额(贷方): 금액(대변)
金额方向: 금액방향
记账金额: 기장금액
凭证编号: 전표번호

View File

@ -1,132 +0,0 @@
Add Error: Add Error
Add Success: Add Success
Cancel: Cancel
Conform: Conform
Delete Error: Delete Error
Delete Success: Delete Success
Discard: Discard
Reset: Reset
Submit: Submit
Update Error: Update Error
Update Success: Update Success
failed: failed
id: id
ok: ok
system error: system error
业务操作: 业务操作
主参与方类型: 主参与方类型
主机构id: 主机构id
主键ID: 主键ID
交易: 交易
产品id: 产品id
从参与方类型: 从参与方类型
从机构id: 从机构id
从机构类型: 从机构类型
余额: 余额
余额方向: 余额方向
保存: 保存
信用额度: 信用额度
信用额度更新成功: 信用额度更新成功
信用额度管理: 信用额度管理
信用额度表: 信用额度表
信用额度设置成功: 信用额度设置成功
借方余额: 借方余额
充值: 充值
充值金额: 充值金额
全部: 全部
全部客户查询: 全部客户查询
创建人: 创建人
创建时间: 创建时间
删除失败: 删除失败
删除成功: 删除成功
原始交易: 原始交易
参数错误: 参数错误
取消: 取消
可用额度: 可用额度
商户id: 商户id
备注: 备注
失效日期: 失效日期
客户用户名: 客户用户名
客户编号: 客户编号
客户额度管理: 客户额度管理
已处理: 已处理
已用额度: 已用额度
开始日期: 开始日期
待处理: 待处理
总账表: 总账表
我的帐务: 我的帐务
我的额度: 我的额度
报告错帐: 报告错帐
授信额度: 授信额度
授信额度必须大于0: 授信额度必须大于0
授信额度设置成功: 授信额度设置成功
摘要: 摘要
新增客户授信: 新增客户授信
新增授信: 新增授信
方向: 方向
日期: 日期
时间: 时间
明细: 明细
明细顺序号: 明细顺序号
更新失败: 更新失败
更新时间: 更新时间
最大明细顺序号: 最大明细顺序号
机构: 机构
机构ID: 机构ID
机构类型: 机构类型
机构账户表: 机构账户表
状态: 状态
生效日期: 生效日期
用户名不能为空: 用户名不能为空
科目: 科目
科目id: 科目id
科目号: 科目号
科目名称: 科目名称
科目类别: 科目类别
科目表: 科目表
系统错误: 系统错误
结束日期: 结束日期
缺少日期参数: 缺少日期参数
订单编号: 订单编号
记账方id: 记账方id
记账方向: 记账方向
记账方类型: 记账方类型
记账日期: 记账日期
记账时间戳: 记账时间戳
记账配置表: 记账配置表
记账金额: 记账金额
设置失败: 设置失败
说明: 说明
调整: 调整
调整授信额度: 调整授信额度
账务日期: 账务日期
账务机构: 账务机构
账务机构id: 账务机构id
账务流水id: 账务流水id
账务流水表: 账务流水表
账务说明: 账务说明
账单: 账单
账单ID: 账单ID
账单id: 账单id
账单日期: 账单日期
账单时间戳: 账单时间戳
账单明细: 账单明细
账单查询: 账单查询
账单状态: 账单状态
账单金额: 账单金额
账户ID: 账户ID
账户ID不能为空: 账户ID不能为空
账户id: 账户id
账户余额: 账户余额
账户余额表: 账户余额表
账户日志: 账户日志
账户明细: 账户明细
账户明细表: 账户明细表
账户设置: 账户设置
账户配置表: 账户配置表
账本机构: 账本机构
贷方余额: 贷方余额
资源id: 资源id
金额: 金额
金额模板: 金额模板
错帐类型: 错帐类型

View File

@ -1,394 +0,0 @@
{
"appcodes": [
{
"id": "accounting_dir",
"name": "记账方向",
"hierarchy_flg": "0"
},
{
"id": "balance_at",
"name": "余额方向",
"hierarchy_flg": "0"
},
{
"id": "balance_side",
"name": "科目余额方向",
"hierarchy_flg": "0"
},
{
"id": "subjecttype",
"name": "科目类别",
"hierarchy_flg": "0"
},
{
"id": "partytype",
"name": "机构类型",
"hierarchy_flg": "0"
},
{
"id": "credit_status",
"name": "信用额度状态",
"hierarchy_flg": "0"
},
{
"id": "currency_status",
"name": "币种状态",
"hierarchy_flg": "0"
}
],
"appcodes_kv": [
{
"id": "accounting_dir_0",
"parentid": "accounting_dir",
"k": "0",
"v": "借"
},
{
"id": "accounting_dir_1",
"parentid": "accounting_dir",
"k": "1",
"v": "贷"
},
{
"id": "balance_at_0",
"parentid": "balance_at",
"k": "0",
"v": "借"
},
{
"id": "balance_at_1",
"parentid": "balance_at",
"k": "1",
"v": "贷"
},
{
"id": "balance_side_0",
"parentid": "balance_side",
"k": "0",
"v": "借"
},
{
"id": "balance_side_1",
"parentid": "balance_side",
"k": "1",
"v": "贷"
},
{
"id": "subjecttype_asset",
"parentid": "subjecttype",
"k": "资产",
"v": "资产"
},
{
"id": "subjecttype_liability",
"parentid": "subjecttype",
"k": "负债",
"v": "负债"
},
{
"id": "subjecttype_pl",
"parentid": "subjecttype",
"k": "损益",
"v": "损益"
},
{
"id": "partytype_owner",
"parentid": "partytype",
"k": "owner",
"v": "平台"
},
{
"id": "partytype_reseller",
"parentid": "partytype",
"k": "reseller",
"v": "分销商"
},
{
"id": "partytype_customer",
"parentid": "partytype",
"k": "customer",
"v": "客户"
},
{
"id": "partytype_provider",
"parentid": "partytype",
"k": "provider",
"v": "供应商"
},
{
"id": "credit_status_active",
"parentid": "credit_status",
"k": "active",
"v": "生效"
},
{
"id": "credit_status_inactive",
"parentid": "credit_status",
"k": "inactive",
"v": "停用"
},
{
"id": "credit_status_expired",
"parentid": "credit_status",
"k": "expired",
"v": "已过期"
},
{
"id": "currency_status_active",
"parentid": "currency_status",
"k": "active",
"v": "启用"
},
{
"id": "currency_status_inactive",
"parentid": "currency_status",
"k": "inactive",
"v": "停用"
}
],
"subject": [
{
"id": "BUyr-yN8rtwefNOjQwVXK",
"name": "平台银行存款",
"balance_side": "0",
"subjecttype": "资产"
},
{
"id": "c98nEf0i_uF7Ik6gozaau",
"name": "平台充值成本",
"balance_side": "0",
"subjecttype": "损益"
},
{
"id": "ePcBex1OU1wdF_-_okZRv",
"name": "商户营业收入",
"balance_side": "1",
"subjecttype": "损益"
},
{
"id": "ERd8EAZa35M4PRLsKI6yX",
"name": "商户采购成本",
"balance_side": "0",
"subjecttype": "损益"
},
{
"id": "heJJIQTWsZE_HbS7WWI4S",
"name": "商户交易费支出",
"balance_side": "0",
"subjecttype": "损益"
},
{
"id": "Iw1EFL1Ubl7Dq7JVFMMEq",
"name": "客户资金账户",
"balance_side": "1",
"subjecttype": "负债"
},
{
"id": "Lnzxp-5XdWadoTCyZiN6M",
"name": "平台交易费收入",
"balance_side": "1",
"subjecttype": "损益"
},
{
"id": "pF2OEBe_E2sYK7fAuVN3n",
"name": "供应商分销收入",
"balance_side": "1",
"subjecttype": "损益"
}
],
"account_config": [
{
"id": "b_MKox_VZiFYIR_lzJC6F",
"subjectid": "ePcBex1OU1wdF_-_okZRv",
"partytype": "分销商",
"party1type": null
},
{
"id": "GIhJc5osnYqgkhDXSiZKQ",
"subjectid": "BUyr-yN8rtwefNOjQwVXK",
"partytype": "平台",
"party1type": null
},
{
"id": "KtwnMnfejNKsnBnPIu7G4",
"subjectid": "heJJIQTWsZE_HbS7WWI4S",
"partytype": "分销商",
"party1type": null
},
{
"id": "qNjewpZiTatSy828LktP0",
"subjectid": "ERd8EAZa35M4PRLsKI6yX",
"partytype": "分销商",
"party1type": "供应商"
},
{
"id": "t-sco3C4pl4ON0pKktGEQ",
"subjectid": "Iw1EFL1Ubl7Dq7JVFMMEq",
"partytype": "客户",
"party1type": null
},
{
"id": "W-11FsYRSzKhx8j7Kc5Il",
"subjectid": "Lnzxp-5XdWadoTCyZiN6M",
"partytype": "平台",
"party1type": null
},
{
"id": "XC6-lNo1yGcxGufTSSi1U",
"subjectid": "c98nEf0i_uF7Ik6gozaau",
"partytype": "平台",
"party1type": null
},
{
"id": "Z6adBvS1uqKP7XuYf73t9",
"subjectid": "pF2OEBe_E2sYK7fAuVN3n",
"partytype": "供应商",
"party1type": "分销商"
}
],
"accounting_config": [
{
"id": "_j054xv_mpfLrD8svBCkT",
"action": "PAY",
"accounting_orgtype": "owner",
"accounting_dir": "0",
"orgtype": "customer",
"org1type": null,
"subjectid": "Iw1EFL1Ubl7Dq7JVFMMEq",
"amt_pattern": "${交易金额}$"
},
{
"id": "_U3HGWL4ywA-ML7cVdBZa",
"action": "PAY",
"accounting_orgtype": "owner",
"accounting_dir": "1",
"orgtype": "reseller",
"org1type": null,
"subjectid": "ePcBex1OU1wdF_-_okZRv",
"amt_pattern": "${交易金额}$"
},
{
"id": "1VOt1KDML_lM0uiH7NFSW",
"action": "RECHARGE",
"accounting_orgtype": "owner",
"accounting_dir": "0",
"orgtype": "owner",
"org1type": null,
"subjectid": "c98nEf0i_uF7Ik6gozaau",
"amt_pattern": "${充值费率}$ * ${交易金额}$"
},
{
"id": "4wQNg-KX8kjOY3evlE2ar",
"action": "PAY*",
"accounting_orgtype": "owner",
"accounting_dir": "0",
"orgtype": "reseller",
"org1type": "provider",
"subjectid": "ERd8EAZa35M4PRLsKI6yX",
"amt_pattern": "${采购成本}$"
},
{
"id": "CIrdqoS2XLP_pxahjKLBQ",
"action": "PAY",
"accounting_orgtype": "owner",
"accounting_dir": "1",
"orgtype": "owner",
"org1type": null,
"subjectid": "Lnzxp-5XdWadoTCyZiN6M",
"amt_pattern": "${交易手续费}$"
},
{
"id": "vi9Q9r3V-IXXgEffs7un8",
"action": "PAY*",
"accounting_orgtype": "owner",
"accounting_dir": "1",
"orgtype": "provider",
"org1type": "reseller",
"subjectid": "pF2OEBe_E2sYK7fAuVN3n",
"amt_pattern": "${采购成本}$"
},
{
"id": "vQ7GNoVridCqzQZ6MtbwN",
"action": "RECHARGE",
"accounting_orgtype": "owner",
"accounting_dir": "1",
"orgtype": "customer",
"org1type": null,
"subjectid": "Iw1EFL1Ubl7Dq7JVFMMEq",
"amt_pattern": "${交易金额}$"
},
{
"id": "Z34IkRVy5SohRqxkVQT2p",
"action": "RECHARGE",
"accounting_orgtype": "owner",
"accounting_dir": "0",
"orgtype": "owner",
"org1type": null,
"subjectid": "BUyr-yN8rtwefNOjQwVXK",
"amt_pattern": "${交易金额}$ - ${充值费率}$ * ${交易金额}$"
}
],
"currency": [
{
"id": "CNY",
"name": "人民币",
"symbol": "¥",
"decimal_places": 2,
"is_base": "1",
"status": "active"
},
{
"id": "GBP",
"name": "英镑",
"symbol": "£",
"decimal_places": 2,
"is_base": "0",
"status": "active"
},
{
"id": "JPY",
"name": "日元",
"symbol": "¥",
"decimal_places": 0,
"is_base": "0",
"status": "active"
},
{
"id": "USD",
"name": "美元",
"symbol": "$",
"decimal_places": 2,
"is_base": "0",
"status": "active"
}
],
"exchange_rate": [
{
"id": "er_gbp_cny_20260821",
"from_currency": "GBP",
"to_currency": "CNY",
"buy_rate": 9.15,
"sell_rate": 9.2179,
"mid_rate": 9.2101,
"effective_date": "2026-08-21"
},
{
"id": "er_jpy_cny_20260821",
"from_currency": "JPY",
"to_currency": "CNY",
"buy_rate": 0.042221,
"sell_rate": 0.042547,
"mid_rate": 0.042516,
"effective_date": "2026-08-21"
},
{
"id": "er_usd_cny_20260821",
"from_currency": "USD",
"to_currency": "CNY",
"buy_rate": 6.7096,
"sell_rate": 6.7379,
"mid_rate": 6.7817,
"effective_date": "2026-08-21"
}
]
}

View File

@ -1,23 +1,17 @@
{ {
"models_dir": "${HOME}$/py/rbac/models",
"output_dir": "${HOME}$/py/sage/wwwroot/account",
"dbname": "sage",
"tblname": "acc_balance", "tblname": "acc_balance",
"title": "账户余额", "title":"科目",
"params": { "params": {
"sortby": [ "sortby":"name",
"acc_date desc"
],
"browserfields": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id" "cwidth": {}
],
"alters": {}
}, },
"editexclouded": [ "editexclouded": [
"id" "id"
], ]
"editable": {
"new_data_url": "default",
"update_data_url": "default",
"delete_data_url": "default"
}
} }
} }

View File

@ -1,38 +1,17 @@
{ {
"models_dir": "${HOME}$/py/rbac/models",
"output_dir": "${HOME}$/py/sage/wwwroot/acc_detail",
"dbname": "sage",
"tblname": "acc_detail", "tblname": "acc_detail",
"title": "账务明细", "title":"科目",
"params": { "params": {
"sortby": [ "sortby":"name",
"acc_date desc"
],
"browserfields": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id" "cwidth": {}
],
"alters": {
"acc_dir": {
"uitype": "code",
"data": [
{
"value": "0",
"text": "贷"
},
{
"value": "1",
"text": "借"
}
]
}
}
}, },
"editexclouded": [ "editexclouded": [
"id", "id"
"acc_no" ]
],
"editable": {
"new_data_url": "default",
"update_data_url": "default",
"delete_data_url": "default"
}
} }
} }

View File

@ -1,68 +1,34 @@
{ {
"models_dir": "${HOME}$/py/rbac/models",
"output_dir": "${HOME}$/py/sage/wwwroot/account",
"dbname": "sage",
"tblname": "account", "tblname": "account",
"title": "账户管理", "title":"科目",
"params": { "params": {
"sortby": [ "sortby":"name",
"id"
],
"browserfields": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id" "cwidth": {}
],
"alters": {
"subjectid": {
"uitype": "code",
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}",
"valueField": "subjectid",
"textField": "subjectid_text"
},
"orgid": {
"uitype": "code",
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}",
"valueField": "orgid",
"textField": "orgid_text"
},
"balance_at": {
"uitype": "code",
"data": [
{
"value": "0",
"text": "借"
},
{
"value": "1",
"text": "贷"
}
]
}
}
}, },
"editexclouded": [ "editexclouded": [
"id", "id"
"max_detailno",
"balance"
], ],
"subtables": [ "subtables":[
{ {
"field": "accountid", "field":"accountid",
"title": "账户余额", "title":"账户余额",
"subtable": "acc_balance" "subtable":"acc_balance"
}, },
{ {
"field": "accountid", "field":"accountid",
"title": "账户明细", "title":"账户明细",
"subtable": "acc_detail" "subtable":"acc_detail"
}, },
{ {
"field": "accountid", "field":"accountid",
"title": "账户日志", "title":"账户日志",
"subtable": "accounting_log" "subtable":"accounting_log"
} }
], ]
"editable": {
"new_data_url": "default",
"update_data_url": "default",
"delete_data_url": "default"
}
} }
} }

View File

@ -3,21 +3,14 @@
"output_dir": "${HOME}$/py/sage/wwwroot/account_config", "output_dir": "${HOME}$/py/sage/wwwroot/account_config",
"dbname": "sage", "dbname": "sage",
"tblname": "account_config", "tblname": "account_config",
"title": "账户设置", "title":"账户设置",
"params": { "params": {
"browserfields": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id"
],
"cwidth": {} "cwidth": {}
}, },
"editexclouded": [ "editexclouded": [
"id" "id"
], ]
"editable": {
"new_data_url": "default",
"update_data_url": "default",
"delete_data_url": "default"
}
} }
} }

View File

@ -1,24 +1,14 @@
{ {
"tblname": "accounting_config", "tblname": "accounting_config",
"title": "账务设置", "title":"账务设置",
"params": { "params": {
"sortby": [ "sortby":["action","accounting_dir"],
"action",
"accounting_dir"
],
"browserfields": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id"
],
"cwidth": {} "cwidth": {}
}, },
"editexclouded": [ "editexclouded": [
"id" "id"
], ]
"editable": {
"new_data_url": "default",
"update_data_url": "default",
"delete_data_url": "default"
}
} }
} }

View File

@ -3,22 +3,15 @@
"output_dir": "${HOME}$/py/sage/wwwroot/accounting_log", "output_dir": "${HOME}$/py/sage/wwwroot/accounting_log",
"dbname": "sage", "dbname": "sage",
"tblname": "accounting_log", "tblname": "accounting_log",
"title": "科目", "title":"科目",
"params": { "params": {
"sortby": "name", "sortby":"name",
"browserfields": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id"
],
"cwidth": {} "cwidth": {}
}, },
"editexclouded": [ "editexclouded": [
"id" "id"
], ]
"editable": {
"new_data_url": "default",
"update_data_url": "default",
"delete_data_url": "default"
}
} }
} }

View File

@ -1,23 +0,0 @@
{
"tblname": "credit_limit",
"title": "信用额度管理",
"params": {
"sortby": ["created_at desc"],
"browserfields": {
"exclouded": ["id"],
"cwidth": {}
},
"editexclouded": ["id", "used_credit", "available_credit", "created_at", "updated_at"],
"editable": {
"new_data_url": "default",
"update_data_url": "default",
"delete_data_url": "default"
},
"data_filter": {
"AND": [
{"field": "orgid", "op": "=", "var": "orgid"},
{"field": "status", "op": "=", "var": "status"}
]
}
}
}

View File

@ -1,28 +0,0 @@
{
"tblname": "currency",
"title": "币种管理",
"params": {
"browserfields": {
"fields": [
"id",
"name",
"symbol",
"decimal_places",
"is_base",
"status"
],
"alters": {
"status": {
"uitype": "select",
"optiontable": "appcodes_kv",
"cond": "parentid='currency_status'"
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/currency_create.dspy')}}",
"update_data_url": "{{entire_url('../api/currency_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/currency_delete.dspy')}}"
}
}
}

View File

@ -1,31 +0,0 @@
{
"tblname": "exchange_rate",
"title": "汇率管理",
"params": {
"browserfields": {
"fields": [
"from_currency",
"to_currency",
"buy_rate",
"sell_rate",
"mid_rate",
"effective_date"
],
"alters": {
"from_currency": {
"uitype": "select",
"optiontable": "currency"
},
"to_currency": {
"uitype": "select",
"optiontable": "currency"
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/exchange_rate_create.dspy')}}",
"update_data_url": "{{entire_url('../api/exchange_rate_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/exchange_rate_delete.dspy')}}"
}
}
}

View File

@ -1,48 +1,25 @@
{ {
"models_dir": "${HOME}$/py/rbac/models",
"output_dir": "${HOME}$/py/sage/wwwroot/subject",
"dbname": "sage",
"tblname": "subject", "tblname": "subject",
"title": "科目管理", "title":"科目",
"params": { "params": {
"sortby": [ "sortby":"name",
"id"
],
"browserfields": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id" "cwidth": {}
],
"alters": {
"balance_side": {
"uitype": "code",
"data": [
{
"value": "0",
"text": "借"
},
{
"value": "1",
"text": "贷"
}
]
},
"subjecttype": {
"uitype": "code",
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
}
}
}, },
"editexclouded": [ "editexclouded": [
"id" "id"
], ],
"subtables": [ "subtables":[
{ {
"field": "subjectid", "field":"subjectid",
"title": "账户设置", "title":"账户设置",
"subtable": "account_config" "url":"../account_config",
} "subtable":"account_config"
], }
"editable": { ]
"new_data_url": "default",
"update_data_url": "default",
"delete_data_url": "default"
}
} }
} }

View File

@ -30,8 +30,7 @@
"name": "balance", "name": "balance",
"title": "账户余额", "title": "账户余额",
"type": "float", "type": "float",
"length": 20, "length": 20
"dec": 2
} }
] ]
} }

View File

@ -52,38 +52,13 @@
"name": "amount", "name": "amount",
"title": "记账金额", "title": "记账金额",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
},
{
"name": "currency",
"title": "币种",
"type": "str",
"length": 3,
"nullable": "yes"
},
{
"name": "exchange_rate",
"title": "汇率",
"type": "double",
"length": 14,
"dec": 6,
"nullable": "yes"
},
{
"name": "base_amount",
"title": "折本位币金额",
"type": "double",
"length": 20,
"dec": 2,
"nullable": "yes"
}, },
{ {
"name": "balance", "name": "balance",
"title": "账户余额", "title": "账户余额",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
}, },
{ {
"name": "acclogid", "name": "acclogid",
@ -92,4 +67,4 @@
"length": 32 "length": 32
} }
] ]
} }

View File

@ -33,13 +33,6 @@
"type": "str", "type": "str",
"length": 32 "length": 32
}, },
{
"name": "currency",
"title": "币种",
"type": "str",
"length": 3,
"nullable": "yes"
},
{ {
"name": "subjectid", "name": "subjectid",
"title": "科目号", "title": "科目号",
@ -61,8 +54,7 @@
"name": "balance", "name": "balance",
"title": "余额", "title": "余额",
"type": "float", "type": "float",
"length": 20, "length": 20
"dec": 2
} }
], ],
"indexes": [ "indexes": [
@ -104,4 +96,4 @@
"textfield": "orgname" "textfield": "orgname"
} }
] ]
} }

View File

@ -47,8 +47,7 @@
"name": "amount", "name": "amount",
"title": "记账金额", "title": "记账金额",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
}, },
{ {
"name": "billid", "name": "billid",

View File

@ -55,23 +55,7 @@
"name": "amount", "name": "amount",
"title": "金额", "title": "金额",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
},
{
"name": "currency",
"title": "币种",
"type": "str",
"length": 3,
"nullable": "yes"
},
{
"name": "base_amount",
"title": "折本位币金额",
"type": "double",
"length": 20,
"dec": 2,
"nullable": "yes"
}, },
{ {
"name": "bill_date", "name": "bill_date",
@ -90,4 +74,4 @@
"length": 1 "length": 1
} }
] ]
} }

View File

@ -61,31 +61,7 @@
"name": "amount", "name": "amount",
"title": "账单金额", "title": "账单金额",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
},
{
"name": "currency",
"title": "币种",
"type": "str",
"length": 3,
"nullable": "yes"
},
{
"name": "exchange_rate",
"title": "汇率",
"type": "double",
"length": 14,
"dec": 6,
"nullable": "yes"
},
{
"name": "base_amount",
"title": "折本位币金额",
"type": "double",
"length": 20,
"dec": 2,
"nullable": "yes"
} }
] ]
} }

View File

@ -1,31 +0,0 @@
{
"summary": [
{
"name": "biz_order",
"title": "业务订单",
"primary": [
"id"
]
}
],
"fields": [
{"name": "id", "title": "id", "type": "str", "length": 32},
{"name": "customerid", "title": "客户id", "type": "str", "length": 32, "nullable": "yes"},
{"name": "resellerid", "title": "分销商id", "type": "str", "length": 32, "nullable": "yes"},
{"name": "order_date", "title": "订单日期", "type": "date", "nullable": "yes"},
{"name": "order_status", "title": "订单状态", "type": "str", "length": 1, "nullable": "yes"},
{"name": "business_op", "title": "业务操作", "type": "str", "length": 255, "nullable": "yes"},
{"name": "amount", "title": "金额", "type": "double", "length": 18, "dec": 2, "nullable": "yes"},
{"name": "currency", "title": "币种", "type": "str", "length": 3, "nullable": "yes"},
{"name": "up_orderid", "title": "上位系统订单号", "type": "str", "length": 256, "nullable": "yes"},
{"name": "down_orderid", "title": "下位系统订单号", "type": "str", "length": 256, "nullable": "yes"},
{"name": "specdataid", "title": "规格数据id", "type": "str", "length": 32, "nullable": "yes"},
{"name": "userid", "title": "用户id", "type": "str", "length": 32, "nullable": "yes"},
{"name": "refund", "title": "退费金额", "type": "str", "length": 32, "nullable": "yes"},
{"name": "source", "title": "订单来源", "type": "str", "length": 10, "nullable": "yes"},
{"name": "originalprice", "title": "原价", "type": "str", "length": 16, "nullable": "yes"},
{"name": "ordertype", "title": "订单类型", "type": "str", "length": 10, "nullable": "yes"},
{"name": "productid", "title": "产品id", "type": "str", "length": 64, "nullable": "yes"},
{"name": "pay_date", "title": "支付时间", "type": "timestamp", "nullable": "yes"}
]
}

View File

@ -1,21 +0,0 @@
{
"summary": [
{
"name": "biz_orderdetail",
"title": "业务订单明细",
"primary": [
"id"
]
}
],
"fields": [
{"name": "id", "title": "id", "type": "str", "length": 32},
{"name": "orderid", "title": "订单号", "type": "str", "length": 32, "nullable": "yes"},
{"name": "productid", "title": "产品id", "type": "str", "length": 32, "nullable": "yes"},
{"name": "product_cnt", "title": "产品数量", "type": "int", "nullable": "yes"},
{"name": "prod_config", "title": "产品配置", "type": "text", "nullable": "yes"},
{"name": "list_amount", "title": "原价", "type": "double", "length": 18, "dec": 4, "nullable": "yes"},
{"name": "trans_amount", "title": "交易金额", "type": "double", "length": 18, "dec": 4, "nullable": "yes"},
{"name": "currency", "title": "币种", "type": "str", "length": 3, "nullable": "yes"}
]
}

View File

@ -1,157 +0,0 @@
{
"summary": [
{
"name": "credit_limit",
"title": "信用额度表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "accountid",
"title": "账户ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "orgid",
"title": "机构ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "grant_orgid",
"title": "授予机构ID",
"type": "str",
"length": 32,
"nullable": "no",
"default": "0"
},
{
"name": "credit_limit",
"title": "信用额度",
"type": "float",
"length": 18,
"dec": 2,
"nullable": "no",
"default": "0.00"
},
{
"name": "used_credit",
"title": "已用额度",
"type": "float",
"length": 18,
"dec": 2,
"nullable": "no",
"default": "0.00"
},
{
"name": "available_credit",
"title": "可用额度",
"type": "float",
"length": 18,
"dec": 2,
"nullable": "no",
"default": "0.00"
},
{
"name": "valid_from",
"title": "生效日期",
"type": "date",
"nullable": "yes"
},
{
"name": "valid_to",
"title": "失效日期",
"type": "date",
"nullable": "yes"
},
{
"name": "status",
"title": "状态",
"type": "str",
"length": 10,
"nullable": "no",
"default": "active"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 32,
"nullable": "yes"
},
{
"name": "remark",
"title": "备注",
"type": "str",
"length": 500,
"nullable": "yes"
}
],
"indexes": [
{
"name": "idx_credit_limit_account",
"idxtype": "unique",
"idxfields": ["accountid"]
},
{
"name": "idx_credit_limit_orgid",
"idxtype": "index",
"idxfields": ["orgid"]
},
{
"name": "idx_credit_limit_grant_orgid",
"idxtype": "index",
"idxfields": ["grant_orgid"]
},
{
"name": "idx_credit_limit_status",
"idxtype": "index",
"idxfields": ["status"]
}
],
"codes": [
{
"field": "accountid",
"table": "account",
"valuefield": "id",
"textfield": "id"
},
{
"field": "orgid",
"table": "organization",
"valuefield": "id",
"textfield": "orgname"
},
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='credit_status'"
}
]
}

View File

@ -1,58 +0,0 @@
{
"summary": [
{
"name": "currency",
"title": "币种",
"primary": ["id"]
}
],
"fields": [
{
"name": "id",
"title": "币种代码",
"type": "str",
"length": 3
},
{
"name": "name",
"title": "币种名称",
"type": "str",
"length": 32
},
{
"name": "symbol",
"title": "货币符号",
"type": "str",
"length": 8
},
{
"name": "decimal_places",
"title": "小数位数",
"type": "short",
"default": 2
},
{
"name": "is_base",
"title": "是否本位币",
"type": "str",
"length": 1,
"default": "0"
},
{
"name": "status",
"title": "状态",
"type": "str",
"length": 16,
"default": "active"
}
],
"codes": [
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='currency_status'"
}
]
}

View File

@ -1,81 +0,0 @@
{
"summary": [
{
"name": "exchange_rate",
"title": "汇率表",
"primary": ["id"]
}
],
"fields": [
{
"name": "id",
"title": "id",
"type": "str",
"length": 32
},
{
"name": "from_currency",
"title": "源币种",
"type": "str",
"length": 3
},
{
"name": "to_currency",
"title": "目标币种",
"type": "str",
"length": 3
},
{
"name": "buy_rate",
"title": "买入价",
"type": "float",
"length": 14,
"dec": 6
},
{
"name": "sell_rate",
"title": "卖出价",
"type": "float",
"length": 14,
"dec": 6
},
{
"name": "mid_rate",
"title": "中间价",
"type": "float",
"length": 14,
"dec": 6
},
{
"name": "effective_date",
"title": "生效日期",
"type": "date"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "datetime"
}
],
"indexes": [
{
"name": "idx_currency_pair_date",
"idxtype": "unique",
"idxfields": ["from_currency", "to_currency", "effective_date"]
}
],
"codes": [
{
"field": "from_currency",
"table": "currency",
"valuefield": "id",
"textfield": "name"
},
{
"field": "to_currency",
"table": "currency",
"valuefield": "id",
"textfield": "name"
}
]
}

View File

@ -36,38 +36,13 @@
"name": "d_balance", "name": "d_balance",
"title": "借方余额", "title": "借方余额",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
}, },
{ {
"name": "c_balance", "name": "c_balance",
"title": "贷方余额", "title": "贷方余额",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
},
{
"name": "currency",
"title": "币种",
"type": "str",
"length": 3,
"nullable": "yes"
},
{
"name": "exchange_rate",
"title": "汇率",
"type": "double",
"length": 14,
"dec": 6,
"nullable": "yes"
},
{
"name": "base_amount",
"title": "折本位币金额",
"type": "double",
"length": 20,
"dec": 2,
"nullable": "yes"
} }
] ]
} }

View File

@ -1,362 +0,0 @@
#!/usr/bin/env python3
"""
accounting 模块 RBAC 权限管理脚本(角色分层版,2026-08-27 重构)
重构原因(安全修复):
旧版把整个财务后台(余额台账/账户/科目/分录/日志/信用/币种/汇率的增删改查)
全部注册给 logined 角色,任何注册用户可见/可改/可删全平台余额与账务数据。
现按角色分层:
- any 免登录静态资源(菜单片段/图标)+ 带 localhost 校验的定时任务入口
- logined 客户自服务页面(后端均已按本人/本机构过滤:我的账户、账务明细、账单、我的信用额度)
- 财务角色 财务后台全部 CRUD(余额台账、账户、科目、分录配置、日志、信用管理、币种汇率)
- 分销商角色 代客充值(含按用户名查询客户余额,属分销商业务需要)
行为:
默认先清理 /accounting/ 下全部旧授权(rolepermission),再按新矩阵重新注册。
(旧授权里 logined、customer.customer 等角色持有财务后台增删改权限,不清理则泄漏依旧)
--add-only 跳过清理,仅增量注册(新路径/新宿主接入时用)
角色解析:
权限缓存按 role.id 关联(rbac/userperm.py load_roleperms),同名角色在不同宿主
的 role.id 可能不同(如 'owner.superuser' 字面量 vs 随机 id),因此本脚本
用 orgtypeid+name 查 role 表解析出真实 role.id 后注册,同名多条全部注册。
使用方法:
cd <app root>(含 py3/ 的目录,如 /d/pipeline/pipeline-app)
./py3/bin/python pkgs/accounting/scripts/load_path.py [--add-only]
"""
import os
import sys
import asyncio
def find_app_root():
candidates = [
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
# pkgs/accounting/scripts/load_path.py 的上三级 = 应用根目录
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
os.getcwd(),
]
for c in candidates:
if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")):
return c
return None
APP_ROOT = find_app_root()
if not APP_ROOT:
print("ERROR: Cannot find app root directory")
sys.exit(1)
MOD = "accounting"
# ============================================================
# 角色定义(标签格式 <orgtypeid>.<name>,运行时解析为真实 role.id)
# ============================================================
# 财务后台管理角色:平台方超管/管理员/会计 + 分销商管理员/会计/运营
FIN_ROLES = [
"owner.superuser",
"owner.admin",
"owner.account",
"reseller.admin",
"reseller.accountant",
"reseller.operator",
]
# 代客充值角色:分销商侧可代客户充值(需按用户名查询客户账户,属业务需要)。
# 2026-09-08:owner 财务角色也需代客充值权(平台方运营给客户充值,归属校验里 owner 不受限),
# 且账务管理菜单「代客充值」入口在 is_fin 块(含 owner 财务)——权限须与菜单门控一致,
# 否则 owner 财务看得到菜单却 403(实测)。= FIN_ROLES(owner财务+reseller财务)+ reseller.sale。
PROXY_ROLES = [
"owner.superuser",
"owner.admin",
"owner.account",
"reseller.admin",
"reseller.accountant",
"reseller.operator",
"reseller.sale",
]
# ============================================================
# 权限路径矩阵
# ============================================================
# any — 无需登录(静态资源 + 自带 localhost 校验的定时任务入口)
PATHS_ANY = [
f"/{MOD}/usermenu.ui",
f"/{MOD}/imgs/accbalance.svg",
f"/{MOD}/imgs/accdetail.svg",
f"/{MOD}/imgs/account.svg",
f"/{MOD}/imgs/bill.svg",
# 定时任务入口(build.sh 注入的 cron 裸 curl 无会话,必须 any;
# dspy 内部已做 client_ip=localhost 校验,nginx XFF 追加模式伪造不成立)
f"/{MOD}/api/fetch_forex_rates.dspy",
]
# logined — 客户自服务(后端查询均已按当前用户/本机构过滤)
PATHS_LOGINED = [
f"/{MOD}/myaccounts.ui",
f"/{MOD}/myaccounts.dspy",
f"/{MOD}/mybalance.dspy",
f"/{MOD}/accdetail.ui",
f"/{MOD}/accdetail.dspy",
f"/{MOD}/billing.ui",
f"/{MOD}/billing.dspy",
f"/{MOD}/billing_download.dspy",
f"/{MOD}/get_user_balance.dspy",
f"/{MOD}/open_customer_accounts_with_orgid.dspy",
f"/{MOD}/error_accounting.ui",
# 我的信用额度(查询按本机构过滤)
f"/{MOD}/credit_limit/credit_overview.ui",
]
# 财务后台 — 仅财务角色
PATHS_FIN = [
# 模块入口
f"/{MOD}",
f"/{MOD}/index.ui",
# 统计卡片(按机构汇总的财务看板)
f"/{MOD}/stat_total_balance.ui",
f"/{MOD}/stat_today_consumption.ui",
f"/{MOD}/stat_month_consumption.ui",
f"/{MOD}/stat_account_count.ui",
# 开户管理(平台/分销商侧操作,可指定任意 orgid)
f"/{MOD}/oca.dspy",
f"/{MOD}/open_customer_accounts.dspy",
f"/{MOD}/open_owner_accounts.dspy",
f"/{MOD}/open_provider_accounts.dspy",
f"/{MOD}/open_reseller_accounts.dspy",
f"/{MOD}/open_reseller_provider_accounts.dspy",
# acc_balance/(日终余额台账,全平台数据,无 orgid 列)
f"/{MOD}/acc_balance/index.ui",
f"/{MOD}/acc_balance/get_acc_balance.dspy",
f"/{MOD}/acc_balance/add_acc_balance.dspy",
f"/{MOD}/acc_balance/update_acc_balance.dspy",
f"/{MOD}/acc_balance/delete_acc_balance.dspy",
# acc_detail/(记账明细台账)
f"/{MOD}/acc_detail/index.ui",
f"/{MOD}/acc_detail/get_acc_detail.dspy",
f"/{MOD}/acc_detail/add_acc_detail.dspy",
f"/{MOD}/acc_detail/update_acc_detail.dspy",
f"/{MOD}/acc_detail/delete_acc_detail.dspy",
# account/(账户管理)
f"/{MOD}/account/index.ui",
f"/{MOD}/account/get_account.dspy",
f"/{MOD}/account/add_account.dspy",
f"/{MOD}/account/update_account.dspy",
f"/{MOD}/account/delete_account.dspy",
# account_config/(参与方科目配置)
f"/{MOD}/account_config/index.ui",
f"/{MOD}/account_config/get_account_config.dspy",
f"/{MOD}/account_config/add_account_config.dspy",
f"/{MOD}/account_config/update_account_config.dspy",
f"/{MOD}/account_config/delete_account_config.dspy",
# accounting_config/(会计分录配置)
f"/{MOD}/accounting_config/index.ui",
f"/{MOD}/accounting_config/get_accounting_config.dspy",
f"/{MOD}/accounting_config/add_accounting_config.dspy",
f"/{MOD}/accounting_config/update_accounting_config.dspy",
f"/{MOD}/accounting_config/delete_accounting_config.dspy",
# accounting_log/(记账日志)
f"/{MOD}/accounting_log/index.ui",
f"/{MOD}/accounting_log/get_accounting_log.dspy",
f"/{MOD}/accounting_log/add_accounting_log.dspy",
f"/{MOD}/accounting_log/update_accounting_log.dspy",
f"/{MOD}/accounting_log/delete_accounting_log.dspy",
# subject/(科目)
f"/{MOD}/subject/index.ui",
f"/{MOD}/subject/get_subject.dspy",
f"/{MOD}/subject/add_subject.dspy",
f"/{MOD}/subject/update_subject.dspy",
f"/{MOD}/subject/delete_subject.dspy",
# credit_limit/(信用额度管理侧;客户自服务的 hub/overview 在 logined 组)
f"/{MOD}/credit_limit/index.ui",
f"/{MOD}/credit_limit/get_credit_limit.dspy",
f"/{MOD}/credit_limit/add_credit_limit.dspy",
f"/{MOD}/credit_limit/update_credit_limit.dspy",
f"/{MOD}/credit_limit/delete_credit_limit.dspy",
f"/{MOD}/credit_limit/credit_manage.ui",
f"/{MOD}/credit_limit/api/credit_summary.dspy",
f"/{MOD}/credit_limit/api/set_credit_form.ui",
f"/{MOD}/credit_limit/api/set_customer_credit.dspy",
# currency/ + exchange_rate/(币种与汇率管理,目录名以 build 生成的 tblname 为准)
f"/{MOD}/currency/index.ui",
f"/{MOD}/currency/get_currency.dspy",
f"/{MOD}/currency/add_currency.dspy",
f"/{MOD}/currency/update_currency.dspy",
f"/{MOD}/currency/delete_currency.dspy",
f"/{MOD}/exchange_rate/index.ui",
f"/{MOD}/exchange_rate/get_exchange_rate.dspy",
f"/{MOD}/exchange_rate/add_exchange_rate.dspy",
f"/{MOD}/exchange_rate/update_exchange_rate.dspy",
f"/{MOD}/exchange_rate/delete_exchange_rate.dspy",
# api(币种/汇率手工维护入口)
f"/{MOD}/api/currency_create.dspy",
f"/{MOD}/api/currency_update.dspy",
f"/{MOD}/api/currency_delete.dspy",
f"/{MOD}/api/exchange_rate_create.dspy",
f"/{MOD}/api/exchange_rate_update.dspy",
f"/{MOD}/api/exchange_rate_delete.dspy",
]
# 代客充值 — 仅分销商角色
PATHS_PROXY = [
f"/{MOD}/proxy_recharge.ui",
f"/{MOD}/proxy_recharge_submit.dspy",
]
# 充值错帐处理(冲正)— 财务角色(FIN_ROLES):owner 财务 + reseller 管理员/会计/运营。
# 敏感操作不开放给 reseller.sale(代客充值可,冲正不可)。
PATHS_FIN += [
f"/{MOD}/recharge_reverse.ui",
f"/{MOD}/recharge_reverse_list.dspy",
f"/{MOD}/recharge_reverse_submit.dspy",
]
# ============================================================
# 数据库辅助(角色解析 + 旧授权清理)
# ============================================================
def _get_db():
sys.path.insert(0, APP_ROOT)
from sqlor.dbpools import DBPools
from appPublic.jsonConfig import getConfig
config = getConfig(APP_ROOT, NS={'workdir': APP_ROOT})
return DBPools(config.databases)
def _row_get(row, key):
try:
return row[key]
except Exception:
return getattr(row, key, None)
async def _find_rbac_db(db):
"""遍历 config.databases,找到含 role 表的库(pipeline 应用是 pipeline 库,sage 是 sage 库)。"""
names = list(db.databases.keys()) if hasattr(db, 'databases') else []
for dbname in names:
try:
async with db.sqlorContext(dbname) as sor:
await sor.sqlExe('select count(*) as c from role', {})
await sor.sqlExe('COMMIT', {})
return dbname
except Exception:
continue
return None
async def resolve_role_ids(sor, labels):
"""标签 'orgtypeid.name' → 真实 role.id 列表(同名多条全返回)。"""
mapping = {}
for label in labels:
if label in ('any', 'logined', 'anonymous'):
mapping[label] = [label]
continue
orgtypeid, _, name = label.partition('.')
recs = await sor.sqlExe(
'select id from role where orgtypeid=${o}$ and name=${n}$',
{'o': orgtypeid, 'n': name})
ids = [_row_get(r, 'id') for r in recs]
ids = [i for i in ids if i]
if not ids:
print(f'WARN: 角色不存在 {label},跳过')
mapping[label] = ids
return mapping
async def set_role_perm(sor, role_id, path):
"""注册单条权限(幂等):permission 缺则建,rolepermission 缺则建。"""
from appPublic.uniqueID import getID
recs = await sor.R('permission', {'path': path})
if not recs:
permid = getID()
await sor.C('permission', {'id': permid, 'path': path})
else:
permid = _row_get(recs[0], 'id')
rp = await sor.R('rolepermission', {'roleid': role_id, 'permid': permid})
if rp:
return False
await sor.C('rolepermission', {'id': getID(), 'roleid': role_id, 'permid': permid})
return True
async def run_all(add_only):
db = _get_db()
dbname = await _find_rbac_db(db)
if not dbname:
print('ERROR: 无法定位 RBAC 库(role 表不可达),中止')
return 1
print(f'RBAC 库: {dbname}')
total = 0
async with db.sqlorContext(dbname) as sor:
if not add_only:
print('=== 清理旧授权 ===')
perms = await sor.sqlExe(
'select id from permission where path like ${p}$ or path=${p2}$',
{'p': f'/{MOD}/%', 'p2': f'/{MOD}'})
for p in perms:
await sor.sqlExe('delete from rolepermission where permid=${pid}$',
{'pid': _row_get(p, 'id')})
await sor.sqlExe('COMMIT', {})
print(f'清理完成:/accounting/ 下 {len(perms)} 个 permission 的旧授权已删除')
print('=== 解析角色 ===')
all_labels = sorted(set(FIN_ROLES + PROXY_ROLES))
role_map = await resolve_role_ids(sor, all_labels)
for lb in all_labels:
print(f' {lb} -> {role_map.get(lb) or "(不存在)"}')
print('=== 注册新矩阵 ===')
async def register_group(group_label, role_ids, paths):
nonlocal total
if not role_ids:
return
added = 0
for rid in role_ids:
for p in paths:
if await set_role_perm(sor, rid, p):
added += 1
await sor.sqlExe('COMMIT', {})
total += added
print(f' {group_label} ({",".join(role_ids)}): +{added} 条'
f'(目标 {len(paths) * len(role_ids)} 条,其余为已存在)')
await register_group('any', ['any'], PATHS_ANY)
await register_group('logined', ['logined'], PATHS_LOGINED)
for label in FIN_ROLES:
await register_group(label, role_map.get(label, []), PATHS_FIN)
for label in PROXY_ROLES:
await register_group(label, role_map.get(label, []), PATHS_PROXY)
print(f'\nDone. 本次新增 {total} 条授权。')
print('NOTE: 重启应用(或调用 /rbac/refresh_userperm.dspy)后权限生效。')
return 0
def main():
add_only = '--add-only' in sys.argv
print(f"App root: {APP_ROOT}")
sys.exit(asyncio.run(run_all(add_only)))
if __name__ == "__main__":
main()

View File

@ -1,70 +0,0 @@
-- ============================================================
-- 多币种迁移 SQL — 一次性执行
-- 数据库: accounting / pricing / llmage / product_management
-- ============================================================
-- ─── 1. accounting: currency + exchange_rate 表 + 初始数据 ───
CREATE TABLE IF NOT EXISTS accounting.currency (
id VARCHAR(3) NOT NULL PRIMARY KEY,
name VARCHAR(32) NOT NULL,
symbol VARCHAR(8) DEFAULT '',
decimal_places SMALLINT DEFAULT 2,
is_base VARCHAR(1) DEFAULT '0',
status VARCHAR(16) DEFAULT 'active'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO accounting.currency (id, name, symbol, decimal_places, is_base, status) VALUES
('CNY', '人民币', '¥', 2, '1', 'active'),
('USD', '美元', '$', 2, '0', 'active'),
('JPY', '日元', '¥', 0, '0', 'active'),
('GBP', '英镑', '£', 2, '0', 'active');
CREATE TABLE IF NOT EXISTS accounting.exchange_rate (
id VARCHAR(32) NOT NULL PRIMARY KEY,
from_currency VARCHAR(3) NOT NULL,
to_currency VARCHAR(3) NOT NULL,
buy_rate DOUBLE(14,6) DEFAULT 0,
sell_rate DOUBLE(14,6) DEFAULT 0,
mid_rate DOUBLE(14,6) DEFAULT 0,
effective_date DATE NOT NULL,
updated_at DATETIME DEFAULT NULL,
UNIQUE KEY idx_pair_date (from_currency, to_currency, effective_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO accounting.exchange_rate (id, from_currency, to_currency, buy_rate, sell_rate, mid_rate, effective_date) VALUES
('er_usd_cny', 'USD', 'CNY', 7.150000, 7.250000, 7.200000, '2025-01-01'),
('er_jpy_cny', 'JPY', 'CNY', 0.048000, 0.049000, 0.048500, '2025-01-01'),
('er_gbp_cny', 'GBP', 'CNY', 9.100000, 9.250000, 9.175000, '2025-01-01'),
('er_cny_usd', 'CNY', 'USD', 0.137900, 0.139900, 0.138900, '2025-01-01');
INSERT IGNORE INTO accounting.appcodes (id, name, hierarchy_flg) VALUES ('currency_status', '币种状态', '0');
INSERT IGNORE INTO accounting.appcodes_kv (id, parentid, k, v) VALUES
('currency_status_active', 'currency_status', 'active', '启用'),
('currency_status_inactive', 'currency_status', 'inactive', '停用');
-- ─── 2. accounting: 账务表加币种字段 ───
ALTER TABLE accounting.account ADD COLUMN currency VARCHAR(3) DEFAULT 'CNY' AFTER org1id;
ALTER TABLE accounting.acc_detail ADD COLUMN currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE accounting.acc_detail ADD COLUMN exchange_rate DOUBLE(14,6) DEFAULT 1.0 AFTER currency;
ALTER TABLE accounting.acc_detail ADD COLUMN base_amount DOUBLE(20,2) DEFAULT 0 AFTER exchange_rate;
ALTER TABLE accounting.bill ADD COLUMN currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE accounting.bill ADD COLUMN base_amount DOUBLE(20,2) DEFAULT 0 AFTER currency;
ALTER TABLE accounting.bill_detail ADD COLUMN currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE accounting.bill_detail ADD COLUMN exchange_rate DOUBLE(14,6) DEFAULT 1.0 AFTER currency;
ALTER TABLE accounting.bill_detail ADD COLUMN base_amount DOUBLE(20,2) DEFAULT 0 AFTER exchange_rate;
ALTER TABLE accounting.ledger ADD COLUMN currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE accounting.ledger ADD COLUMN exchange_rate DOUBLE(14,6) DEFAULT 1.0 AFTER currency;
ALTER TABLE accounting.ledger ADD COLUMN base_amount DOUBLE(20,2) DEFAULT 0 AFTER exchange_rate;
-- ─── 3. pricing: 定价项目加币种 ───
ALTER TABLE pricing_program ADD COLUMN currency VARCHAR(3) DEFAULT 'CNY' AFTER description;
-- ─── 4. llmage: 使用记录加币种字段 ───
ALTER TABLE llmage.llmusage ADD COLUMN amount_currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE llmage.llmusage ADD COLUMN amount_base DOUBLE(20,2) DEFAULT 0 AFTER amount_currency;
ALTER TABLE llmage.llmusage ADD COLUMN cost_currency VARCHAR(3) DEFAULT 'CNY' AFTER cost;
ALTER TABLE llmage.llmusage ADD COLUMN cost_base DOUBLE(20,2) DEFAULT 0 AFTER cost_currency;
-- ─── 5. product_management: 订单表加币种 ───
ALTER TABLE product_management.biz_order ADD COLUMN currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE product_management.biz_orderdetail ADD COLUMN currency VARCHAR(3) DEFAULT 'CNY' AFTER trans_amount;

View File

@ -1,27 +0,0 @@
-- Credit Limit Table for accounting module
-- Run this DDL on the sage database to create the credit_limit table
CREATE TABLE IF NOT EXISTS credit_limit (
id VARCHAR(32) NOT NULL COMMENT '主键ID',
accountid VARCHAR(32) NOT NULL COMMENT '账户ID',
orgid VARCHAR(32) NOT NULL COMMENT '机构ID',
credit_limit DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '信用额度',
used_credit DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '已用额度',
available_credit DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '可用额度',
valid_from DATE COMMENT '生效日期',
valid_to DATE COMMENT '失效日期',
status VARCHAR(10) NOT NULL DEFAULT 'active' COMMENT '状态',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL COMMENT '更新时间',
created_by VARCHAR(32) COMMENT '创建人',
remark VARCHAR(500) COMMENT '备注',
PRIMARY KEY (id),
UNIQUE INDEX idx_credit_limit_account (accountid),
INDEX idx_credit_limit_orgid (orgid),
INDEX idx_credit_limit_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Insert credit_status codes into appcodes_kv
INSERT IGNORE INTO appcodes_kv (parentid, k, v) VALUES ('credit_status', 'active', '生效');
INSERT IGNORE INTO appcodes_kv (parentid, k, v) VALUES ('credit_status', 'inactive', '停用');
INSERT IGNORE INTO appcodes_kv (parentid, k, v) VALUES ('credit_status', 'expired', '已过期');

View File

@ -1,14 +0,0 @@
# 币种创建
ns = {
'id': params_kw.get('id', ''),
'name': params_kw.get('name', ''),
'symbol': params_kw.get('symbol', ''),
'decimal_places': params_kw.get('decimal_places', 2),
'is_base': params_kw.get('is_base', '0'),
'status': params_kw.get('status', 'active'),
}
if not ns['id'] or not ns['name']:
return json.dumps({'success': False, 'message': 'id and name required'})
async with get_sor_context(request._run_ns, 'accounting') as sor:
await sor.C('currency', ns)
return json.dumps({'success': True, 'message': '币种已创建'})

View File

@ -1,7 +0,0 @@
# 币种删除
id = params_kw.get('id', '')
if not id:
return json.dumps({'success': False, 'message': 'id required'})
async with get_sor_context(request._run_ns, 'accounting') as sor:
await sor.D('currency', {'id': id})
return json.dumps({'success': True, 'message': '币种已删除'})

View File

@ -1,12 +0,0 @@
# 币种更新
ns = {}
for k in ('name', 'symbol', 'decimal_places', 'is_base', 'status'):
v = params_kw.get(k)
if v is not None:
ns[k] = v
ns['id'] = params_kw.get('id', '')
if not ns['id']:
return json.dumps({'success': False, 'message': 'id required'})
async with get_sor_context(request._run_ns, 'accounting') as sor:
await sor.U('currency', ns)
return json.dumps({'success': True, 'message': '币种已更新'})

View File

@ -1,73 +0,0 @@
# 日终切日任务端点:日结余额快照 + 日结总账 + 营业日期推进
# 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
# 切日(catch-up 语义,同一 sor 上下文,全成才提交):
# 循环执行三步直至 business_date 追平今天:
# 1. dayend_balance —— 上一营业日余额快照(acc_balance 补齐无当日行的账户)
# 2. accounting_ledger —— 上一营业日总账(delete+insert 幂等重建 ledger 当日行)
# 3. new_business_date —— params.business_date 推进 +1 天
# 停机多日/cron 漏跑后一次调用自动追平,不会漏日;同日重放 0 步跳过(幂等)。
# _MAX_CATCHUP 防御异常配置(如 business_date 误写远古值)导致超长循环。
_MAX_CATCHUP = 400
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
today = date.today().isoformat()
bd = await get_business_date(sor)
start_bd = bd
if bd > today:
return json.dumps({
'success': False,
'business_date': bd,
'message': 'business_date 超前于今天(配置异常),拒绝切日',
}, ensure_ascii=False)
steps = 0
snapshots = []
while bd < today and steps < _MAX_CATCHUP:
snap_date = await dayend_balance(sor)
await accounting_ledger(sor)
await new_business_date(sor)
bd = await get_business_date(sor)
snapshots.append(snap_date)
steps += 1
if steps == 0:
return json.dumps({
'success': True, 'skipped': True,
'business_date': bd,
'message': '已是当日营业日期,跳过切日(幂等)',
}, ensure_ascii=False)
if bd < today:
# 触达防御上限仍未追平:已推进的天数是完整日结单元、照常生效(可续跑),
# 但 business_date 疑似配置异常(如误写远古值),必须人工核查后再续。
return json.dumps({
'success': False,
'business_date': '%s -> %s' % (start_bd, bd),
'steps': steps,
'message': 'catch-up 达 %d 天上限仍未追平今天,business_date 疑似配置异常,'
'请人工核查后重跑(已推进的 %d 天日结完整生效,重跑自动续推)'
% (_MAX_CATCHUP, steps),
}, ensure_ascii=False)
await sor.sqlExe("COMMIT", {})
return json.dumps({
'success': True,
'business_date': '%s -> %s' % (start_bd, bd),
'steps': steps,
'snapshots': snapshots[-5:],
}, ensure_ascii=False)
except Exception as e:
return json.dumps({'success': False, 'message': 'dayend failed: %s' % str(e)[:200]},
ensure_ascii=False)

View File

@ -1,16 +0,0 @@
# 汇率创建
ns = {
'id': getID(),
'from_currency': params_kw.get('from_currency', ''),
'to_currency': params_kw.get('to_currency', ''),
'buy_rate': params_kw.get('buy_rate', 0),
'sell_rate': params_kw.get('sell_rate', 0),
'mid_rate': params_kw.get('mid_rate', 0),
'effective_date': params_kw.get('effective_date', curDateString()),
'updated_at': timestampstr(),
}
if not ns['from_currency'] or not ns['to_currency']:
return json.dumps({'success': False, 'message': 'from_currency and to_currency required'})
async with get_sor_context(request._run_ns, 'accounting') as sor:
await sor.C('exchange_rate', ns)
return json.dumps({'success': True, 'message': '汇率已创建'})

View File

@ -1,7 +0,0 @@
# 汇率删除
id = params_kw.get('id', '')
if not id:
return json.dumps({'success': False, 'message': 'id required'})
async with get_sor_context(request._run_ns, 'accounting') as sor:
await sor.D('exchange_rate', {'id': id})
return json.dumps({'success': True, 'message': '汇率已删除'})

View File

@ -1,13 +0,0 @@
# 汇率更新
ns = {}
for k in ('from_currency', 'to_currency', 'buy_rate', 'sell_rate', 'mid_rate', 'effective_date'):
v = params_kw.get(k)
if v is not None:
ns[k] = v
ns['id'] = params_kw.get('id', '')
ns['updated_at'] = timestampstr()
if not ns['id']:
return json.dumps({'success': False, 'message': 'id required'})
async with get_sor_context(request._run_ns, 'accounting') as sor:
await sor.U('exchange_rate', ns)
return json.dumps({'success': True, 'message': '汇率已更新'})

View File

@ -1,73 +0,0 @@
# 获取中国银行外汇牌价并插入 exchange_rate 表
# GET /accounting/api/fetch_forex_rates.dspy
#
# 安全:本端点是定时任务入口(会发起外网抓取 + 写库),只允许本机调用。
# 之前无任何鉴权且授权给 any 角色,匿名可反复触发 → 外部抓取放大 + 脏数据写入 + DoS。
# 判定依据 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)
import re
import urllib.request
from datetime import date
BOC_URL = 'https://www.boc.cn/sourcedb/whpj/index.html'
CURRENCIES = {'美元': 'USD', '日元': 'JPY', '英镑': 'GBP'}
async with get_sor_context(request._run_ns, 'accounting') as sor:
# 1. 抓取 BOC 页面
try:
req = urllib.request.Request(BOC_URL, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=15) as resp:
html = resp.read().decode('utf-8', errors='replace')
except Exception as e:
return json.dumps({'success': False, 'message': f'获取BOC页面失败: {e}'}, ensure_ascii=False)
today = date.today().isoformat()
inserted = 0
for cn_name, code in CURRENCIES.items():
pos = html.find(cn_name)
if pos < 0:
continue
chunk = html[pos:pos+500]
tds = re.findall(r'<td[^>]*>(.*?)</td>', chunk, re.DOTALL)
clean = [re.sub(r'<[^>]+>', '', t).strip() for t in tds]
if len(clean) < 6:
continue
try:
buy_rate = float(clean[1]) # 现汇买入价
sell_rate = float(clean[3]) # 现汇卖出价
mid_rate = float(clean[5]) # 中行折算价
except ValueError:
continue
# BOC quotes USD/GBP per 100 units, JPY per 100 units
if code in ('USD', 'GBP'):
buy_rate = round(buy_rate / 100, 6)
sell_rate = round(sell_rate / 100, 6)
mid_rate = round(mid_rate / 100, 6)
elif code == 'JPY':
buy_rate = round(buy_rate / 100, 6)
sell_rate = round(sell_rate / 100, 6)
mid_rate = round(mid_rate / 100, 6)
record_id = f'er_{code.lower()}_cny_{today.replace("-","")}'
await sor.execute(
"""INSERT INTO exchange_rate (id, from_currency, to_currency, buy_rate, sell_rate, mid_rate, effective_date)
VALUES (${id}$, ${from_cur}$, 'CNY', ${buy}$, ${sell}$, ${mid}$, ${date}$)
ON DUPLICATE KEY UPDATE buy_rate=${buy}$, sell_rate=${sell}$, mid_rate=${mid}$""",
{'id': record_id, 'from_cur': code, 'buy': buy_rate, 'sell': sell_rate, 'mid': mid_rate, 'date': today}
)
inserted += 1
return json.dumps({
'success': True,
'message': f'已更新 {inserted} 条汇率',
'date': today,
'source': '中国银行外汇牌价'
}, ensure_ascii=False)

View File

@ -1,54 +0,0 @@
debug(f'{params_kw=}')
userid = await get_user()
userorgid = await get_userorgid()
start_date = params_kw.get('start_date', '')
end_date = params_kw.get('end_date', '')
if not start_date or not end_date:
return json.dumps({'total': 0, 'rows': [], 'stats': {'total_count': 0, 'debit_sum': 0, 'credit_sum': 0}}, ensure_ascii=False, default=str)
ns = {
'orgid': userorgid,
'start_date': start_date,
'end_date': end_date,
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 60)),
'sort': 'acc_timestamp desc'
}
async with get_sor_context(request._run_ns, 'accounting') as sor:
sql = """select d.acc_timestamp,
case when d.acc_dir = '0' then '借' else '贷' end as acc_dir,
d.amount, d.balance, s.name as subject_name,
count(*) over() as _total,
coalesce(sum(case when d.acc_dir = '0' then d.amount else 0 end) over(), 0) as _debit,
coalesce(sum(case when d.acc_dir = '1' then d.amount else 0 end) over(), 0) as _credit
from acc_detail d
join account a on d.accountid = a.id COLLATE utf8mb4_unicode_ci
left join subject s on a.subjectid = s.id COLLATE utf8mb4_unicode_ci
where a.orgid = ${orgid}$
and d.acc_date >= ${start_date}$
and d.acc_date <= ${end_date}$"""
rows = await sor.sqlExe(sql, ns)
if not isinstance(rows, list):
data_rows = rows.rows
total = rows.total
first = data_rows[0] if data_rows else None
else:
data_rows = rows
total = len(rows)
first = data_rows[0] if data_rows else None
stats = {
'total_count': total,
'debit_sum': round(float(first._debit), 4) if first else 0,
'credit_sum': round(float(first._credit), 4) if first else 0
}
result = {
'total': total,
'rows': data_rows,
'stats': stats
}
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,152 +0,0 @@
{% set billing_url = entire_url('/accounting/billing.dspy') %}
{
"widgettype": "VBox",
"id": "billing_page",
"options": {
"width": "100%",
"height": "100%",
"gap": "10px"
},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "billing_form",
"options": {
"fields": [
{
"name": "start_date",
"label": "开始日期",
"uitype": "date",
"required": true
},
{
"name": "end_date",
"label": "结束日期",
"uitype": "date",
"required": true
}
],
"submit_label": "查询"
},
"binds": [
{
"wid": "self",
"event": "submit",
"actiontype": "urlwidget",
"target": "app.billing_tabular",
"options": {
"url": "{{billing_url}}",
"params": {}
}
}
]
},
{
"widgettype": "HBox",
"id": "billing_stats_box",
"options": {
"width": "100%",
"gap": "20px",
"align_items": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"id": "billing_stats",
"options": {
"text": "请输入日期范围进行查询",
"css": "font-size: 14px; color: #666;"
}
},
{
"widgettype": "Text",
"id": "billing_download_btn",
"options": {
"text": " 下载Excel ",
"bgcolor": "#52c41a",
"color": "#FFFFFF",
"css": "border-radius: 4px; padding: 4px 12px; cursor: pointer; font-size: 14px;"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"script": "var sd=document.querySelector(\"[name='start_date']\"),ed=document.querySelector(\"[name='end_date']\");if(!sd||!ed||!sd.value||!ed.value){bricks.show_error({title:'提示',message:'请先选择日期范围'});return;}var u='/accounting/billing_download.dspy?start_date='+encodeURIComponent(sd.value)+'&end_date='+encodeURIComponent(ed.value);fetch(u,{credentials:'include'}).then(function(r){return r.json()}).then(function(d){if(d.status!=='ok'){bricks.show_error({title:'下载失败',message:d.data.message});return;}var b=atob(d.data.content),n=b.length,arr=new Uint8Array(n);for(var i=0;i<n;i++)arr[i]=b.charCodeAt(i);var blob=new Blob([arr],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=d.data.filename;a.click()}).catch(function(e){bricks.show_error({title:'下载失败',message:e.toString()})}"
}
]
}
]
},
{
"widgettype": "Tabular",
"id": "billing_tabular",
"options": {
"width": "100%",
"flex": "1",
"data_url": "{{entire_url('/accounting/billing.dspy')}}",
"editable": false,
"page_rows": 60,
"cache_limit": 3,
"row_options": {
"browserfields": {
"exclouded": ["row_num_"]
},
"fields": [
{
"name": "acc_timestamp",
"title": "时间",
"type": "timestamp",
"uitype": "timestamp",
"datatype": "timestamp",
"label": "时间",
"cwidth": 18
},
{
"name": "subject_name",
"title": "科目",
"type": "str",
"length": 50,
"uitype": "str",
"datatype": "str",
"label": "科目",
"cwidth": 14
},
{
"name": "acc_dir",
"title": "方向",
"type": "str",
"length": 4,
"uitype": "str",
"datatype": "str",
"label": "方向",
"cwidth": 6
},
{
"name": "amount",
"title": "金额",
"type": "float",
"length": 18,
"dec": 4,
"uitype": "float",
"datatype": "float",
"label": "金额",
"cwidth": 12
},
{
"name": "balance",
"title": "余额",
"type": "float",
"length": 18,
"dec": 4,
"uitype": "float",
"datatype": "float",
"label": "余额",
"cwidth": 12
}
]
}
}
}
]
}

View File

@ -1,84 +0,0 @@
import io
import base64
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
userid = await get_user()
userorgid = await get_userorgid()
start_date = params_kw.get('start_date', '')
end_date = params_kw.get('end_date', '')
if not start_date or not end_date:
return json.dumps({'status': 'error', 'data': {'message': '缺少日期参数'}}, ensure_ascii=False)
ns = {
'orgid': userorgid,
'start_date': start_date,
'end_date': end_date
}
async with get_sor_context(request._run_ns, 'accounting') as sor:
sql = """select d.acc_timestamp,
concat('#', substring_index(d.summary, ':', 1)) as summary,
s.name as subject_name,
case when d.acc_dir = '0' then '借' else '贷' end as acc_dir,
d.amount, d.balance
from acc_detail d
join account a on d.accountid = a.id COLLATE utf8mb4_unicode_ci
left join subject s on a.subjectid = s.id COLLATE utf8mb4_unicode_ci
where a.orgid = ${orgid}$
and d.acc_date >= ${start_date}$
and d.acc_date <= ${end_date}$
order by d.acc_timestamp desc"""
recs = await sor.sqlExe(sql, ns)
# 生成 xlsx
wb = Workbook()
ws = wb.active
ws.title = '账单明细'
# 表头
headers = ['时间', '科目', '方向', '摘要', '金额', '余额']
ws.append(headers)
header_font = Font(bold=True)
for cell in ws[1]:
cell.font = header_font
cell.alignment = Alignment(horizontal='center')
# 数据
for rec in recs:
ws.append([
str(rec.acc_timestamp),
rec.subject_name,
rec.acc_dir,
rec.summary,
float(rec.amount),
float(rec.balance)
])
# 调整列宽
ws.column_dimensions['A'].width = 12
ws.column_dimensions['B'].width = 20
ws.column_dimensions['C'].width = 15
ws.column_dimensions['D'].width = 8
ws.column_dimensions['E'].width = 40
ws.column_dimensions['F'].width = 15
ws.column_dimensions['G'].width = 15
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
# Base64 编码
b64_data = base64.b64encode(output.read()).decode('utf-8')
filename = f'账单明细_{start_date}_{end_date}.xlsx'
return json.dumps({
'status': 'ok',
'data': {
'filename': filename,
'content': b64_data
}
}, ensure_ascii=False)

View File

@ -1,43 +0,0 @@
orgid = await get_userorgid()
db = DBPools()
dbname = get_module_dbname('accounting')
async with db.sqlorContext(dbname) as sor:
sql = """
SELECT
COALESCE(SUM(credit_limit), 0) as total_credit,
COALESCE(SUM(used_credit), 0) as total_used,
COALESCE(SUM(available_credit), 0) as total_available,
COUNT(*) as customer_count,
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_count,
COUNT(CASE WHEN status = 'expired' THEN 1 END) as expired_count
FROM credit_limit
WHERE grant_orgid = ${orgid}$
"""
recs = await sor.sqlExe(sql, {'orgid': orgid})
if recs and len(recs) > 0:
r = recs[0]
total_credit = float(r.total_credit or 0)
total_used = float(r.total_used or 0)
total_available = float(r.total_available or 0)
usage_pct = round((total_used / total_credit * 100), 1) if total_credit > 0 else 0
return json.dumps({
"status": "ok",
"data": {
"total_credit": total_credit,
"total_used": total_used,
"total_available": total_available,
"usage_pct": usage_pct,
"customer_count": int(r.customer_count or 0),
"active_count": int(r.active_count or 0),
"expired_count": int(r.expired_count or 0)
}
})
return json.dumps({
"status": "ok",
"data": {
"total_credit": 0, "total_used": 0, "total_available": 0,
"usage_pct": 0, "customer_count": 0, "active_count": 0, "expired_count": 0
}
})

View File

@ -1,56 +0,0 @@
{
"widgettype": "Form",
"id": "credit_form",
"options": {
"width": "100%",
"padding": "16px",
"submit_url": "{{entire_url('/accounting/credit_limit/api/set_customer_credit.dspy')}}{% if params_kw.get('id') %}?id={{params_kw.get('id')}}{% endif %}",
"fields": [
{
"name": "customer_name",
"label": "客户名称",
"uitype": "str",
"required": true,
"value": "{{params_kw.get('customer_name', '')}}",
"placeholder": "输入客户名称进行查找"
},
{
"name": "credit_limit",
"label": "授信额度",
"uitype": "float",
"required": true,
"value": "{{params_kw.get('credit_limit', '0')}}"
},
{
"name": "valid_from",
"label": "生效日期",
"uitype": "date",
"value": "{{params_kw.get('valid_from', '')}}"
},
{
"name": "valid_to",
"label": "失效日期",
"uitype": "date",
"value": "{{params_kw.get('valid_to', '')}}"
},
{
"name": "remark",
"label": "备注",
"uitype": "str",
"value": "{{params_kw.get('remark', '')}}"
}
]
},
"binds": [
{
"wid": "self",
"event": "submited",
"actiontype": "urlwidget",
"target": "app.sage_main_content",
"mode": "replace",
"options": {
"url": "{{entire_url('/accounting/credit_limit/credit_manage.ui')}}"
}
}
]
}

View File

@ -1,211 +0,0 @@
ns = params_kw.copy()
for k, v in ns.items():
if v == 'NaN' or v == 'null' or v == '':
ns[k] = None
customer_name = ns.get('customer_name', '').strip() if ns.get('customer_name') else ''
if not customer_name:
return {
"widgettype": "Error",
"options": {
"title": "参数错误",
"cwidth": 16,
"cheight": 9,
"timeout": 3,
"message": "客户名称不能为空"
}
}
credit_limit_amount = float(ns.get('credit_limit', 0) or 0)
if credit_limit_amount <= 0:
return {
"widgettype": "Error",
"options": {
"title": "参数错误",
"cwidth": 16,
"cheight": 9,
"timeout": 3,
"message": "授信额度必须大于0"
}
}
valid_from = ns.get('valid_from')
valid_to = ns.get('valid_to')
remark = ns.get('remark')
record_id = ns.get('id')
user_id = await get_user()
orgid = await get_userorgid()
db = DBPools()
dbname = get_module_dbname('accounting')
async with db.sqlorContext(dbname) as sor:
# Look up customer by name
lookup_sql = """
select o.id as orgid, o.orgname, a.id as accountid
from organization o
left join account a on a.orgid = o.id COLLATE utf8mb4_unicode_ci
where o.orgname = ${customer_name}$
limit 1
"""
recs = await sor.sqlExe(lookup_sql, {'customer_name': customer_name})
if not recs or len(recs) == 0:
# Try fuzzy match
fuzzy_sql = """
select o.id as orgid, o.orgname, a.id as accountid
from organization o
left join account a on a.orgid = o.id COLLATE utf8mb4_unicode_ci
where o.orgname LIKE ${customer_name}$
limit 1
"""
recs = await sor.sqlExe(fuzzy_sql, {'customer_name': f'%{customer_name}%'})
if not recs or len(recs) == 0:
return {
"widgettype": "Error",
"options": {
"title": "查找失败",
"cwidth": 16,
"cheight": 9,
"timeout": 3,
"message": f"未找到客户: {customer_name}"
}
}
customer = recs[0]
customer_orgid = customer.orgid
accountid = customer.accountid
if not accountid:
return {
"widgettype": "Error",
"options": {
"title": "查找失败",
"cwidth": 16,
"cheight": 9,
"timeout": 3,
"message": f"客户 {customer.orgname} 尚未开设账户"
}
}
try:
# Check if credit limit already exists for this account (UNIQUE on accountid)
exist_sql = "select id, used_credit from credit_limit where accountid = ${accountid}$"
exist_recs = await sor.sqlExe(exist_sql, {'accountid': accountid})
if exist_recs and len(exist_recs) > 0:
# Account already has a credit limit record — update it
existing = exist_recs[0]
sql = """
UPDATE credit_limit
SET credit_limit = ${credit_limit}$,
available_credit = ${credit_limit}$ - used_credit,
orgid = ${orgid}$,
grant_orgid = ${grant_orgid}$,
valid_from = ${valid_from}$,
valid_to = ${valid_to}$,
remark = ${remark}$,
updated_at = CURRENT_TIMESTAMP
WHERE accountid = ${accountid}$
"""
await sor.sqlExe(sql, {
'credit_limit': credit_limit_amount,
'orgid': customer_orgid,
'grant_orgid': orgid,
'valid_from': valid_from,
'valid_to': valid_to,
'remark': remark,
'accountid': accountid
})
debug(f'Updated credit limit for {customer.orgname}(accountid={accountid}): {credit_limit_amount}')
elif record_id:
sql = """
UPDATE credit_limit
SET credit_limit = ${credit_limit}$,
available_credit = ${credit_limit}$ - used_credit,
grant_orgid = ${grant_orgid}$,
valid_from = ${valid_from}$,
valid_to = ${valid_to}$,
remark = ${remark}$,
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}$ AND orgid = ${orgid}$
"""
await sor.sqlExe(sql, {
'credit_limit': credit_limit_amount,
'grant_orgid': orgid,
'valid_from': valid_from,
'valid_to': valid_to,
'remark': remark,
'id': record_id,
'orgid': orgid
})
debug(f'Updated credit limit {record_id} to {credit_limit_amount}')
else:
new_id = getID()
now = datetime.datetime.now()
data = {
'id': new_id,
'accountid': accountid,
'orgid': customer_orgid,
'grant_orgid': orgid,
'credit_limit': credit_limit_amount,
'used_credit': 0,
'available_credit': credit_limit_amount,
'valid_from': valid_from,
'valid_to': valid_to,
'status': 'active',
'created_at': now,
'updated_at': now,
'created_by': user_id,
'remark': remark
}
await sor.C('credit_limit', data)
debug(f'Created credit limit for {customer.orgname}(orgid={customer_orgid}, accountid={accountid}): {credit_limit_amount}')
return {
"widgettype": "Message",
"options": {
"cwidth": 16,
"cheight": 9,
"title": "授信额度设置成功",
"timeout": 3,
"message": "ok"
}
}
except Exception as e:
err_msg = str(e)
debug(f'set_customer_credit error: {format_exc()}')
if 'Duplicate' in err_msg or '1062' in err_msg:
return {
"widgettype": "Error",
"options": {
"title": "重复数据",
"cwidth": 16,
"cheight": 9,
"timeout": 5,
"message": f"客户 {customer.orgname} 已有授信记录,请使用调整功能修改额度"
}
}
return {
"widgettype": "Error",
"options": {
"title": "设置失败",
"cwidth": 16,
"cheight": 9,
"timeout": 5,
"message": err_msg
}
}
return {
"widgettype": "Error",
"options": {
"title": "设置失败",
"cwidth": 16,
"cheight": 9,
"timeout": 3,
"message": "failed"
}
}

View File

@ -1,278 +0,0 @@
{% set credits = get_all_credits_web(request) %}
{
"widgettype": "VBox",
"options": {
"width": "100%",
"gap": "12px",
"padding": "4px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"justifyContent": "space-between"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "客户额度管理 (共{{credits|length}}条)",
"fontSize": "16px",
"fontWeight": "600",
"color": "#F1F5F9"
}
},
{
"widgettype": "Button",
"options": {
"label": "新增授信",
"bgcolor": "#3B82F6",
"color": "#FFFFFF",
"borderRadius": "6px",
"padding": "6px 14px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "新增客户授信",
"width": "480px",
"height": "520px",
"dismiss_events": ["cancel", "submited"]
},
"options": {
"url": "{{entire_url('/accounting/credit_limit/api/set_credit_form.ui')}}"
}
}]
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"borderRadius": "10px",
"border": "1px solid #334155",
"width": "100%"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "#0F172A",
"padding": "10px 16px",
"borderRadius": "10px 10px 0 0",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {"text": "客户名称", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 10}
},
{
"widgettype": "Text",
"options": {"text": "授信额度", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 8}
},
{
"widgettype": "Text",
"options": {"text": "已用/剩余", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 10}
},
{
"widgettype": "Text",
"options": {"text": "使用率", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 6}
},
{
"widgettype": "Text",
"options": {"text": "状态", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 4}
},
{
"widgettype": "Text",
"options": {"text": "操作", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 6}
}
]
},
{% if credits|length == 0 %}
{
"widgettype": "VBox",
"options": {
"padding": "30px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "暂无客户授信记录",
"fontSize": "14px",
"color": "#64748B"
}
}
]
}
{% else %}
{% for c in credits %}
{
"widgettype": "HBox",
"options": {
"padding": "12px 16px",
"alignItems": "center",
"border": "0 0 1px 0",
"borderColor": "#334155"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"cwidth": 10, "gap": "2px"},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "{{c.orgname_text or c.accountid}}",
"fontSize": "13px",
"fontWeight": "500",
"color": "#F1F5F9"
}
},
{
"widgettype": "Text",
"options": {
"text": "{{c.subject_name or ''}}",
"fontSize": "11px",
"color": "#64748B"
}
}
]
},
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % c.credit_limit}}",
"fontSize": "13px",
"fontWeight": "600",
"color": "#3B82F6",
"cwidth": 8
}
},
{
"widgettype": "VBox",
"options": {"cwidth": 10, "gap": "2px"},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "已用 ¥{{'%.2f' % c.used_credit}}",
"fontSize": "12px",
"color": "#F59E0B"
}
},
{
"widgettype": "Text",
"options": {
"text": "剩余 ¥{{'%.2f' % c.available_credit}}",
"fontSize": "12px",
"color": "#22C55E"
}
}
]
},
{
"widgettype": "HBox",
"options": {"cwidth": 6, "alignItems": "center", "gap": "4px"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "#334155",
"borderRadius": "3px",
"height": "6px",
"width": "50px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "{{'#22C55E' if c.usage_pct < 60 else ('#F59E0B' if c.usage_pct < 85 else '#EF4444')}}",
"borderRadius": "3px",
"height": "6px",
"width": "{{c.usage_pct}}%"
},
"subwidgets": []
}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{c.usage_pct}}%",
"fontSize": "11px",
"color": "{{'#22C55E' if c.usage_pct < 60 else ('#F59E0B' if c.usage_pct < 85 else '#EF4444')}}"
}
}
]
},
{
"widgettype": "VBox",
"options": {"cwidth": 4},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "{{'生效' if c.status == 'active' else ('停用' if c.status == 'inactive' else '过期')}}",
"fontSize": "11px",
"fontWeight": "600",
"color": "{{'#22C55E' if c.status == 'active' else '#EF4444'}}"
}
}
]
},
{
"widgettype": "HBox",
"options": {"cwidth": 6, "gap": "4px"},
"subwidgets": [
{
"widgettype": "Button",
"options": {
"label": "调整",
"bgcolor": "#475569",
"color": "#FFFFFF",
"borderRadius": "4px",
"padding": "4px 8px",
"fontSize": "11px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "调整授信额度",
"width": "480px",
"height": "520px",
"dismiss_events": ["cancel", "submited"]
},
"options": {
"url": "{{entire_url('/accounting/credit_limit/api/set_credit_form.ui')}}",
"params_kw": {
"id": "{{c.id}}",
"customer_name": "{{c.orgname_text or ''}}",
"credit_limit": "{{c.credit_limit}}",
"valid_from": "{{c.valid_from or ''}}",
"valid_to": "{{c.valid_to or ''}}",
"remark": "{{c.remark or ''}}"
}
}
}]
}
]
}
]
}{% if not loop.last %},{% endif %}
{% endfor %}
{% endif %}
]
}
]
}

View File

@ -1,275 +0,0 @@
{% set credits = get_my_credits_web(request) %}
{
"widgettype": "VBox",
"options": {
"width": "100%",
"gap": "12px",
"padding": "4px"
},
"subwidgets": [
{% if credits|length == 0 %}
{
"widgettype": "VBox",
"options": {
"css": "card",
"padding": "40px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "暂无信用额度记录",
"fontSize": "16px"
}
},
{
"widgettype": "Text",
"options": {
"text": "请联系您的分销商销售人员为您设置信用额度",
"fontSize": "13px",
"marginTop": "8px"
}
}
]
}
{% else %}
{% for c in credits %}
{
"widgettype": "VBox",
"options": {
"css": "card",
"padding": "16px",
"gap": "12px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"justifyContent": "space-between"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"gap": "2px"},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "{{c.orgname_text or '未知客户'}}",
"fontSize": "16px",
"fontWeight": "600"
}
},
{
"widgettype": "Text",
"options": {
"text": "{{c.subject_name or ''}} | 账户: {{c.accountid}}",
"fontSize": "12px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "{{'#16A34A22' if c.status == 'active' else '#EF444422'}}",
"padding": "4px 12px",
"borderRadius": "12px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "{{'生效' if c.status == 'active' else ('停用' if c.status == 'inactive' else '已过期')}}",
"fontSize": "12px",
"fontWeight": "600",
"color": "{{'#22C55E' if c.status == 'active' else '#EF4444'}}"
}
}
]
}
]
},
{
"widgettype": "HBox",
"options": {
"gap": "12px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"flex": "1", "gap": "4px"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"justifyContent": "space-between"},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "额度使用",
"fontSize": "12px"
}
},
{
"widgettype": "Text",
"options": {
"text": "{{c.usage_pct}}%",
"fontSize": "12px",
"fontWeight": "600",
"color": "{{'#22C55E' if c.usage_pct < 60 else ('#F59E0B' if c.usage_pct < 85 else '#EF4444')}}"
}
}
]
},
{
"widgettype": "HBox",
"options": {
"css": "subcard",
"borderRadius": "4px",
"height": "8px",
"width": "100%"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "{{'#22C55E' if c.usage_pct < 60 else ('#F59E0B' if c.usage_pct < 85 else '#EF4444')}}",
"borderRadius": "4px",
"height": "8px",
"width": "{{c.usage_pct}}%"
},
"subwidgets": []
}
]
}
]
}
]
},
{
"widgettype": "HBox",
"options": {
"gap": "8px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"flex": "1",
"css": "subcard",
"padding": "10px",
"borderRadius": "6px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % c.credit_limit}}",
"fontSize": "16px",
"fontWeight": "700",
"color": "#3B82F6"
}
},
{
"widgettype": "Text",
"options": {
"text": "授信额度",
"fontSize": "11px",
"marginTop": "2px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"flex": "1",
"css": "subcard",
"padding": "10px",
"borderRadius": "6px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % c.used_credit}}",
"fontSize": "16px",
"fontWeight": "700",
"color": "#F59E0B"
}
},
{
"widgettype": "Text",
"options": {
"text": "已用额度",
"fontSize": "11px",
"marginTop": "2px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"flex": "1",
"css": "subcard",
"padding": "10px",
"borderRadius": "6px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % c.available_credit}}",
"fontSize": "16px",
"fontWeight": "700",
"color": "#22C55E"
}
},
{
"widgettype": "Text",
"options": {
"text": "剩余额度",
"fontSize": "11px",
"marginTop": "2px"
}
}
]
}
]
},
{
"widgettype": "HBox",
"options": {
"justifyContent": "space-between"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "有效期: {{c.valid_from or '不限'}} ~ {{c.valid_to or '不限'}}",
"fontSize": "11px"
}
},
{
"widgettype": "Text",
"options": {
"text": "更新于 {{c.updated_at}}",
"fontSize": "11px"
}
}
]
}
]
}{% if not loop.last %},{% endif %}
{% endfor %}
{% endif %}
]
}

View File

@ -1,298 +0,0 @@
{% set cstats = get_credit_stats_web(request) %}
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "16px",
"gap": "16px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "信用额度管理",
"fontSize": "22px",
"fontWeight": "700",
"color": "#F1F5F9"
}
},
{
"widgettype": "ResponsableBox",
"options": {
"gap": "12px",
"minWidth": "200px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "12px",
"borderRadius": "10px",
"border": "1px solid #334155",
"flex": "none"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "6px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\"><path d=\"M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6\"/></svg>",
"width": "20px",
"height": "20px"
}
},
{"widgettype": "Filler"}
]
},
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % cstats.total_credit}}",
"fontSize": "22px",
"fontWeight": "700",
"color": "#3B82F6",
"lineHeight": "1.2"
}
},
{
"widgettype": "Text",
"options": {
"text": "授信总额度",
"fontSize": "13px",
"color": "#94A3B8",
"marginTop": "2px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "12px",
"borderRadius": "10px",
"border": "1px solid #334155",
"flex": "none"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "6px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#F59E0B\" stroke-width=\"2\"><path d=\"M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"/><path d=\"M9 12l2 2 4-4\"/></svg>",
"width": "20px",
"height": "20px"
}
},
{"widgettype": "Filler"}
]
},
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % cstats.total_used}}",
"fontSize": "22px",
"fontWeight": "700",
"color": "#F59E0B",
"lineHeight": "1.2"
}
},
{
"widgettype": "Text",
"options": {
"text": "已用额度",
"fontSize": "13px",
"color": "#94A3B8",
"marginTop": "2px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "12px",
"borderRadius": "10px",
"border": "1px solid #334155",
"flex": "none"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "6px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22C55E\" stroke-width=\"2\"><path d=\"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125H3.375a.75.75 0 01-.75-.75V4.5\"/></svg>",
"width": "20px",
"height": "20px"
}
},
{"widgettype": "Filler"}
]
},
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % cstats.total_available}}",
"fontSize": "22px",
"fontWeight": "700",
"color": "#22C55E",
"lineHeight": "1.2"
}
},
{
"widgettype": "Text",
"options": {
"text": "剩余额度",
"fontSize": "13px",
"color": "#94A3B8",
"marginTop": "2px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "12px",
"borderRadius": "10px",
"border": "1px solid #334155",
"flex": "none"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "6px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#A78BFA\" stroke-width=\"2\"><path d=\"M3 3v18h18\"/><path d=\"M18.7 8l-5.1 5.2-2.8-2.7L7 14.3\"/></svg>",
"width": "20px",
"height": "20px"
}
},
{"widgettype": "Filler"}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{cstats.usage_pct}}%",
"fontSize": "22px",
"fontWeight": "700",
"color": "#A78BFA",
"lineHeight": "1.2"
}
},
{
"widgettype": "Text",
"options": {
"text": "额度使用率 ({{cstats.active_count}}/{{cstats.customer_count}}户)",
"fontSize": "13px",
"color": "#94A3B8",
"marginTop": "2px"
}
}
]
}
]
},
{
"widgettype": "HBox",
"options": {
"gap": "8px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Button",
"options": {
"label": "我的额度",
"bgcolor": "#3B82F6",
"color": "#FFFFFF",
"borderRadius": "6px",
"padding": "8px 16px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.credit_content",
"options": {"url": "{{entire_url('/accounting/credit_limit/credit_overview.ui')}}"},
"mode": "replace"
}]
},
{
"widgettype": "Button",
"options": {
"label": "客户额度管理",
"bgcolor": "#475569",
"color": "#FFFFFF",
"borderRadius": "6px",
"padding": "8px 16px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.credit_content",
"options": {"url": "{{entire_url('/accounting/credit_limit/credit_manage.ui')}}"},
"mode": "replace"
}]
},
{
"widgettype": "Button",
"options": {
"label": "全部客户查询",
"bgcolor": "#475569",
"color": "#FFFFFF",
"borderRadius": "6px",
"padding": "8px 16px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.credit_content",
"options": {"url": "{{entire_url('/accounting/credit_limit/index.ui')}}"},
"mode": "replace"
}]
}
]
},
{
"widgettype": "VBox",
"id": "credit_content",
"options": {
"width": "100%",
"css": "filler",
"marginTop": "8px"
}
}
]
}

View File

@ -1,498 +0,0 @@
{#
错帐处理 (Error Accounting) Page
PURPOSE:
This page provides a management interface for handling accounting errors
and exceptions. It allows operators to review, correct, and resolve
accounting discrepancies.
INTENDED WORKFLOW:
1. An accounting exception is detected (manually or automatically):
- wrong_account: Transaction posted to incorrect account/subject
- duplicate_entry: Same transaction recorded more than once
- missing_entry: Expected transaction not found in records
- amount_mismatch: Debit/credit amounts don't balance or differ from source
2. Operator reviews the error log table (error_accounting_log):
- Each row shows: timestamp, error type, original transaction info, status
- Filter by status (pending/resolved) to prioritize work
3. Operator selects an error record and chooses a correction action:
- reverse_entry: Create a reversing journal entry to cancel the original
- adjust_entry: Create an adjustment entry to correct the amount/account
- mark_resolved: Flag as resolved without further action (e.g., duplicate already fixed)
4. The correction is recorded with an audit trail linking back to the original error.
DATA SOURCE:
- Table: error_accounting_log
- Expected fields: id, timestamp, error_type, original_trans_id,
original_subject, original_amount, original_summary,
error_description, status, resolved_at, resolved_by, correction_action
TOOLBAR ACTIONS:
- 报告错帐: Opens a form to manually report a new accounting error
- 全部: Show all error records (no filter)
- 待处理: Filter to show only pending/unresolved errors
- 已处理: Filter to show only resolved errors
ROW ACTIONS (on click):
- 冲正 (reverse_entry): Reverse the original transaction
- 调整 (adjust_entry): Create an adjustment/correction entry
- 标记已处理 (mark_resolved): Mark as resolved
#}
{
"widgettype": "VBox",
"id": "error_accounting_page",
"options": {
"width": "100%",
"height": "100%",
"gap": "12px",
"padding": "16px"
},
"subwidgets": [
{
"widgettype": "VBox",
"id": "error_acc_header",
"options": {
"bgcolor": "#1E293B",
"padding": "20px",
"borderRadius": "12px",
"border": "1px solid #334155",
"gap": "8px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"justifyContent": "space-between"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"gap": "4px"},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "错帐处理",
"fontSize": "22px",
"fontWeight": "700",
"color": "#F1F5F9"
}
},
{
"widgettype": "Text",
"options": {
"text": "会计差错管理 — 发现、纠正并解决帐务异常记录",
"fontSize": "13px",
"color": "#94A3B8"
}
}
]
}
]
}
]
},
{
"widgettype": "HBox",
"id": "error_acc_toolbar",
"options": {
"bgcolor": "#1E293B",
"padding": "12px 16px",
"borderRadius": "10px",
"border": "1px solid #334155",
"alignItems": "center",
"gap": "10px"
},
"subwidgets": [
{
"widgettype": "Button",
"id": "btn_report_error",
"options": {
"label": "报告错帐",
"bgcolor": "#EF4444",
"color": "#FFFFFF",
"borderRadius": "6px",
"padding": "8px 16px",
"fontWeight": "600"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "报告错帐",
"width": "560px",
"height": "600px",
"dismiss_events": ["cancel"]
},
"options": {
"url": "{{entire_url('/accounting/error_accounting_report.ui')}}"
}
}]
},
{
"widgettype": "Filler"
},
{
"widgettype": "Text",
"options": {
"text": "筛选:",
"fontSize": "13px",
"color": "#94A3B8"
}
},
{
"widgettype": "Button",
"id": "btn_filter_all",
"options": {
"label": "全部",
"bgcolor": "#3B82F6",
"color": "#FFFFFF",
"borderRadius": "6px",
"padding": "6px 12px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "script",
"script": "const tab = bricks.getWidgetById('error_acc_tabular'); if(tab) { tab.render({}); }"
}]
},
{
"widgettype": "Button",
"id": "btn_filter_pending",
"options": {
"label": "待处理",
"bgcolor": "#F59E0B",
"color": "#FFFFFF",
"borderRadius": "6px",
"padding": "6px 12px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "script",
"script": "const tab = bricks.getWidgetById('error_acc_tabular'); if(tab) { tab.render({status: 'pending'}); }"
}]
},
{
"widgettype": "Button",
"id": "btn_filter_resolved",
"options": {
"label": "已处理",
"bgcolor": "#22C55E",
"color": "#FFFFFF",
"borderRadius": "6px",
"padding": "6px 12px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "script",
"script": "const tab = bricks.getWidgetById('error_acc_tabular'); if(tab) { tab.render({status: 'resolved'}); }"
}]
}
]
},
{
"widgettype": "VBox",
"id": "error_acc_table_container",
"options": {
"bgcolor": "#1E293B",
"borderRadius": "10px",
"border": "1px solid #334155",
"width": "100%",
"flex": "1"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "#0F172A",
"padding": "10px 16px",
"borderRadius": "10px 10px 0 0",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {"text": "时间", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 16}
},
{
"widgettype": "Text",
"options": {"text": "错帐类型", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 12}
},
{
"widgettype": "Text",
"options": {"text": "原始交易", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 20}
},
{
"widgettype": "Text",
"options": {"text": "金额", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 10}
},
{
"widgettype": "Text",
"options": {"text": "说明", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 24}
},
{
"widgettype": "Text",
"options": {"text": "状态", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 8}
},
{
"widgettype": "Text",
"options": {"text": "操作", "fontSize": "12px", "fontWeight": "600", "color": "#94A3B8", "cwidth": 10}
}
]
},
{
"widgettype": "Tabular",
"id": "error_acc_tabular",
"options": {
"width": "100%",
"height": "100%",
"css": "filler",
"data_url": "{{entire_url('/accounting/error_accounting_log.dspy')}}",
"editable": false,
"page_rows": 50,
"row_options": {
"browserfields": {
"exclouded": ["row_num_"]
},
"fields": [
{
"name": "timestamp",
"title": "时间",
"type": "timestamp",
"uitype": "timestamp",
"datatype": "timestamp",
"label": "时间",
"cwidth": 16
},
{
"name": "error_type",
"title": "错帐类型",
"type": "str",
"length": 20,
"uitype": "str",
"datatype": "str",
"label": "错帐类型",
"cwidth": 12
},
{
"name": "original_summary",
"title": "原始交易",
"type": "str",
"length": 100,
"uitype": "str",
"datatype": "str",
"label": "原始交易",
"cwidth": 20
},
{
"name": "original_amount",
"title": "金额",
"type": "float",
"length": 18,
"dec": 4,
"uitype": "float",
"datatype": "float",
"label": "金额",
"cwidth": 10
},
{
"name": "error_description",
"title": "说明",
"type": "str",
"length": 200,
"uitype": "str",
"datatype": "str",
"label": "说明",
"cwidth": 24
},
{
"name": "status",
"title": "状态",
"type": "str",
"length": 10,
"uitype": "str",
"datatype": "str",
"label": "状态",
"cwidth": 8
}
]
}
}
},
{
"widgettype": "VBox",
"id": "error_acc_empty_hint",
"options": {
"padding": "20px",
"alignItems": "center",
"bgcolor": "#1E293B"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "如表格为空,表示暂无错帐记录。请点击「报告错帐」按钮手动添加,或确认 error_accounting_log 数据源已配置。",
"fontSize": "13px",
"color": "#64748B"
}
}
]
}
]
},
{
"widgettype": "VBox",
"id": "error_acc_legend",
"options": {
"bgcolor": "#1E293B",
"padding": "16px",
"borderRadius": "10px",
"border": "1px solid #334155",
"gap": "8px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "错帐类型说明",
"fontSize": "14px",
"fontWeight": "600",
"color": "#F1F5F9"
}
},
{
"widgettype": "HBox",
"options": {
"gap": "16px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"gap": "6px", "alignItems": "center"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "#EF444433",
"padding": "2px 8px",
"borderRadius": "4px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {"text": "科目错误", "fontSize": "12px", "color": "#EF4444"}
}
]
},
{
"widgettype": "Text",
"options": {"text": "wrong_account", "fontSize": "11px", "color": "#64748B"}
}
]
},
{
"widgettype": "HBox",
"options": {"gap": "6px", "alignItems": "center"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "#F59E0B33",
"padding": "2px 8px",
"borderRadius": "4px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {"text": "重复入帐", "fontSize": "12px", "color": "#F59E0B"}
}
]
},
{
"widgettype": "Text",
"options": {"text": "duplicate_entry", "fontSize": "11px", "color": "#64748B"}
}
]
},
{
"widgettype": "HBox",
"options": {"gap": "6px", "alignItems": "center"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "#8B5CF633",
"padding": "2px 8px",
"borderRadius": "4px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {"text": "漏记", "fontSize": "12px", "color": "#8B5CF6"}
}
]
},
{
"widgettype": "Text",
"options": {"text": "missing_entry", "fontSize": "11px", "color": "#64748B"}
}
]
},
{
"widgettype": "HBox",
"options": {"gap": "6px", "alignItems": "center"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"bgcolor": "#3B82F633",
"padding": "2px 8px",
"borderRadius": "4px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {"text": "金额不符", "fontSize": "12px", "color": "#3B82F6"}
}
]
},
{
"widgettype": "Text",
"options": {"text": "amount_mismatch", "fontSize": "11px", "color": "#64748B"}
}
]
}
]
},
{
"widgettype": "HBox",
"options": {
"gap": "16px",
"alignItems": "center",
"marginTop": "4px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "纠正操作: 冲正(reverse_entry) | 调整(adjust_entry) | 标记已处理(mark_resolved)",
"fontSize": "12px",
"color": "#94A3B8"
}
}
]
}
]
}
]
}

View File

@ -1,6 +1,4 @@
# 安全:只允许查询登录用户本人的余额。 username= params_kw.username
# 历史问题:外部传任意 username 即可查询他人余额(且原文件有 swait 拼写错误,一调用就 500)。
userid = await get_user()
env = request._run_ns env = request._run_ns
async with get_sor_context(env, 'accounting') as sor: async with get_sor_context(env, 'accounting') as sor:
sql = """select sql = """select
@ -10,9 +8,12 @@ a.balance
from account a, subject c, users d from account a, subject c, users d
where a.orgid = d.orgid where a.orgid = d.orgid
and a.subjectid = c.id and a.subjectid = c.id
and d.id = ${userid}$ and d.username = ${username}$
""" """
recs = await sor.sqlExe(sql, {'userid': userid}) recs = swait or.sqlExe(sql, {
'username': username,
'sort': 'username'
})
return { return {
'status': 'ok', 'status': 'ok',
'data': recs 'data': recs

157
wwwroot/index.ui Normal file
View File

@ -0,0 +1,157 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "0"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"width": "100%",
"alignItems": "center",
"marginBottom": "24px"
},
"subwidgets": [
{
"widgettype": "Title2",
"options": {
"text": "计费管理",
"color": "#F1F5F9",
"fontWeight": "700"
}
},
{
"widgettype": "Filler"
},
{
"widgettype": "Text",
"options": {
"text": "账户管理、账单明细与计费配置",
"fontSize": "14px",
"color": "#64748B"
}
}
]
},
{
"widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "250px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.accounting_content",
"options": {
"url": "{{entire_url('myaccounts')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22C55E\" stroke-width=\"1.5\"><path d=\"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125H3.375a.75.75 0 01-.75-.75V4.5m0 0V3.75c0-.621.504-1.125 1.125-1.125h1.5c1.243 0 2.25 1.007 2.25 2.25v.375M3.75 4.5h15.75m0 0v-.375c0-.621-.504-1.125-1.125-1.125h-1.5c-1.243 0-2.25 1.007-2.25 2.25v.375M3.75 12.75h15.75M3.75 16.5h15.75\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "我的账户",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "查看账户余额与充值记录",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.accounting_content",
"options": {
"url": "{{entire_url('accdetail')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"1.5\"><path d=\"M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "账单明细",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "查看计费明细与消费流水",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
}
]
},
{
"widgettype": "VBox",
"id": "accounting_content",
"options": {
"width": "100%",
"flex": "1",
"marginTop": "20px"
}
}
]
}

View File

@ -20,7 +20,6 @@
"subwidgets":[ "subwidgets":[
{ {
"widgettype":"IconBar", "widgettype":"IconBar",
"id":"iconbar_{{loop.index}}",
"options":{ "options":{
"rate": 1.5, "rate": 1.5,
"tools":[ "tools":[
@ -33,7 +32,7 @@
{% endif %} {% endif %}
{ {
"name":"detail", "name":"detail",
"icon":"{{entire_url('/imgs/accdetail.svg')}}", "icon":"{{entire_url('imgs/accdetail.svg')}}",
"tip":"查看账户明细" "tip":"查看账户明细"
} }
] ]
@ -62,7 +61,7 @@
], ],
"binds":[ "binds":[
{ {
"wid":"iconbar_{{loop.index}}", "wid":"self",
"event":"recharge", "event":"recharge",
"actiontype":"urlwidget", "actiontype":"urlwidget",
"target":"PopupWindow", "target":"PopupWindow",
@ -73,39 +72,37 @@
"width":"100%", "width":"100%",
"height":"95%" "height":"95%"
{% else %} {% else %}
"width":"600px", "width":"360px",
"height":"500px" "height":"240px"
{% endif %} {% endif %}
,"dismiss_events":["cancel"]
}, },
"options":{ "options":{
"params_kw":{ "params_kw":{
"accountid":"{{acc.id}}" "accountid":"{{acc.id}}"
}, },
"url":"{{entire_url('/unipay/recharge.ui')}}" "url":"entire_url('/uniapy/recharge.ui')"
} }
},{ },{
"wid":"iconbar_{{loop.index}}", "wid":"self",
"event":"detail", "event":"recharge",
"actiontype":"urlwidget", "actiontype":"urlwidget",
"target":"PopupWindow", "target":"PopupWindow",
"popup_options":{ "popup_options":{
"icon":"{{entire_url('/imgs/accdetail.svg')}}", "icon":"{{entire_url('imgs/accdetail.svg')}}",
"title":"明细", "title":"明细",
{% if params_kw._is_mobile %} {% if params_kw._is_mobile %}
"width":"100%", "width":"100%",
"height":"95%" "height":"95%"
{% else %} {% else %}
"width":"700px", "width":"360px",
"height":"550px" "height":"240px"
{% endif %} {% endif %}
,"dismiss_events":["cancel"]
}, },
"options":{ "options":{
"params_kw":{ "params_kw":{
"accountid":"{{acc.id}}" "accountid":"{{acc.id}}"
}, },
"url":"{{entire_url('/accounting/accdetail.ui')}}" "url":"entire_url('accdetail.ui')"
} }
} }
] ]

View File

@ -1,22 +1,11 @@
userid = await get_user() userid = await get_user()
userorgid = await get_userorgid() userorgid = await get_userorgid()
if get_user_tpac:
tpac = await get_user_tpac(userid)
if tpac:
tpac_balance = await get_tpac_balance(tpac, userid)
return {
'status': 'ok',
'data': [
{
'account': 'tpac account',
'balance': tpac_balance
}
]
}
async with get_sor_context(request._run_ns, 'accounting') as sor: async with get_sor_context(request._run_ns, 'accounting') as sor:
sql = """select b.id, a.name, b.balance_at, b.balance from sql = """select b.id, a.name, b.balance_at, c.balance from
subject a, account b subject a, account b,
where b.subjectid = a.id (select a.* from acc_balance a, (select accountid, max(acc_date) max_date from acc_balance group by accountid) b where a.accountid=b.accountid and a.acc_date=b.max_date) c
where c.accountid = b.id
and b.subjectid = a.id
and b.orgid = ${orgid}$ and b.orgid = ${orgid}$
""" """
ns = {'orgid': userorgid} ns = {'orgid': userorgid}

View File

@ -1,8 +1,6 @@
debug(f'{params_kw=}') debug(f'{params_kw=}')
dbname = get_module_dbname('accounting') dbname = get_module_dbname('accounting')
orgid = params_kw.orgid orgid = await get_userorgid()
if orgid is None:
orgid = await get_userorgid()
db = DBPools() db = DBPools()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
await openCustomerAccounts(sor, '0', orgid) await openCustomerAccounts(sor, '0', orgid)

View File

@ -1,8 +0,0 @@
debug(f'{params_kw=}')
dbname = get_module_dbname('accounting')
orgid = await get_userorgid()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
await openCustomerAccounts(sor, '0', orgid)
return f'{orgid} customer accounts opened'
return f'{db.e_except=}'

View File

@ -1,88 +0,0 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "16px",
"gap": "16px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"css": "card",
"padding": "20px",
"cwidth": 40
},
"subwidgets": [
{
"widgettype": "Title3",
"options": {"text": "代客充值"}
},
{
"widgettype": "Text",
"options": {
"text": "输入客户用户名和充值金额,由管理员代为客户完成充值操作。",
"cfontsize": 0.9
}
},
{
"widgettype": "Form",
"id": "proxy_recharge_form",
"options": {
"name": "proxy_recharge",
"show_label": true,
"submit_label": "确认充值",
"submit_css": "primary",
"fields": [
{
"name": "username",
"label": "客户用户名",
"uitype": "str",
"required": true,
"placeholder": "输入客户用户名",
"cwidth": 20
},
{
"name": "amount",
"label": "充值金额",
"uitype": "float",
"required": true,
"placeholder": "输入充值金额",
"rules": [
{"type": "number", "message": "充值金额必须是有效数字"},
{"type": "min", "value": 0.01, "message": "充值金额必须大于0"}
],
"cwidth": 20
}
]
},
"binds": [{
"wid": "self",
"event": "submit",
"actiontype": "urlwidget",
"target": "app.recharge_result",
"options": {
"url": "{{entire_url('/accounting/proxy_recharge_submit.dspy')}}"
}
}, {
"wid": "self",
"event": "cancel",
"actiontype": "method",
"target": "self",
"method": "reset_data"
}]
},
{
"widgettype": "VBox",
"id": "recharge_result",
"options": {
"width": "100%",
"padding": "8px"
},
"subwidgets": []
}
]
}
]
}

View File

@ -1,223 +0,0 @@
async def _audit_recharge(sor, operator_id, operator_name, customerid, customer_name, orgname, amount, client_ip):
"""代客充值审计:检查审计模块,有则写入,失败不阻断主流程。"""
try:
from app_audit import audit_log
except ImportError:
return
try:
await audit_log(
sor, operator_id, operator_name, 'customer_recharge',
target=customerid,
detail='代客充值 客户=' + str(customer_name) + ' (' + str(orgname or '') + ') 金额=' + str(amount),
result='ok', client_ip=client_ip)
except Exception:
pass
async def _is_owner_role(sor, uid):
"""操作者是否持 owner 组织角色(owner 财务不受归属限制)。"""
recs = await sor.sqlExe(
"SELECT r.orgtypeid FROM userrole ur JOIN role r ON ur.roleid=r.id WHERE ur.userid=${u}$",
{'u': uid})
await sor.sqlExe("COMMIT", {})
return any((getattr(r, 'orgtypeid', '') or '') == 'owner' for r in (recs or []))
async def _customer_belongs(sor, customerid, resellerid):
"""客户是否归属该 reseller(2026-09-08 多租户隔离:reseller 财务角色只能操作
自己名下客户)。归属来源两条,命中其一即通过:
1. organization.parentid = resellerid(注册时按 tenant_domain 绑定,register.dspy)
2. discount_customer_bind.resellerid = resellerid(客户归属表)
"""
if not customerid or not resellerid or resellerid == '0':
return False
recs = await sor.sqlExe(
"SELECT id FROM organization WHERE id=${c}$ AND parentid=${r}$",
{'c': customerid, 'r': resellerid})
await sor.sqlExe("COMMIT", {})
if recs:
return True
recs2 = await sor.sqlExe(
"SELECT id FROM discount_customer_bind WHERE customerid=${c}$ AND resellerid=${r}$ LIMIT 1",
{'c': customerid, 'r': resellerid})
await sor.sqlExe("COMMIT", {})
return bool(recs2)
username = params_kw.get('username', '').strip()
amount_raw = params_kw.get('amount', 0)
debug(f'{params_kw=},{username=}, {amount_raw=}')
action = params_kw.get('action', 'submit')
# ---- Lookup mode: find customer by username, return info ----
if action == 'lookup':
username = params_kw.get('username', '').strip()
if not username:
return json.dumps({'status': 'error', 'message': '用户名不能为空'}, ensure_ascii=False, default=str)
db = DBPools()
dbname = get_module_dbname('accounting')
uid = await get_user()
userorgid = await get_userorgid()
async with db.sqlorContext(dbname) as sor:
sql = """
select
u.username,
u.orgid as customerid,
o.orgname,
a.id as accountid,
a.balance
from users u
left join organization o on u.orgid = o.id COLLATE utf8mb4_unicode_ci
left join account a on a.orgid = u.orgid COLLATE utf8mb4_unicode_ci
where u.username = ${username}$
limit 1
"""
recs = await sor.sqlExe(sql, {'username': username})
if not recs or len(recs) == 0:
return json.dumps({'status': 'error', 'message': f'用户 {username} 不存在'}, ensure_ascii=False, default=str)
rec = recs[0]
# 归属校验(reseller 财务角色只能查自己名下客户;不归属时按「不存在」应答,
# 不泄露别家客户的存在性/余额)
if not await _is_owner_role(sor, uid):
if not await _customer_belongs(sor, rec.customerid, userorgid):
return json.dumps({'status': 'error', 'message': f'用户 {username} 不存在'}, ensure_ascii=False, default=str)
return json.dumps({
'status': 'ok',
'data': {
'username': rec.username,
'customerid': rec.customerid,
'orgname': rec.orgname or '',
'accountid': rec.accountid or '',
'balance': float(rec.balance) if rec.balance else 0.0
}
}, ensure_ascii=False, default=str)
# ---- Submit mode: process the proxy recharge ----
if not username:
return {
"widgettype": "Text",
"options": {"text": "❌ 用户名不能为空", "color": "#EF4444"}
}
try:
amount = float(amount_raw)
except (ValueError, TypeError):
return {
"widgettype": "Text",
"options": {"text": "❌ 充值金额格式错误", "color": "#EF4444"}
}
if amount != amount or amount <= 0:
return {
"widgettype": "Text",
"options": {"text": "❌ 充值金额必须大于0", "color": "#EF4444"}
}
userid = await get_user()
userorgid = await get_userorgid()
db = DBPools()
# Look up the target customer by username
dbname = get_module_dbname('accounting')
async with db.sqlorContext(dbname) as sor:
sql = """
select
u.username,
u.orgid as customerid,
o.orgname,
a.id as accountid
from users u
left join organization o on u.orgid = o.id COLLATE utf8mb4_unicode_ci
left join account a on a.orgid = u.orgid COLLATE utf8mb4_unicode_ci
where u.username = ${username}$
limit 1
"""
recs = await sor.sqlExe(sql, {'username': username})
if not recs or len(recs) == 0:
return {
"widgettype": "Text",
"options": {"text": f"❌ 找不到用户名: {username}", "color": "#EF4444"}
}
customer = recs[0]
customerid = customer.customerid
if customerid == userorgid:
return {
"widgettype": "Text",
"options": {"text": "❌ 不能给自己进行代客充值", "color": "#EF4444"}
}
# 归属校验(2026-09-08):reseller 财务角色只能给自己名下客户充值
if not await _is_owner_role(sor, userid):
if not await _customer_belongs(sor, customerid, userorgid):
return {
"widgettype": "Text",
"options": {"text": f"❌ 找不到用户名: {username}", "color": "#EF4444"}
}
# Create payment log in unipay for audit trail
unipay_dbname = get_module_dbname('unipay')
async with db.sqlorContext(unipay_dbname) as unipay_sor:
plog_id = uuid()
biz_date = await get_business_date(sor)
now_str = timestampstr()
plog_data = {
"id": plog_id,
"customerid": customerid,
"channelid": "proxy",
"payment_name": "充值",
"payer_client_ip": "admin_proxy",
"amount_total": amount,
"pay_feerate": 0.0,
"pay_fee": 0.0,
"currency": "CNY",
"payment_status": "1",
"init_timestamp": now_str,
"payed_timestamp": now_str,
"cancel_timestamp": "2000-01-01 00:00:00.001",
"userid": userid
}
await unipay_sor.C('payment_log', plog_data.copy())
# Perform recharge accounting
await recharge_accounting(
sor,
customerid,
'RECHARGE',
plog_id,
biz_date,
amount,
0.0
)
# 审计:代客充值(旁路,失败不阻断主流程)
_op_username = userid
try:
_op_recs = await sor.sqlExe("SELECT username FROM users WHERE id=${u}$", {'u': userid})
if _op_recs:
_op_username = _op_recs[0].username or userid
except Exception:
pass
_client_ip = ''
try:
_client_ip = request.get('client_ip', '') or ''
except Exception:
pass
await _audit_recharge(sor, userid, _op_username, customerid, username, customer.orgname or '', amount, _client_ip)
debug(f'Proxy recharge: user={username}, customerid={customerid}, amount={amount}, operator={userid}')
orgname = customer.orgname or ''
return {
"widgettype": "Text",
"options": {
"text": f"✅ 代客充值成功 — 已为用户 {username} ({orgname}) 充值 ¥{amount:.2f}",
"color": "#22C55E",
"fontSize": "14px",
"fontWeight": "500"
}
}

View File

@ -1,41 +0,0 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "16px",
"gap": "12px"
},
"subwidgets": [
{
"widgettype": "Title3",
"options": {"text": "充值错帐处理"}
},
{
"widgettype": "Text",
"options": {
"text": "选中错帐的充值账单点击「冲正」:引擎自动翻转原记账借贷方向(客户余额相应扣回),冲正单以 REV:原账单ID 记账、同一笔不可重复冲正、操作记入审计。分销商财务角色仅能处理自己名下客户的充值账单。",
"cfontsize": 0.9,
"halign": "left",
"wrap": true
}
},
{
"widgettype": "VScrollPanel",
"options": {"css": "filler"},
"subwidgets": [
{
"widgettype": "VBox",
"id": "rev_list",
"options": {"width": "100%"},
"subwidgets": [
{
"widgettype": "urlwidget",
"options": {"url": "{{entire_url('/accounting/recharge_reverse_list.dspy')}}"}
}
]
}
]
}
]
}

View File

@ -1,121 +0,0 @@
# recharge_reverse_list.dspy — 充值错帐处理:充值账单列表(2026-09-08)
# 归属过滤:reseller 财务角色只看自己名下客户;owner 财务角色看全部。
# 每条账单一个「冲正」按钮(conform 确认),已冲正的显示标记禁用。
# 返回 widget 描述(VBox 卡片列表),由 recharge_reverse.ui 的 rev_list 容器加载。
dbname = get_module_dbname('accounting')
uid = await get_user()
if not uid:
return {"widgettype": "Text", "options": {"text": "请先登录", "color": "#EF4444"}}
userorgid = await get_userorgid() or ''
db = DBPools()
async with db.sqlorContext(dbname) as sor:
# 操作者是否 owner 财务角色(不受归属限制)
roles_recs = await sor.sqlExe(
"SELECT r.orgtypeid FROM userrole ur JOIN role r ON ur.roleid=r.id WHERE ur.userid=${u}$",
{'u': uid})
await sor.sqlExe("COMMIT", {})
is_owner = any((getattr(r, 'orgtypeid', '') or '') == 'owner' for r in (roles_recs or []))
# 充值账单(RECHARGE + 冲正 RECHARGE_REVERSE 一起查,冲正行用于判「已冲正」)
if is_owner:
bills = await sor.sqlExe(
"SELECT b.id, b.customerid, b.orderid, b.business_op, b.amount, b.bill_date, "
"COALESCE(o.orgname,'') AS orgname FROM bill b "
"LEFT JOIN organization o ON o.id=b.customerid "
"WHERE b.business_op IN ('RECHARGE','RECHARGE_REVERSE') "
"ORDER BY b.bill_date DESC, b.bill_timestamp DESC LIMIT 100", {})
else:
# reseller:只看自己名下客户(organization.parentid 或 discount_customer_bind)
bills = await sor.sqlExe(
"SELECT b.id, b.customerid, b.orderid, b.business_op, b.amount, b.bill_date, "
"COALESCE(o.orgname,'') AS orgname FROM bill b "
"LEFT JOIN organization o ON o.id=b.customerid "
"WHERE b.business_op IN ('RECHARGE','RECHARGE_REVERSE') "
"AND (o.parentid=${r}$ OR EXISTS ("
" SELECT 1 FROM discount_customer_bind dcb "
" WHERE dcb.customerid=b.customerid AND dcb.resellerid=${r}$)) "
"ORDER BY b.bill_date DESC, b.bill_timestamp DESC LIMIT 100", {'r': userorgid})
await sor.sqlExe("COMMIT", {})
# 已冲正集合:冲正单的 orderid='REV:<原bill.id>'
reversed_ids = set()
reverses = [b for b in (bills or []) if getattr(b, 'business_op', '') == 'RECHARGE_REVERSE']
for rv in reverses:
oid = getattr(rv, 'orderid', '') or ''
if oid.startswith('REV:'):
reversed_ids.add(oid[4:])
submit_url = entire_url('/accounting/recharge_reverse_submit.dspy')
list_url = entire_url('/accounting/recharge_reverse_list.dspy')
cards = []
for b in (bills or []):
bop = getattr(b, 'business_op', '')
bid = getattr(b, 'id', '')
if bop == 'RECHARGE_REVERSE':
# 冲正单本身只展示不再冲正
cards.append({
"widgettype": "HBox",
"options": {"bgcolor": "#F0FDF4", "border": "1px solid #BBF7D0",
"borderRadius": "8px", "padding": "10px 14px", "gap": "12px",
"alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "冲正单", "bgcolor": "#16A34A",
"color": "#FFFFFF", "borderRadius": "6px",
"padding": "2px 8px", "cfontsize": 0.85}},
{"widgettype": "Text", "options": {"text": str(getattr(b, 'orgname', '') or getattr(b, 'customerid', '')), "halign": "left", "css": "filler"}},
{"widgettype": "Text", "options": {"text": "¥" + str(getattr(b, 'amount', 0)), "halign": "left"}},
{"widgettype": "Text", "options": {"text": str(getattr(b, 'bill_date', '')), "color": "#94A3B8", "cfontsize": 0.85, "halign": "left"}},
]})
continue
is_rev = bid in reversed_ids
row = [
{"widgettype": "Text", "options": {"text": "充值", "bgcolor": "#2563EB" if not is_rev else "#94A3B8",
"color": "#FFFFFF", "borderRadius": "6px",
"padding": "2px 8px", "cfontsize": 0.85}},
{"widgettype": "Text", "options": {"text": str(getattr(b, 'orgname', '') or getattr(b, 'customerid', '')), "halign": "left", "css": "filler"}},
{"widgettype": "Text", "options": {"text": "¥" + str(getattr(b, 'amount', 0)), "halign": "left"}},
{"widgettype": "Text", "options": {"text": str(getattr(b, 'bill_date', '')), "color": "#94A3B8", "cfontsize": 0.85, "halign": "left"}},
]
if is_rev:
row.append({"widgettype": "Text", "options": {"text": "已冲正", "color": "#16A34A", "cfontsize": 0.85, "halign": "left"}})
else:
# 冲正按钮:conform 确认 → script fetch POST → 成功后刷新列表
script = (
"var body=new URLSearchParams();"
"body.append('bill_id'," + json.dumps(bid) + ");"
"body.append('reason',(window.prompt&&window.prompt('冲正原因(记入审计)',''))||'');"
"var resp=await fetch(" + json.dumps(submit_url) + ",{method:'POST',body:body});"
"var rj=await resp.json();"
"if(rj.success){"
" var lc=bricks.getWidgetById('rev_list',bricks.app);"
" if(lc){lc.clear_widgets();var lr=await fetch(" + json.dumps(list_url) + ");var ld=await lr.json();"
" var lw=await bricks.widgetBuild(ld,lc);if(lw)lc.add_widget(lw);}"
" new bricks.Message({title:'冲正成功',message:rj.message||'已冲正'});"
"}else{new bricks.Message({title:'冲正失败',message:rj.error||'失败'});}")
row.append({
"widgettype": "Button",
"options": {"label": "冲正", "css": "small danger"},
"binds": [{
"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"conform": {"title": "冲正确认",
"message": "确认冲正该笔充值 ¥" + str(getattr(b, 'amount', 0)) + "?冲正将翻转原记账方向(客户余额相应扣回),操作记入审计且不可自动撤销。",
"conform": {"label": "确认冲正"},
"discard": {"label": "取消"}},
"script": script}]})
cards.append({
"widgettype": "HBox",
"options": {"bgcolor": "#1E293B" if not is_rev else "#26313F",
"border": "1px solid #334155", "borderRadius": "8px",
"padding": "10px 14px", "gap": "12px", "alignItems": "center"},
"subwidgets": row})
if not cards:
cards = [{"widgettype": "Text", "options": {"text": "暂无充值账单", "color": "#94A3B8", "padding": "20px"}}]
return {
"widgettype": "VBox",
"options": {"width": "100%", "gap": "8px"},
"subwidgets": cards}

View File

@ -1,108 +0,0 @@
# recharge_reverse_submit.dspy — 充值错帐冲正提交(2026-09-08)
# 参数:bill_id(要冲正的充值账单)、reason(冲正原因,记入审计)
# 逻辑:校验归属→防重复冲正→取原单费率→recharge_accounting(RECHARGE_REVERSE)
# 引擎自动翻转借贷方向(accounting_config.py endswith('_REVERSE'))→审计留痕
# orderid='REV:<bill_id>' 既防重复冲正,又让列表能识别「已冲正」
dbname = get_module_dbname('accounting')
uid = await get_user()
if not uid:
return json.dumps({'success': False, 'error': '未登录'}, ensure_ascii=False)
userorgid = await get_userorgid() or ''
bill_id = (params_kw or {}).get('bill_id', '') or ''
reason = (params_kw or {}).get('reason', '') or ''
if not bill_id:
return json.dumps({'success': False, 'error': '缺少 bill_id'}, ensure_ascii=False)
async def _is_owner_role(sor, uid):
recs = await sor.sqlExe(
"SELECT r.orgtypeid FROM userrole ur JOIN role r ON ur.roleid=r.id WHERE ur.userid=${u}$",
{'u': uid})
await sor.sqlExe("COMMIT", {})
return any((getattr(r, 'orgtypeid', '') or '') == 'owner' for r in (recs or []))
async def _customer_belongs(sor, customerid, resellerid):
if not customerid or not resellerid or resellerid == '0':
return False
recs = await sor.sqlExe(
"SELECT id FROM organization WHERE id=${c}$ AND parentid=${r}$",
{'c': customerid, 'r': resellerid})
await sor.sqlExe("COMMIT", {})
if recs:
return True
recs2 = await sor.sqlExe(
"SELECT id FROM discount_customer_bind WHERE customerid=${c}$ AND resellerid=${r}$ LIMIT 1",
{'c': customerid, 'r': resellerid})
await sor.sqlExe("COMMIT", {})
return bool(recs2)
db = DBPools()
async with db.sqlorContext(dbname) as sor:
# 1. 取原充值账单
recs = await sor.sqlExe(
"SELECT id, customerid, orderid, business_op, amount FROM bill WHERE id=${b}$",
{'b': bill_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return json.dumps({'success': False, 'error': '账单不存在'}, ensure_ascii=False)
bill = recs[0]
if getattr(bill, 'business_op', '') != 'RECHARGE':
return json.dumps({'success': False, 'error': '只能冲正充值(RECHARGE)账单'}, ensure_ascii=False)
customerid = getattr(bill, 'customerid', '') or ''
# 2. 归属校验(reseller 财务只能冲正自己名下客户的充值)
is_owner = await _is_owner_role(sor, uid)
if not is_owner and not await _customer_belongs(sor, customerid, userorgid):
return json.dumps({'success': False, 'error': '无权冲正该账单(客户不归属当前组织)'}, ensure_ascii=False)
# 3. 防重复冲正:已存在 orderid='REV:<bill_id>' 的冲正单则拒绝
rev_orderid = 'REV:' + bill_id
exist = await sor.sqlExe(
"SELECT id FROM bill WHERE orderid=${o}$ AND business_op='RECHARGE_REVERSE' LIMIT 1",
{'o': rev_orderid})
await sor.sqlExe("COMMIT", {})
if exist:
return json.dumps({'success': False, 'error': '该充值账单已冲正过,不可重复冲正'}, ensure_ascii=False)
# 4. 取原单充值费率(payment_log.id = bill.orderid,代客充值/在线充值都写了 payment_log)
feerate = 0.0
orig_orderid = getattr(bill, 'orderid', '') or ''
if orig_orderid:
prec = await sor.sqlExe(
"SELECT pay_feerate FROM payment_log WHERE id=${o}$ LIMIT 1", {'o': orig_orderid})
await sor.sqlExe("COMMIT", {})
if prec:
try:
feerate = float(getattr(prec[0], 'pay_feerate', 0) or 0)
except (TypeError, ValueError):
feerate = 0.0
# 5. 冲正记账(引擎按 endswith('_REVERSE') 翻转借贷方向;transdate=当前营业日)
amount = float(getattr(bill, 'amount', 0) or 0)
biz_date = await get_business_date(sor)
try:
await recharge_accounting(sor, customerid, 'RECHARGE_REVERSE', rev_orderid,
biz_date, amount, feerate)
await sor.sqlExe("COMMIT", {})
except Exception as e:
return json.dumps({'success': False,
'error': '冲正记账失败:%s' % str(e)[:200]}, ensure_ascii=False)
# 6. 审计留痕(旁路,失败不阻断)
try:
from app_audit import audit_log
op_recs = await sor.sqlExe("SELECT username FROM users WHERE id=${u}$", {'u': uid})
op_name = (op_recs[0].username if op_recs else uid)
await audit_log(sor, uid, op_name, 'recharge_reverse', target=customerid,
detail='充值错帐冲正 bill=%s 金额=%s 原因=%s' % (bill_id, amount, reason or '(未填)'),
result='ok', client_ip='')
await sor.sqlExe("COMMIT", {})
except Exception:
pass
return json.dumps({'success': True,
'message': '冲正成功:¥%.2f(原账单 %s)' % (amount, bill_id)},
ensure_ascii=False)

View File

@ -1,53 +0,0 @@
{% set stats = get_accounting_stats(request) %}
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "20px",
"borderRadius": "12px",
"border": "1px solid #334155",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\"><path d=\"M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{stats.account_count}}",
"fontSize": "32px",
"fontWeight": "700",
"color": "#F1F5F9",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "账户数量",
"fontSize": "14px",
"color": "#94A3B8",
"marginTop": "4px"
}
}
]
}

View File

@ -1,53 +0,0 @@
{% set stats = get_accounting_stats(request) %}
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "20px",
"borderRadius": "12px",
"border": "1px solid #334155",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#8B5CF6\" stroke-width=\"2\"><path d=\"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % stats.month_amount}}",
"fontSize": "32px",
"fontWeight": "700",
"color": "#F1F5F9",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "本月消费",
"fontSize": "14px",
"color": "#94A3B8",
"marginTop": "4px"
}
}
]
}

View File

@ -1,53 +0,0 @@
{% set stats = get_accounting_stats(request) %}
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "20px",
"borderRadius": "12px",
"border": "1px solid #334155",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#F59E0B\" stroke-width=\"2\"><path d=\"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % stats.today_amount}}",
"fontSize": "32px",
"fontWeight": "700",
"color": "#F1F5F9",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "今日消费",
"fontSize": "14px",
"color": "#94A3B8",
"marginTop": "4px"
}
}
]
}

View File

@ -1,53 +0,0 @@
{% set stats = get_accounting_stats(request) %}
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "20px",
"borderRadius": "12px",
"border": "1px solid #334155",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22C55E\" stroke-width=\"2\"><path d=\"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125H3.375a.75.75 0 01-.75-.75V4.5m0 0V3.75c0-.621.504-1.125 1.125-1.125h1.5c1.243 0 2.25 1.007 2.25 2.25v.375M3.75 4.5h15.75m0 0v-.375c0-.621-.504-1.125-1.125-1.125h-1.5c-1.243 0-2.25 1.007-2.25 2.25v.375M3.75 12.75h15.75M3.75 16.5h15.75\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % stats.total_balance}}",
"fontSize": "32px",
"fontWeight": "700",
"color": "#F1F5F9",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "账户总余额",
"fontSize": "14px",
"color": "#94A3B8",
"marginTop": "4px"
}
}
]
}