From 20dfdca82aedf24e3ff2e3f786baa7b0cfa204af Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 8 Sep 2026 13:51:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B4=A6=E5=8A=A1):=20reseller=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E8=A7=92=E8=89=B2=E4=B8=89=E8=83=BD=E5=8A=9B(2026-09-?= =?UTF-8?q?08=E7=94=A8=E6=88=B7=E5=AE=9A=E5=A4=BA)=E2=80=94=E2=80=94?= =?UTF-8?q?=E2=91=A0=E4=BF=AERECHARGE=5FREVESE=E6=8B=BC=E5=86=99bug(?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E6=BC=8FR=E8=87=B4=E5=86=B2?= =?UTF-8?q?=E6=AD=A3/=E6=94=AF=E4=BB=98=E5=AE=9D=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E9=95=BF=E6=9C=9F=E8=A2=AB=E6=8B=92,=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=97=A7=E6=8B=BC=E5=86=99=E5=BD=92=E4=B8=80)=E2=91=A1?= =?UTF-8?q?=E4=BB=A3=E5=AE=A2=E5=85=85=E5=80=BC=E5=8A=A0=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C(reseller=E5=8F=AA=E8=83=BD=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=9C=AC=E7=BB=84=E7=BB=87=E5=90=8D=E4=B8=8B=E5=AE=A2=E6=88=B7?= =?UTF-8?q?:organization.parentid=E6=88=96discount=5Fcustomer=5Fbind,looku?= =?UTF-8?q?p=E4=B8=8D=E6=B3=84=E9=9C=B2=E5=88=AB=E5=AE=B6=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E6=80=A7)=E2=91=A2=E6=96=B0=E5=A2=9E=E5=85=85?= =?UTF-8?q?=E5=80=BC=E9=94=99=E5=B8=90=E5=A4=84=E7=90=86=E4=B8=89=E4=BB=B6?= =?UTF-8?q?=E5=A5=97(=E5=88=97=E8=A1=A8=E6=8C=89=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E8=BF=87=E6=BB=A4+=E5=86=B2=E6=AD=A3=E8=B5=B0RECHARGE=5FREVERS?= =?UTF-8?q?E=E5=BC=95=E6=93=8E=E7=BF=BB=E8=BD=AC=E5=80=9F=E8=B4=B7+orderid?= =?UTF-8?q?=3DREV:=E5=8E=9Fbillid=E9=98=B2=E9=87=8D=E5=A4=8D+=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E7=95=99=E7=97=95;=E6=8E=88FIN=5FROLES=E4=B8=8D?= =?UTF-8?q?=E5=90=ABreseller.sale)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounting/recharge.py | 10 ++- scripts/load_path.py | 8 ++ wwwroot/proxy_recharge_submit.dspy | 45 ++++++++++ wwwroot/recharge_reverse.ui | 42 ++++++++++ wwwroot/recharge_reverse_list.dspy | 121 +++++++++++++++++++++++++++ wwwroot/recharge_reverse_submit.dspy | 108 ++++++++++++++++++++++++ 6 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 wwwroot/recharge_reverse.ui create mode 100644 wwwroot/recharge_reverse_list.dspy create mode 100644 wwwroot/recharge_reverse_submit.dspy diff --git a/accounting/recharge.py b/accounting/recharge.py index ba4bd7a..f9db860 100644 --- a/accounting/recharge.py +++ b/accounting/recharge.py @@ -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, diff --git a/scripts/load_path.py b/scripts/load_path.py index 613b679..fcd2a33 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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", +] + # ============================================================ # 数据库辅助(角色解析 + 旧授权清理) diff --git a/wwwroot/proxy_recharge_submit.dspy b/wwwroot/proxy_recharge_submit.dspy index 791765a..ad7d420 100644 --- a/wwwroot/proxy_recharge_submit.dspy +++ b/wwwroot/proxy_recharge_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: diff --git a/wwwroot/recharge_reverse.ui b/wwwroot/recharge_reverse.ui new file mode 100644 index 0000000..a5c7be2 --- /dev/null +++ b/wwwroot/recharge_reverse.ui @@ -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')}}"} + } + ] + } + ] + } + ] +} diff --git a/wwwroot/recharge_reverse_list.dspy b/wwwroot/recharge_reverse_list.dspy new file mode 100644 index 0000000..013879d --- /dev/null +++ b/wwwroot/recharge_reverse_list.dspy @@ -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} diff --git a/wwwroot/recharge_reverse_submit.dspy b/wwwroot/recharge_reverse_submit.dspy new file mode 100644 index 0000000..da1c317 --- /dev/null +++ b/wwwroot/recharge_reverse_submit.dspy @@ -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:' 既防重复冲正,又让列表能识别「已冲正」 + +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:' 的冲正单则拒绝 + 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)