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:
parent
6c45f8e798
commit
20dfdca82a
@ -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,
|
||||
|
||||
@ -214,6 +214,14 @@ PATHS_PROXY = [
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据库辅助(角色解析 + 旧授权清理)
|
||||
|
||||
@ -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):
|
||||
"""客户是否归属该 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=}')
|
||||
@ -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-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:
|
||||
|
||||
42
wwwroot/recharge_reverse.ui
Normal file
42
wwwroot/recharge_reverse.ui
Normal 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')}}"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
121
wwwroot/recharge_reverse_list.dspy
Normal file
121
wwwroot/recharge_reverse_list.dspy
Normal 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}
|
||||
108
wwwroot/recharge_reverse_submit.dspy
Normal file
108
wwwroot/recharge_reverse_submit.dspy
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user