feat: multi-currency support — currency/exchange_rate tables + migration

This commit is contained in:
yumoqing 2026-07-07 15:43:01 +08:00
parent b95a57fd34
commit 3f6c71b4d5
15 changed files with 512 additions and 0 deletions

View File

@ -40,6 +40,9 @@ class ConsumeBiz(PFBiz):
self.providerid = od['providerid']
self.summary = f'{self.action}|{self.customerid}|{self.resellerid}|{self.productid}'
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):
if role == 'owner':

103
accounting/exchange.py Normal file
View File

@ -0,0 +1,103 @@
"""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

@ -11,6 +11,7 @@ from .stats import get_accounting_stats
from .recharge import RechargeBiz, recharge_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):
env = request._run_ns
@ -83,6 +84,11 @@ def load_accounting():
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):

View File

@ -57,4 +57,68 @@ appcodes_kv:
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: 停用
currency:
- id: CNY
name: 人民币
symbol: ¥
decimal_places: 2
is_base: '1'
status: active
- id: USD
name: 美元
symbol: $
decimal_places: 2
is_base: '0'
status: active
- id: JPY
name: 日元
symbol: ¥
decimal_places: 0
is_base: '0'
status: active
- id: GBP
name: 英镑
symbol: £
decimal_places: 2
is_base: '0'
status: active
exchange_rate:
- id: er_usd_cny
from_currency: USD
to_currency: CNY
buy_rate: 7.150000
sell_rate: 7.250000
mid_rate: 7.200000
effective_date: '2025-01-01'
- id: er_jpy_cny
from_currency: JPY
to_currency: CNY
buy_rate: 0.048000
sell_rate: 0.049000
mid_rate: 0.048500
effective_date: '2025-01-01'
- id: er_gbp_cny
from_currency: GBP
to_currency: CNY
buy_rate: 9.100000
sell_rate: 9.250000
mid_rate: 9.175000
effective_date: '2025-01-01'
- id: er_cny_usd
from_currency: CNY
to_currency: USD
buy_rate: 0.137900
sell_rate: 0.139900
mid_rate: 0.138900
effective_date: '2025-01-01'

18
json/currency_list.json Normal file
View File

@ -0,0 +1,18 @@
{
"tblname": "currency",
"alias": "currency_list",
"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

@ -0,0 +1,19 @@
{
"tblname": "exchange_rate",
"alias": "exchange_rate_list",
"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')}}"
}
}
}

58
models/currency.json Normal file
View File

@ -0,0 +1,58 @@
{
"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'"
}
]
}

81
models/exchange_rate.json Normal file
View File

@ -0,0 +1,81 @@
{
"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

@ -0,0 +1,91 @@
-- Multi-currency migration for accounting + pricing + llmage
-- Execute against the 'accounting' and 'llmage' databases
-- ============================================================
-- 1. accounting DB: currency + exchange_rate tables
-- ============================================================
USE accounting;
-- currency table
CREATE TABLE IF NOT EXISTS 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;
INSERT IGNORE INTO 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');
-- exchange_rate table
CREATE TABLE IF NOT EXISTS 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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE UNIQUE INDEX IF NOT EXISTS idx_currency_pair_date
ON exchange_rate (from_currency, to_currency, effective_date);
INSERT IGNORE INTO 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');
-- appcodes for currency_status
INSERT IGNORE INTO appcodes (id, name, hierarchy_flg) VALUES ('currency_status', '币种状态', '0');
INSERT IGNORE INTO appcodes_kv (id, parentid, k, v) VALUES
('currency_status_active', 'currency_status', 'active', '启用'),
('currency_status_inactive', 'currency_status', 'inactive', '停用');
-- ============================================================
-- 2. account table: add currency field
-- ============================================================
ALTER TABLE account ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'CNY' AFTER org1id;
-- Drop old unique index and recreate with currency
-- (manual: if unique index exists on accounting_orgid+orgid+subjectid+org1id, recreate)
-- ALTER TABLE account DROP INDEX idx1;
-- CREATE UNIQUE INDEX idx1 ON account (accounting_orgid, orgid, subjectid, org1id, currency);
-- ============================================================
-- 3. acc_detail / bill / bill_detail / ledger: add currency fields
-- ============================================================
ALTER TABLE acc_detail ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE acc_detail ADD COLUMN IF NOT EXISTS exchange_rate DOUBLE(14,6) DEFAULT 1.0 AFTER currency;
ALTER TABLE acc_detail ADD COLUMN IF NOT EXISTS base_amount DOUBLE(20,2) DEFAULT 0 AFTER exchange_rate;
ALTER TABLE bill ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE bill ADD COLUMN IF NOT EXISTS base_amount DOUBLE(20,2) DEFAULT 0 AFTER currency;
ALTER TABLE bill_detail ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE bill_detail ADD COLUMN IF NOT EXISTS exchange_rate DOUBLE(14,6) DEFAULT 1.0 AFTER currency;
ALTER TABLE bill_detail ADD COLUMN IF NOT EXISTS base_amount DOUBLE(20,2) DEFAULT 0 AFTER exchange_rate;
ALTER TABLE ledger ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE ledger ADD COLUMN IF NOT EXISTS exchange_rate DOUBLE(14,6) DEFAULT 1.0 AFTER currency;
ALTER TABLE ledger ADD COLUMN IF NOT EXISTS base_amount DOUBLE(20,2) DEFAULT 0 AFTER exchange_rate;
-- ============================================================
-- 4. pricing DB: pricing_program add currency
-- ============================================================
ALTER TABLE pricing_program ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'CNY' AFTER description;
-- ============================================================
-- 5. llmage DB: llmusage add currency fields
-- ============================================================
ALTER TABLE llmusage ADD COLUMN IF NOT EXISTS amount_currency VARCHAR(3) DEFAULT 'CNY' AFTER amount;
ALTER TABLE llmusage ADD COLUMN IF NOT EXISTS amount_base DOUBLE(20,2) DEFAULT 0 AFTER amount_currency;
ALTER TABLE llmusage ADD COLUMN IF NOT EXISTS cost_currency VARCHAR(3) DEFAULT 'CNY' AFTER cost;
ALTER TABLE llmusage ADD COLUMN IF NOT EXISTS cost_base DOUBLE(20,2) DEFAULT 0 AFTER cost_currency;

View File

@ -0,0 +1,14 @@
# 币种创建
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

@ -0,0 +1,7 @@
# 币种删除
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

@ -0,0 +1,12 @@
# 币种更新
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

@ -0,0 +1,16 @@
# 汇率创建
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

@ -0,0 +1,7 @@
# 汇率删除
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

@ -0,0 +1,13 @@
# 汇率更新
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': '汇率已更新'})