feat(账务): reseller财务角色三能力(2026-09-08用户定夺)——①修RECHARGE_REVESE拼写bug(白名单漏R致冲正/支付宝退款长期被拒,兼容旧拼写归一)②代客充值加归属校验(reseller只能操作本组织名下客户:organization.parentid或discount_customer_bind,lookup不泄露别家存在性)③新增充值错帐处理三件套(列表按归属过滤+冲正走RECHARGE_REVERSE引擎翻转借贷+orderid=REV:原billid防重复+审计留痕;授FIN_ROLES不含reseller.sale)

This commit is contained in:
yumoqing 2026-09-08 13:51:16 +08:00
parent 6c45f8e798
commit 20dfdca82a
6 changed files with 333 additions and 1 deletions

View File

@ -79,10 +79,18 @@ async def recharge_accounting(sor, customerid, action, orderid,
"""
summary:recharge channe(handly, wechat, alipay)
"""
if action not in ['RECHARGE', 'RECHARGE_REVESE']:
# 2026-09-08 修拼写 bug原白名单写的是 'RECHARGE_REVESE'(漏 R而 const.py 的
# 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})')
exception(f'{e}')
raise e
# 旧错拼归一为正确值(引擎按 endswith('_REVERSE') 判冲正方向)
if action == 'RECHARGE_REVESE':
action = ACTION_RECHARGE_REVERSE
recharge_log = {
"customerid":customerid,
"transdate":transdate,

View File

@ -214,6 +214,14 @@ PATHS_PROXY = [
f"/{MOD}/proxy_recharge_submit.dspy",
]
# 充值错帐处理(冲正)— 财务角色FIN_ROLESowner 财务 + reseller 管理员/会计/运营。
# 敏感操作不开放给 reseller.sale代客充值可冲正不可
PATHS_FIN += [
f"/{MOD}/recharge_reverse.ui",
f"/{MOD}/recharge_reverse_list.dspy",
f"/{MOD}/recharge_reverse_submit.dspy",
]
# ============================================================
# 数据库辅助(角色解析 + 旧授权清理)

View File

@ -14,6 +14,36 @@ async def _audit_recharge(sor, operator_id, operator_name, customerid, customer_
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):
"""客户是否归属该 reseller2026-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=}')
@ -27,6 +57,8 @@ if action == 'lookup':
db = DBPools()
dbname = get_module_dbname('accounting')
uid = await get_user()
userorgid = await get_userorgid()
async with db.sqlorContext(dbname) as sor:
sql = """
select
@ -46,6 +78,11 @@ if action == 'lookup':
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': {
@ -114,6 +151,14 @@ async with db.sqlorContext(dbname) as sor:
"options": {"text": "❌ 不能给自己进行代客充值", "color": "#EF4444"}
}
# 归属校验2026-09-08reseller 财务角色只能给自己名下客户充值
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:

View File

@ -0,0 +1,42 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "16px",
"gap": "12px"
},
"subwidgets": [
{
"widgettype": "Title3",
"options": {"text": "充值错帐处理"}
},
{
"widgettype": "Text",
"options": {
"text": "选中错帐的充值账单点击「冲正」:引擎自动翻转原记账借贷方向(客户余额相应扣回),冲正单以 REV:原账单ID 记账、同一笔不可重复冲正、操作记入审计。分销商财务角色仅能处理自己名下客户的充值账单。",
"color": "#94A3B8",
"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

@ -0,0 +1,121 @@
# 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

@ -0,0 +1,108 @@
# 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)