feat: 添加信用额度功能

- 新增 credit_limit 表定义和DDL
- 修改 accounting_config.py 支持信用额度透支检查
- 新增 creditlimit.py 信用额度管理模块
- 新增信用额度管理界面和CRUD API
- 支持设置/查询/更新客户信用额度
This commit is contained in:
yumoqing 2026-05-28 19:09:04 +08:00
parent e34be6ad16
commit 9696d4334b
10 changed files with 748 additions and 9 deletions

View File

@ -13,6 +13,7 @@ from appPublic.timeUtils import curDateString
# 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:
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:

101
accounting/creditlimit.py Normal file
View File

@ -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

23
json/credit_limit.json Normal file
View File

@ -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"}
]
}
}
}

144
models/credit_limit.json Normal file
View File

@ -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'"
}
]
}

27
sql/credit_limit.sql Normal file
View File

@ -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', '已过期');

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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":[]
}

View File

@ -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
}
}

View File

@ -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"
}
}