feat(accounting): 计费落账通用化,账号/存储接入出账
- 抽 product_accounting_generic(product_id, usage_data, ...):算金额+分销链+consume_accounting,llm/账号/存储通用 - product_accounting(llmusage) 变薄封装:转换 llmusage→通用参数→generic+finalize+标记 - 新增 account_resource_accounting / storage_resource_accounting:acctres/storres usage → generic → 标记 accounted - _resource_ref_to_product_id: spec.id → product.id(按 product_type 区分 account/workspace_storage)
This commit is contained in:
parent
622dec8495
commit
29f1c1fc92
@ -117,6 +117,18 @@ async def product_accounting(llmusage):
|
|||||||
return await manager.product_accounting(llmusage)
|
return await manager.product_accounting(llmusage)
|
||||||
|
|
||||||
|
|
||||||
|
async def account_resource_accounting(usage_record):
|
||||||
|
"""账号用量计费落账(acctres_usage → 通用计费落账)。"""
|
||||||
|
manager = get_manager()
|
||||||
|
return await manager.account_resource_accounting(usage_record)
|
||||||
|
|
||||||
|
|
||||||
|
async def storage_resource_accounting(usage_record):
|
||||||
|
"""存储用量计费落账(storres_usage → 通用计费落账)。"""
|
||||||
|
manager = get_manager()
|
||||||
|
return await manager.storage_resource_accounting(usage_record)
|
||||||
|
|
||||||
|
|
||||||
async def backend_accounting():
|
async def backend_accounting():
|
||||||
"""Background accounting loop."""
|
"""Background accounting loop."""
|
||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
@ -145,5 +157,7 @@ def load_product_management():
|
|||||||
env.sync_llm_product = sync_llm_product
|
env.sync_llm_product = sync_llm_product
|
||||||
# Product accounting
|
# Product accounting
|
||||||
env.product_accounting = product_accounting
|
env.product_accounting = product_accounting
|
||||||
|
env.account_resource_accounting = account_resource_accounting
|
||||||
|
env.storage_resource_accounting = storage_resource_accounting
|
||||||
env.backend_accounting = backend_accounting
|
env.backend_accounting = backend_accounting
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -1585,25 +1585,15 @@ WHERE resource_ref_id = ${llm_id}$ AND product_type = 'llm_model' LIMIT 1"""
|
|||||||
# ─── Product Accounting ───
|
# ─── Product Accounting ───
|
||||||
|
|
||||||
async def product_accounting(self, llmusage):
|
async def product_accounting(self, llmusage):
|
||||||
"""Full product accounting for one llmusage record.
|
"""llmage 用量计费落账(薄封装:转换 llmusage → 通用参数 → generic)。"""
|
||||||
|
|
||||||
1. Convert llm_id → product_id
|
|
||||||
2. Calculate raw cost via resource module
|
|
||||||
3. Determine distribution chain (product_owner → ... → seller)
|
|
||||||
4. Compute final amounts with discounts for each party
|
|
||||||
5. Build accounting items and call consume_accounting
|
|
||||||
"""
|
|
||||||
env = ServerEnv()
|
|
||||||
llmid = llmusage.llmid
|
llmid = llmusage.llmid
|
||||||
userorgid = llmusage.userorgid
|
userorgid = llmusage.userorgid
|
||||||
userid = llmusage.userid
|
userid = llmusage.userid
|
||||||
|
|
||||||
# 1. Get product_id
|
|
||||||
product_id = await self.llm_id_to_product_id(llmid)
|
product_id = await self.llm_id_to_product_id(llmid)
|
||||||
if not product_id:
|
if not product_id:
|
||||||
raise Exception(f'llm({llmid}) has no product mapping')
|
raise Exception(f'llm({llmid}) has no product mapping')
|
||||||
|
|
||||||
# 2. Get llm info for ownerid
|
|
||||||
from llmage.utils import get_llmage_llm
|
from llmage.utils import get_llmage_llm
|
||||||
llm = await get_llmage_llm(llmid)
|
llm = await get_llmage_llm(llmid)
|
||||||
if not llm:
|
if not llm:
|
||||||
@ -1611,27 +1601,55 @@ WHERE resource_ref_id = ${llm_id}$ AND product_type = 'llm_model' LIMIT 1"""
|
|||||||
ownerid = getattr(llm, 'ownerid', '0') or '0'
|
ownerid = getattr(llm, 'ownerid', '0') or '0'
|
||||||
providerid = getattr(llm, 'providerid', '0') or '0'
|
providerid = getattr(llm, 'providerid', '0') or '0'
|
||||||
|
|
||||||
# 2b. Self-use detection: customer org == owner org (e.g. owner-side users
|
|
||||||
# or platform users with NULL/'0' userorgid using owner '0' models).
|
|
||||||
# Self-use skips the PAY leg — merchant never opens a customer account
|
|
||||||
# for itself, so PAY would fail account lookup. PAY* legs stay balanced.
|
|
||||||
is_self_use = (userorgid or '0') == (ownerid or '0')
|
|
||||||
|
|
||||||
# 3. Parse usage data
|
|
||||||
usages = llmusage.usages
|
usages = llmusage.usages
|
||||||
if isinstance(usages, str):
|
if isinstance(usages, str):
|
||||||
usages = json.loads(usages)
|
usages = json.loads(usages)
|
||||||
|
|
||||||
# 4. Calculate raw cost
|
result = await self.product_accounting_generic(
|
||||||
|
product_id, usages, userorgid, userid,
|
||||||
|
providerid=providerid, ownerid=ownerid,
|
||||||
|
tenantid=getattr(llmusage, 'tenantid', None))
|
||||||
|
|
||||||
|
# finalize_balance(llmage 专属,非关键)
|
||||||
|
try:
|
||||||
|
luid = getattr(llmusage, 'id', None)
|
||||||
|
if luid:
|
||||||
|
from llmage.balance import finalize_balance
|
||||||
|
await finalize_balance(ServerEnv(), luid, result.get('customer_amount', 0),
|
||||||
|
llmid=llmid)
|
||||||
|
except Exception as e:
|
||||||
|
exception(f'finalize_balance failed (non-critical): {e}')
|
||||||
|
|
||||||
|
# 标记 llmusage accounted
|
||||||
|
lmage_dbname = ServerEnv().get_module_dbname('llmage')
|
||||||
|
async with DBPools().sqlorContext(lmage_dbname) as sor:
|
||||||
|
await sor.U('llmusage', {'id': llmusage.id, 'accounting_status': 'accounted'})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def product_accounting_generic(self, product_id, usage_data, userorgid, userid,
|
||||||
|
providerid=None, ownerid=None, tenantid=None):
|
||||||
|
"""通用商品计费落账(llm/账号/存储通用)。
|
||||||
|
|
||||||
|
核心:算金额(calculate_product_cost,含客户折扣+供应商成本) →
|
||||||
|
分销链各环节金额 → consume_accounting 落账。
|
||||||
|
返回 {'success', 'orderid', 'customer_amount', 'supplier_cost', 'original_amount'}
|
||||||
|
"""
|
||||||
|
env = ServerEnv()
|
||||||
|
ownerid = ownerid or '0'
|
||||||
|
providerid = providerid or '0'
|
||||||
|
is_self_use = (userorgid or '0') == (ownerid or '0')
|
||||||
|
|
||||||
|
# 1. 算金额(客户售价 amount + 供应商成本 cost)
|
||||||
cost_result = await self.calculate_product_cost(
|
cost_result = await self.calculate_product_cost(
|
||||||
product_id=product_id, usage_data=usages, user_org_id=userorgid)
|
product_id=product_id, usage_data=usage_data, user_org_id=userorgid)
|
||||||
if not cost_result or not cost_result.get('success'):
|
if not cost_result or not cost_result.get('success'):
|
||||||
raise Exception(f'calculate_product_cost failed: {cost_result}')
|
raise Exception(f'calculate_product_cost failed: {cost_result}')
|
||||||
raw_cost = cost_result.get('amount', 0)
|
raw_cost = cost_result.get('amount', 0)
|
||||||
original_amount = cost_result.get('original_amount', 0) or raw_cost
|
original_amount = cost_result.get('original_amount', 0) or raw_cost
|
||||||
supplier_cost = cost_result.get('cost', 0) or raw_cost
|
supplier_cost = cost_result.get('cost', 0) or raw_cost
|
||||||
|
|
||||||
# 4b. Get product currency
|
# 2. 币种转换
|
||||||
prod_currency = 'CNY'
|
prod_currency = 'CNY'
|
||||||
try:
|
try:
|
||||||
dbname = self._get_dbname()
|
dbname = self._get_dbname()
|
||||||
@ -1643,12 +1661,10 @@ WHERE resource_ref_id = ${llm_id}$ AND product_type = 'llm_model' LIMIT 1"""
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 4c. Get user billing currency and exchange rates
|
|
||||||
user_currency = 'CNY'
|
user_currency = 'CNY'
|
||||||
cost_in_user = raw_cost
|
cost_in_user = raw_cost
|
||||||
cost_base = raw_cost
|
cost_base = raw_cost
|
||||||
try:
|
try:
|
||||||
env = ServerEnv()
|
|
||||||
user_currency = await env.get_user_currency(userorgid)
|
user_currency = await env.get_user_currency(userorgid)
|
||||||
if prod_currency != user_currency:
|
if prod_currency != user_currency:
|
||||||
rate = await env.get_exchange_rate(prod_currency, user_currency, 'sell_rate')
|
rate = await env.get_exchange_rate(prod_currency, user_currency, 'sell_rate')
|
||||||
@ -1657,42 +1673,22 @@ WHERE resource_ref_id = ${llm_id}$ AND product_type = 'llm_model' LIMIT 1"""
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 5. Determine seller (who sold to customer)
|
# 3. 分销链
|
||||||
dbname = self._get_dbname()
|
seller_orgid = tenantid or userorgid
|
||||||
seller_orgid = userorgid # default: customer bought directly from owner
|
|
||||||
|
|
||||||
# Try tenantid from llmusage
|
|
||||||
tenantid = getattr(llmusage, 'tenantid', None)
|
|
||||||
if tenantid:
|
|
||||||
seller_orgid = tenantid
|
|
||||||
|
|
||||||
# 6. Get distribution chain
|
|
||||||
try:
|
try:
|
||||||
from supplychain.init import get_distribution_chain as _get_chain
|
from supplychain.init import get_distribution_chain as _get_chain
|
||||||
chain = await _get_chain(ownerid, seller_orgid)
|
chain = await _get_chain(ownerid, seller_orgid)
|
||||||
except Exception:
|
except Exception:
|
||||||
chain = [ownerid]
|
chain = [ownerid]
|
||||||
|
|
||||||
# 7. 客户售价:calculate_product_cost 的 amount 已含客户折扣(discount 模块),
|
|
||||||
# 这里不再重复乘,避免双重折扣。
|
|
||||||
customer_amount = cost_in_user
|
customer_amount = cost_in_user
|
||||||
|
biz_date = await env.get_business_date(None)
|
||||||
# 8. Build accounting items for each link in chain
|
|
||||||
# Chain: product_owner → ... → seller
|
|
||||||
# Each link: PAY* (reseller→provider) except last link: PAY (customer→reseller)
|
|
||||||
biz_date = await env.get_business_date(None) # will use default
|
|
||||||
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
|
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
orderid = getID()
|
orderid = getID()
|
||||||
|
|
||||||
# 供应商成本:calculate_product_cost 的 cost 已含 supplychain 供应商折扣(基于原价)。
|
# 4. 构建 accounting_items(PAY/PAY*)
|
||||||
# 不用 discount 模块的 get_min_supplier_discount(供应商折扣归 supplychain 管)。
|
|
||||||
|
|
||||||
accounting_items = []
|
accounting_items = []
|
||||||
|
|
||||||
if len(chain) == 1:
|
if len(chain) == 1:
|
||||||
# Direct: customer → product_owner → supplier
|
|
||||||
# PAY: customer → owner, amount = customer_amount
|
|
||||||
# (skipped for self-use: no customer account exists for owner itself)
|
|
||||||
if not is_self_use:
|
if not is_self_use:
|
||||||
accounting_items.append(DictObject(
|
accounting_items.append(DictObject(
|
||||||
action='PAY', customerid=userorgid, resellerid=ownerid,
|
action='PAY', customerid=userorgid, resellerid=ownerid,
|
||||||
@ -1700,21 +1696,16 @@ WHERE resource_ref_id = ${llm_id}$ AND product_type = 'llm_model' LIMIT 1"""
|
|||||||
productid=product_id, transamt=customer_amount,
|
productid=product_id, transamt=customer_amount,
|
||||||
currency=user_currency, base_amount=round(customer_amount, 2),
|
currency=user_currency, base_amount=round(customer_amount, 2),
|
||||||
variable={"交易金额": customer_amount, "交易币种": user_currency, "交易手续费": 0}))
|
variable={"交易金额": customer_amount, "交易币种": user_currency, "交易手续费": 0}))
|
||||||
# PAY*: owner → supplier, amount = supplier_cost(含 supplychain 供应商折扣)
|
|
||||||
supplier_amount = supplier_cost
|
|
||||||
accounting_items.append(DictObject(
|
accounting_items.append(DictObject(
|
||||||
action='PAY*', customerid=ownerid, resellerid=ownerid,
|
action='PAY*', customerid=ownerid, resellerid=ownerid,
|
||||||
providerid=providerid, biz_date=biz_date, timestamp=timestamp,
|
providerid=providerid, biz_date=biz_date, timestamp=timestamp,
|
||||||
productid=product_id, transamt=supplier_amount,
|
productid=product_id, transamt=supplier_cost,
|
||||||
currency=prod_currency, base_amount=cost_base,
|
currency=prod_currency, base_amount=cost_base,
|
||||||
variable={"采购成本": supplier_amount, "采购币种": prod_currency}))
|
variable={"采购成本": supplier_cost, "采购币种": prod_currency}))
|
||||||
else:
|
else:
|
||||||
# Multi-level distribution chain
|
|
||||||
for i, orgid in enumerate(chain):
|
for i, orgid in enumerate(chain):
|
||||||
is_last = (i == len(chain) - 1)
|
is_last = (i == len(chain) - 1)
|
||||||
if is_last:
|
if is_last:
|
||||||
# Last link (closest to customer): PAY
|
|
||||||
# (skipped for self-use: no customer account for owner itself)
|
|
||||||
if not is_self_use:
|
if not is_self_use:
|
||||||
accounting_items.append(DictObject(
|
accounting_items.append(DictObject(
|
||||||
action='PAY', customerid=userorgid, resellerid=orgid,
|
action='PAY', customerid=userorgid, resellerid=orgid,
|
||||||
@ -1723,9 +1714,6 @@ WHERE resource_ref_id = ${llm_id}$ AND product_type = 'llm_model' LIMIT 1"""
|
|||||||
currency=user_currency, base_amount=round(customer_amount, 2),
|
currency=user_currency, base_amount=round(customer_amount, 2),
|
||||||
variable={"交易金额": customer_amount, "交易币种": user_currency, "交易手续费": 0}))
|
variable={"交易金额": customer_amount, "交易币种": user_currency, "交易手续费": 0}))
|
||||||
else:
|
else:
|
||||||
# Middle link: PAY*
|
|
||||||
# 分销折扣从 supplychain 查(distribution_agreement_items),
|
|
||||||
# 不用 discount 模块的 get_min_distributor_discount。
|
|
||||||
next_orgid = chain[i + 1]
|
next_orgid = chain[i + 1]
|
||||||
link_amount = raw_cost
|
link_amount = raw_cost
|
||||||
try:
|
try:
|
||||||
@ -1749,64 +1737,75 @@ WHERE resource_ref_id = ${llm_id}$ AND product_type = 'llm_model' LIMIT 1"""
|
|||||||
productid=product_id, transamt=link_amount,
|
productid=product_id, transamt=link_amount,
|
||||||
currency=prod_currency, base_amount=round(link_amount, 2),
|
currency=prod_currency, base_amount=round(link_amount, 2),
|
||||||
variable={"分销结算": link_amount, "结算币种": prod_currency}))
|
variable={"分销结算": link_amount, "结算币种": prod_currency}))
|
||||||
# Final PAY*: top of chain → supplier
|
|
||||||
supplier_amount = supplier_cost
|
|
||||||
accounting_items.append(DictObject(
|
accounting_items.append(DictObject(
|
||||||
action='PAY*', customerid=chain[0], resellerid=chain[0],
|
action='PAY*', customerid=chain[0], resellerid=chain[0],
|
||||||
providerid=providerid, biz_date=biz_date, timestamp=timestamp,
|
providerid=providerid, biz_date=biz_date, timestamp=timestamp,
|
||||||
productid=product_id, transamt=supplier_amount,
|
productid=product_id, transamt=supplier_cost,
|
||||||
currency=prod_currency, base_amount=cost_base,
|
currency=prod_currency, base_amount=cost_base,
|
||||||
variable={"采购成本": supplier_amount, "采购币种": prod_currency}))
|
variable={"采购成本": supplier_cost, "采购币种": prod_currency}))
|
||||||
|
|
||||||
# 9. Write biz_order and call consume_accounting
|
# 5. 写 biz_order + consume_accounting
|
||||||
dbname = self._get_dbname()
|
dbname = self._get_dbname()
|
||||||
db = DBPools()
|
db = DBPools()
|
||||||
config = getConfig()
|
config = getConfig()
|
||||||
db.databases = config.databases
|
db.databases = config.databases
|
||||||
async with db.sqlorContext(dbname) as sor:
|
async with db.sqlorContext(dbname) as sor:
|
||||||
order = {
|
await sor.C('biz_order', {
|
||||||
"id": orderid,
|
"id": orderid, "customerid": userorgid, "resellerid": ownerid,
|
||||||
"customerid": userorgid,
|
"order_date": biz_date, "order_status": "1", "business_op": "PAY",
|
||||||
"resellerid": ownerid,
|
"amount": customer_amount, "currency": user_currency,
|
||||||
"order_date": biz_date,
|
"userid": userid, "productid": product_id})
|
||||||
"order_status": "1",
|
await sor.C('biz_orderdetail', {
|
||||||
"business_op": "PAY",
|
"id": getID(), "orderid": orderid, "productid": product_id,
|
||||||
"amount": customer_amount,
|
"product_cnt": 1, "trans_amount": customer_amount, "currency": user_currency})
|
||||||
"currency": user_currency,
|
|
||||||
"userid": userid,
|
|
||||||
"productid": product_id
|
|
||||||
}
|
|
||||||
await sor.C('biz_order', order)
|
|
||||||
orderdetail = {
|
|
||||||
"id": getID(),
|
|
||||||
"orderid": orderid,
|
|
||||||
"productid": product_id,
|
|
||||||
"product_cnt": 1,
|
|
||||||
"trans_amount": customer_amount,
|
|
||||||
"currency": user_currency,
|
|
||||||
}
|
|
||||||
await sor.C('biz_orderdetail', orderdetail)
|
|
||||||
await consume_accounting(sor, orderid, accounting_items)
|
await consume_accounting(sor, orderid, accounting_items)
|
||||||
|
|
||||||
# Finalize Redis balance reservation (non-critical)
|
return {'success': True, 'orderid': orderid, 'customer_amount': customer_amount,
|
||||||
try:
|
'supplier_cost': supplier_cost, 'original_amount': original_amount}
|
||||||
luid = getattr(llmusage, 'id', None)
|
|
||||||
if luid:
|
|
||||||
from llmage.balance import finalize_balance
|
|
||||||
await finalize_balance(ServerEnv(), luid, customer_amount,
|
|
||||||
llmid=getattr(llmusage, 'llmid', None))
|
|
||||||
except Exception as e:
|
|
||||||
exception(f'finalize_balance failed (non-critical): {e}')
|
|
||||||
|
|
||||||
# Mark llmusage as accounted
|
async def _resource_ref_to_product_id(self, ref_id, product_type):
|
||||||
lmage_dbname = ServerEnv().get_module_dbname('llmage')
|
"""资源引用 id(spec.id)→ 产品 id(按 product_type 区分资源模块)。"""
|
||||||
async with db.sqlorContext(lmage_dbname) as sor:
|
dbname = self._get_dbname()
|
||||||
await sor.U('llmusage', {
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
'id': llmusage.id,
|
recs = await sor.sqlExe(
|
||||||
'accounting_status': 'accounted'
|
"SELECT id FROM product WHERE resource_ref_id=${rid}$ "
|
||||||
})
|
"AND product_type=${pt}$ LIMIT 1",
|
||||||
|
{'rid': ref_id, 'pt': product_type})
|
||||||
|
if recs:
|
||||||
|
return recs[0].id
|
||||||
|
return None
|
||||||
|
|
||||||
return {'success': True, 'orderid': orderid}
|
async def account_resource_accounting(self, usage_record):
|
||||||
|
"""账号用量计费落账(acctres_usage → generic)。"""
|
||||||
|
spec_id = getattr(usage_record, 'spec_id', '')
|
||||||
|
userorgid = getattr(usage_record, 'userorgid', '') or '0'
|
||||||
|
userid = getattr(usage_record, 'userid', '') or ''
|
||||||
|
product_id = await self._resource_ref_to_product_id(spec_id, 'account')
|
||||||
|
if not product_id:
|
||||||
|
raise Exception(f'acctres spec({spec_id}) has no product mapping')
|
||||||
|
usages = getattr(usage_record, 'usages', '{}')
|
||||||
|
if isinstance(usages, str):
|
||||||
|
usages = json.loads(usages)
|
||||||
|
result = await self.product_accounting_generic(product_id, usages, userorgid, userid)
|
||||||
|
from account_resource.init import mark_acctres_accounted
|
||||||
|
await mark_acctres_accounted(getattr(usage_record, 'id', ''))
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def storage_resource_accounting(self, usage_record):
|
||||||
|
"""存储用量计费落账(storres_usage → generic)。"""
|
||||||
|
spec_id = getattr(usage_record, 'spec_id', '')
|
||||||
|
userorgid = getattr(usage_record, 'userorgid', '') or '0'
|
||||||
|
userid = getattr(usage_record, 'userid', '') or ''
|
||||||
|
product_id = await self._resource_ref_to_product_id(spec_id, 'workspace_storage')
|
||||||
|
if not product_id:
|
||||||
|
raise Exception(f'storres spec({spec_id}) has no product mapping')
|
||||||
|
usages = getattr(usage_record, 'usages', '{}')
|
||||||
|
if isinstance(usages, str):
|
||||||
|
usages = json.loads(usages)
|
||||||
|
result = await self.product_accounting_generic(product_id, usages, userorgid, userid)
|
||||||
|
from storage_resource.init import mark_storres_accounted
|
||||||
|
await mark_storres_accounted(getattr(usage_record, 'id', ''))
|
||||||
|
return result
|
||||||
|
|
||||||
async def backend_accounting(self):
|
async def backend_accounting(self):
|
||||||
"""Background accounting loop — replaces llmage.backend_accounting."""
|
"""Background accounting loop — replaces llmage.backend_accounting."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user