From 3f6c71b4d54817413763fe3107bf5a0747c765f3 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 7 Jul 2026 15:43:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20multi-currency=20support=20=E2=80=94=20?= =?UTF-8?q?currency/exchange=5Frate=20tables=20+=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounting/consume.py | 3 + accounting/exchange.py | 103 ++++++++++++++++++++++++++ accounting/init.py | 6 ++ init/data.yaml | 64 ++++++++++++++++ json/currency_list.json | 18 +++++ json/exchange_rate_list.json | 19 +++++ models/currency.json | 58 +++++++++++++++ models/exchange_rate.json | 81 ++++++++++++++++++++ scripts/multi_currency_migration.sql | 91 +++++++++++++++++++++++ wwwroot/api/currency_create.dspy | 14 ++++ wwwroot/api/currency_delete.dspy | 7 ++ wwwroot/api/currency_update.dspy | 12 +++ wwwroot/api/exchange_rate_create.dspy | 16 ++++ wwwroot/api/exchange_rate_delete.dspy | 7 ++ wwwroot/api/exchange_rate_update.dspy | 13 ++++ 15 files changed, 512 insertions(+) create mode 100644 accounting/exchange.py create mode 100644 json/currency_list.json create mode 100644 json/exchange_rate_list.json create mode 100644 models/currency.json create mode 100644 models/exchange_rate.json create mode 100644 scripts/multi_currency_migration.sql create mode 100644 wwwroot/api/currency_create.dspy create mode 100644 wwwroot/api/currency_delete.dspy create mode 100644 wwwroot/api/currency_update.dspy create mode 100644 wwwroot/api/exchange_rate_create.dspy create mode 100644 wwwroot/api/exchange_rate_delete.dspy create mode 100644 wwwroot/api/exchange_rate_update.dspy diff --git a/accounting/consume.py b/accounting/consume.py index bcdef24..b09ecdd 100644 --- a/accounting/consume.py +++ b/accounting/consume.py @@ -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': diff --git a/accounting/exchange.py b/accounting/exchange.py new file mode 100644 index 0000000..1eeab4e --- /dev/null +++ b/accounting/exchange.py @@ -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 diff --git a/accounting/init.py b/accounting/init.py index df9de65..21be33b 100644 --- a/accounting/init.py +++ b/accounting/init.py @@ -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): diff --git a/init/data.yaml b/init/data.yaml index 6083649..03fe5e8 100644 --- a/init/data.yaml +++ b/init/data.yaml @@ -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' diff --git a/json/currency_list.json b/json/currency_list.json new file mode 100644 index 0000000..f99ea0b --- /dev/null +++ b/json/currency_list.json @@ -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')}}" + } + } +} diff --git a/json/exchange_rate_list.json b/json/exchange_rate_list.json new file mode 100644 index 0000000..71d0f08 --- /dev/null +++ b/json/exchange_rate_list.json @@ -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')}}" + } + } +} diff --git a/models/currency.json b/models/currency.json new file mode 100644 index 0000000..36ec6e3 --- /dev/null +++ b/models/currency.json @@ -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'" + } + ] +} diff --git a/models/exchange_rate.json b/models/exchange_rate.json new file mode 100644 index 0000000..3a26a86 --- /dev/null +++ b/models/exchange_rate.json @@ -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" + } + ] +} diff --git a/scripts/multi_currency_migration.sql b/scripts/multi_currency_migration.sql new file mode 100644 index 0000000..ccf988c --- /dev/null +++ b/scripts/multi_currency_migration.sql @@ -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; diff --git a/wwwroot/api/currency_create.dspy b/wwwroot/api/currency_create.dspy new file mode 100644 index 0000000..55488bf --- /dev/null +++ b/wwwroot/api/currency_create.dspy @@ -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': '币种已创建'}) diff --git a/wwwroot/api/currency_delete.dspy b/wwwroot/api/currency_delete.dspy new file mode 100644 index 0000000..6942cf9 --- /dev/null +++ b/wwwroot/api/currency_delete.dspy @@ -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': '币种已删除'}) diff --git a/wwwroot/api/currency_update.dspy b/wwwroot/api/currency_update.dspy new file mode 100644 index 0000000..6f8b28c --- /dev/null +++ b/wwwroot/api/currency_update.dspy @@ -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': '币种已更新'}) diff --git a/wwwroot/api/exchange_rate_create.dspy b/wwwroot/api/exchange_rate_create.dspy new file mode 100644 index 0000000..805ef58 --- /dev/null +++ b/wwwroot/api/exchange_rate_create.dspy @@ -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': '汇率已创建'}) diff --git a/wwwroot/api/exchange_rate_delete.dspy b/wwwroot/api/exchange_rate_delete.dspy new file mode 100644 index 0000000..60ab253 --- /dev/null +++ b/wwwroot/api/exchange_rate_delete.dspy @@ -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': '汇率已删除'}) diff --git a/wwwroot/api/exchange_rate_update.dspy b/wwwroot/api/exchange_rate_update.dspy new file mode 100644 index 0000000..dd837c6 --- /dev/null +++ b/wwwroot/api/exchange_rate_update.dspy @@ -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': '汇率已更新'})