diff --git a/accounting/accounting_config.py b/accounting/accounting_config.py index cc6e5b7..d4db35f 100644 --- a/accounting/accounting_config.py +++ b/accounting/accounting_config.py @@ -10,9 +10,10 @@ from appPublic.uniqueID import getID from appPublic.log import debug, exception from sqlor.dbpools import DBPools from appPublic.timeUtils import curDateString -# from .argsconvert import ArgsConvert -from appPublic.argsConvert import ArgsConvert -from datetime import datetime +# from .argsconvert import ArgsConvert +from appPublic.argsConvert import ArgsConvert +from datetime import datetime +from .creditlimit import get_credit_limit_for_account, update_used_credit accounting_config = None @@ -196,12 +197,18 @@ class Accounting: e = Exception(f'{accid} account not exist') exception(f'{e}') raise e - account = accounts[0] - new_balance = account.balance + leg['balance_amount'] - if new_balance < -0.0000001: - e = AccountOverDraw(accid, account.balance, leg['amount']) - exception(f'{e},{leg=}') - raise e + account = accounts[0] + new_balance = account.balance + leg['balance_amount'] + + # Check credit limit if balance goes negative + if new_balance < -0.0000001: + credit_limit = await get_credit_limit_for_account(sor, accid) + if credit_limit is None or credit_limit['available_credit'] < abs(new_balance): + e = AccountOverDraw(accid, account.balance, leg['amount']) + exception(f'{e},{leg=}') + raise e + # Update used credit + await update_used_credit(sor, accid, abs(new_balance)) subjects = await sor.R('subject', {'id': leg['subjectid']}) if len(subjects) > 0: diff --git a/accounting/creditlimit.py b/accounting/creditlimit.py new file mode 100644 index 0000000..a7c2845 --- /dev/null +++ b/accounting/creditlimit.py @@ -0,0 +1,101 @@ +from appPublic.log import debug, exception +from datetime import datetime + +async def get_credit_limit_for_account(sor, accid): + """ + Get active credit limit for an account. + Returns credit_limit record if active and valid, None otherwise. + """ + sql = """ + SELECT * FROM credit_limit + WHERE accountid = ${accid}$ + AND status = 'active' + AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) + AND (valid_to IS NULL OR valid_to >= CURRENT_DATE) + ORDER BY created_at DESC + LIMIT 1 + """ + recs = await sor.sqlExe(sql, {'accid': accid}) + if len(recs) == 0: + return None + return recs[0] + +async def update_used_credit(sor, accid, new_used_amount): + """ + Update used_credit and available_credit for an account. + new_used_amount is the absolute value of negative balance. + """ + credit = await get_credit_limit_for_account(sor, accid) + if credit is None: + return + + new_used = new_used_amount + new_available = credit['credit_limit'] - new_used + + sql = """ + UPDATE credit_limit + SET used_credit = ${used}$, + available_credit = ${available}$, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${id}$ + """ + await sor.sqlExe(sql, { + 'used': new_used, + 'available': new_available, + 'id': credit['id'] + }) + debug(f'Updated credit for {accid}: used={new_used}, available={new_available}') + +async def set_credit_limit(sor, accountid, orgid, credit_limit_amount, + valid_from=None, valid_to=None, created_by=None, remark=None): + """ + Set or update credit limit for an account. + If a credit limit already exists, update it; otherwise create new. + """ + from appPublic.uniqueID import getID + + # Check if credit limit exists + existing = await get_credit_limit_for_account(sor, accountid) + + if existing: + # Update existing + sql = """ + UPDATE credit_limit + SET credit_limit = ${credit_limit}$, + available_credit = ${credit_limit}$ - used_credit, + valid_from = ${valid_from}$, + valid_to = ${valid_to}$, + remark = ${remark}$, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${id}$ + """ + await sor.sqlExe(sql, { + 'credit_limit': credit_limit_amount, + 'valid_from': valid_from, + 'valid_to': valid_to, + 'remark': remark, + 'id': existing['id'] + }) + debug(f'Updated credit limit for {accountid}: {credit_limit_amount}') + return existing['id'] + else: + # Create new + new_id = getID() + ns = { + 'id': new_id, + 'accountid': accountid, + 'orgid': orgid, + 'credit_limit': credit_limit_amount, + 'used_credit': 0, + 'available_credit': credit_limit_amount, + 'valid_from': valid_from, + 'valid_to': valid_to, + 'status': 'active', + 'created_at': datetime.now(), + 'updated_at': datetime.now(), + 'created_by': created_by, + 'remark': remark + } + await sor.C('credit_limit', ns) + debug(f'Created credit limit for {accountid}: {credit_limit_amount}') + return new_id diff --git a/json/credit_limit.json b/json/credit_limit.json new file mode 100644 index 0000000..b84e8fd --- /dev/null +++ b/json/credit_limit.json @@ -0,0 +1,23 @@ +{ + "tblname": "credit_limit", + "title": "信用额度管理", + "params": { + "sortby": ["created_at desc"], + "browserfields": { + "exclouded": ["id"], + "cwidth": {} + }, + "editexclouded": ["id", "used_credit", "available_credit", "created_at", "updated_at"], + "editable": { + "new_data_url": "{{entire_url('add_credit_limit.dspy')}}", + "update_data_url": "{{entire_url('update_credit_limit.dspy')}}", + "delete_data_url": "{{entire_url('delete_credit_limit.dspy')}}" + }, + "data_filter": { + "AND": [ + {"field": "orgid", "op": "=", "var": "orgid"}, + {"field": "status", "op": "=", "var": "status"} + ] + } + } +} diff --git a/models/credit_limit.json b/models/credit_limit.json new file mode 100644 index 0000000..ca77bdf --- /dev/null +++ b/models/credit_limit.json @@ -0,0 +1,144 @@ +{ + "summary": [ + { + "name": "credit_limit", + "title": "信用额度表", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "accountid", + "title": "账户ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "orgid", + "title": "机构ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "credit_limit", + "title": "信用额度", + "type": "float", + "length": 18, + "dec": 2, + "nullable": "no", + "default": "0.00" + }, + { + "name": "used_credit", + "title": "已用额度", + "type": "float", + "length": 18, + "dec": 2, + "nullable": "no", + "default": "0.00" + }, + { + "name": "available_credit", + "title": "可用额度", + "type": "float", + "length": 18, + "dec": 2, + "nullable": "no", + "default": "0.00" + }, + { + "name": "valid_from", + "title": "生效日期", + "type": "date", + "nullable": "yes" + }, + { + "name": "valid_to", + "title": "失效日期", + "type": "date", + "nullable": "yes" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 10, + "nullable": "no", + "default": "active" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "remark", + "title": "备注", + "type": "str", + "length": 500, + "nullable": "yes" + } + ], + "indexes": [ + { + "name": "idx_credit_limit_account", + "idxtype": "unique", + "idxfields": ["accountid"] + }, + { + "name": "idx_credit_limit_orgid", + "idxtype": "index", + "idxfields": ["orgid"] + }, + { + "name": "idx_credit_limit_status", + "idxtype": "index", + "idxfields": ["status"] + } + ], + "codes": [ + { + "field": "accountid", + "table": "account", + "valuefield": "id", + "textfield": "id" + }, + { + "field": "orgid", + "table": "organization", + "valuefield": "id", + "textfield": "orgname" + }, + { + "field": "status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='credit_status'" + } + ] +} diff --git a/sql/credit_limit.sql b/sql/credit_limit.sql new file mode 100644 index 0000000..749f16b --- /dev/null +++ b/sql/credit_limit.sql @@ -0,0 +1,27 @@ +-- Credit Limit Table for accounting module +-- Run this DDL on the sage database to create the credit_limit table + +CREATE TABLE IF NOT EXISTS credit_limit ( + id VARCHAR(32) NOT NULL COMMENT '主键ID', + accountid VARCHAR(32) NOT NULL COMMENT '账户ID', + orgid VARCHAR(32) NOT NULL COMMENT '机构ID', + credit_limit DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '信用额度', + used_credit DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '已用额度', + available_credit DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '可用额度', + valid_from DATE COMMENT '生效日期', + valid_to DATE COMMENT '失效日期', + status VARCHAR(10) NOT NULL DEFAULT 'active' COMMENT '状态', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL COMMENT '更新时间', + created_by VARCHAR(32) COMMENT '创建人', + remark VARCHAR(500) COMMENT '备注', + PRIMARY KEY (id), + UNIQUE INDEX idx_credit_limit_account (accountid), + INDEX idx_credit_limit_orgid (orgid), + INDEX idx_credit_limit_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Insert credit_status codes into appcodes_kv +INSERT IGNORE INTO appcodes_kv (parentid, k, v) VALUES ('credit_status', 'active', '生效'); +INSERT IGNORE INTO appcodes_kv (parentid, k, v) VALUES ('credit_status', 'inactive', '停用'); +INSERT IGNORE INTO appcodes_kv (parentid, k, v) VALUES ('credit_status', 'expired', '已过期'); diff --git a/wwwroot/credit_limit/add_credit_limit.dspy b/wwwroot/credit_limit/add_credit_limit.dspy new file mode 100644 index 0000000..2b8507a --- /dev/null +++ b/wwwroot/credit_limit/add_credit_limit.dspy @@ -0,0 +1,43 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + +# Initialize credit fields +ns['used_credit'] = 0 +ns['available_credit'] = float(ns.get('credit_limit', 0)) +ns['status'] = 'active' +from datetime import datetime +ns['created_at'] = datetime.now() +ns['updated_at'] = datetime.now() + +db = DBPools() +dbname = get_module_dbname('accounting') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('credit_limit', ns.copy()) + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"信用额度设置成功", + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"设置失败", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} diff --git a/wwwroot/credit_limit/delete_credit_limit.dspy b/wwwroot/credit_limit/delete_credit_limit.dspy new file mode 100644 index 0000000..87d44c2 --- /dev/null +++ b/wwwroot/credit_limit/delete_credit_limit.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('accounting') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('credit_limit', ns) + debug('delete credit_limit success') + return { + "widgettype":"Message", + "options":{ + "title":"删除成功", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete credit_limit failed') +return { + "widgettype":"Error", + "options":{ + "title":"删除失败", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} diff --git a/wwwroot/credit_limit/get_credit_limit.dspy b/wwwroot/credit_limit/get_credit_limit.dspy new file mode 100644 index 0000000..7a7be35 --- /dev/null +++ b/wwwroot/credit_limit/get_credit_limit.dspy @@ -0,0 +1,128 @@ + +ns = params_kw.copy() + + +debug(f'get_credit_limit.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + ns['sort'] = 'created_at desc' + + +sql = '''select a.*, b.accountid_text, c.orgid_text, d.status_text +from (select * from credit_limit where 1=1 [[filterstr]]) a +left join (select id as accountid, id as accountid_text from account where 1 = 1) b on a.accountid = b.accountid +left join (select id as orgid, orgname as orgid_text from organization where 1 = 1) c on a.orgid = c.orgid +left join (select k as status, v as status_text from appcodes_kv where parentid='credit_status') d on a.status = d.status''' + +filterjson = params_kw.get('data_filter') +fields_str=r'''[ + { + "name": "id", + "title": "id", + "type": "str", + "length": 32 + }, + { + "name": "accountid", + "title": "账户ID", + "type": "str", + "length": 32 + }, + { + "name": "orgid", + "title": "机构ID", + "type": "str", + "length": 32 + }, + { + "name": "credit_limit", + "title": "信用额度", + "type": "float", + "length": 18, + "dec": 2 + }, + { + "name": "used_credit", + "title": "已用额度", + "type": "float", + "length": 18, + "dec": 2 + }, + { + "name": "available_credit", + "title": "可用额度", + "type": "float", + "length": 18, + "dec": 2 + }, + { + "name": "valid_from", + "title": "生效日期", + "type": "date" + }, + { + "name": "valid_to", + "title": "失效日期", + "type": "date" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 10 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32 + }, + { + "name": "remark", + "title": "备注", + "type": "str", + "length": 500 + } +]''' +ori_fields = json.loads(fields_str) +if not filterjson: + fields = [ f['name'] for f in ori_fields ] + filterjson = default_filterjson(fields, ns) +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('accounting') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} diff --git a/wwwroot/credit_limit/index.ui b/wwwroot/credit_limit/index.ui new file mode 100644 index 0000000..658ecc3 --- /dev/null +++ b/wwwroot/credit_limit/index.ui @@ -0,0 +1,186 @@ + +{ + "id":"credit_limit_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + "title":"信用额度管理", + "toolbar":{ + "tools":[] + }, + "css":"card", + "editable":{ + "new_data_url":"{{entire_url('add_credit_limit.dspy')}}", + "delete_data_url":"{{entire_url('delete_credit_limit.dspy')}}", + "update_data_url":"{{entire_url('update_credit_limit.dspy')}}" + }, + "data_url":"{{entire_url('./get_credit_limit.dspy')}}", + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + "browserfields": { + "exclouded": ["id"], + "cwidth": {} + }, + "editexclouded":[ + "id", "used_credit", "available_credit", "created_at", "updated_at" + ], + "fields":[ + { + "name": "id", + "title": "id", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "id" + }, + { + "name": "accountid", + "title": "账户ID", + "type": "str", + "length": 32, + "label": "账户ID", + "uitype": "code", + "valueField": "accountid", + "textField": "accountid_text", + "params": { + "dbname": "{{get_module_dbname('accounting')}}", + "table": "account", + "tblvalue": "id", + "tbltext": "id", + "valueField": "accountid", + "textField": "accountid_text" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "orgid", + "title": "机构", + "type": "str", + "length": 32, + "label": "机构", + "uitype": "code", + "valueField": "orgid", + "textField": "orgid_text", + "params": { + "dbname": "{{get_module_dbname('accounting')}}", + "table": "organization", + "tblvalue": "id", + "tbltext": "orgname", + "valueField": "orgid", + "textField": "orgid_text" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "credit_limit", + "title": "信用额度", + "type": "float", + "length": 18, + "dec": 2, + "cwidth": 15, + "uitype": "float", + "datatype": "float", + "label": "信用额度" + }, + { + "name": "used_credit", + "title": "已用额度", + "type": "float", + "length": 18, + "dec": 2, + "cwidth": 15, + "uitype": "float", + "datatype": "float", + "label": "已用额度" + }, + { + "name": "available_credit", + "title": "可用额度", + "type": "float", + "length": 18, + "dec": 2, + "cwidth": 15, + "uitype": "float", + "datatype": "float", + "label": "可用额度" + }, + { + "name": "valid_from", + "title": "生效日期", + "type": "date", + "uitype": "date", + "datatype": "date", + "label": "生效日期" + }, + { + "name": "valid_to", + "title": "失效日期", + "type": "date", + "uitype": "date", + "datatype": "date", + "label": "失效日期" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 10, + "label": "状态", + "uitype": "code", + "valueField": "status", + "textField": "status_text", + "params": { + "dbname": "{{get_module_dbname('accounting')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "status", + "textField": "status_text", + "cond": "parentid='credit_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "uitype": "timestamp", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "uitype": "timestamp", + "datatype": "timestamp", + "label": "更新时间" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "uitype": "str", + "datatype": "str", + "label": "创建人" + }, + { + "name": "remark", + "title": "备注", + "type": "str", + "length": 500, + "uitype": "str", + "datatype": "str", + "label": "备注" + } + ] + }, + "page_rows":160, + "cache_limit":5 + } +} diff --git a/wwwroot/credit_limit/update_credit_limit.dspy b/wwwroot/credit_limit/update_credit_limit.dspy new file mode 100644 index 0000000..17e5a5b --- /dev/null +++ b/wwwroot/credit_limit/update_credit_limit.dspy @@ -0,0 +1,47 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None + +# Recalculate available_credit when credit_limit changes +if 'credit_limit' in ns and ns['credit_limit'] is not None: + credit_limit_val = float(ns['credit_limit']) + # Get current used_credit from DB + db = DBPools() + dbname = get_module_dbname('accounting') + async with db.sqlorContext(dbname) as sor: + recs = await sor.R('credit_limit', {'id': ns['id']}) + if len(recs) > 0: + used = float(recs[0].get('used_credit', 0)) + ns['available_credit'] = credit_limit_val - used + +from datetime import datetime +ns['updated_at'] = datetime.now() + +db = DBPools() +dbname = get_module_dbname('accounting') +async with db.sqlorContext(dbname) as sor: + r = await sor.U('credit_limit', ns) + debug('update credit_limit success') + return { + "widgettype":"Message", + "options":{ + "title":"信用额度更新成功", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"更新失败", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +}