sync: local modifications to financial_management

- Updated financial_core.py
- Updated models/receivables.json
- Added mysql.ddl.sql
- Added API files: debug_receivables, receivables CRUD, test_env
- Added UI files: financial_vouchers, index, payments, receipts, receivable_edit, receivables
This commit is contained in:
yumoqing 2026-04-28 18:53:13 +08:00
parent c87c5efaa7
commit e3c19bc359
16 changed files with 808 additions and 546 deletions

View File

@ -2,422 +2,149 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Financial Management Module - Core Business Logic Financial Management Module - Core Business Logic
Implements comprehensive receivables and payments management with order-level granularity
""" """
import uuid import uuid
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional
from decimal import Decimal from decimal import Decimal
# Import database module (following sqlor-database-module pattern) from sqlor.dbpools import DBPools
from appPublic.jsonconfig import getConfig from ahserver.serverenv import ServerEnv
from sqlor.dbp import getDBP
class FinancialManager:
"""Financial management core class"""
def __init__(self): async def get_financial_db():
self.config = getConfig() """Get database connection"""
env = ServerEnv()
try:
dbname = env.get_module_dbname('financial_management')
except:
dbname = 'crm_db'
return DBPools().sqlorContext(dbname)
async def get_db_connection(self, org_id: str):
"""Get database connection for organization"""
dburl = self.config.get('dburl')
return await getDBP(dburl, {'org_id': org_id})
async def create_receivable_from_order(self, order_id: str, org_id: str) -> str: async def create_receivable_from_order(order_id: str, org_id: str) -> str:
"""自动立应收:按订单生成应收计划""" """自动立应收:按订单生成应收计划"""
dbp = await self.get_db_connection(org_id) async with await get_financial_db() as sor:
# 获取订单信息
# 获取订单信息(假设订单管理模块已存在) ns = {'page': 1, 'rows': 1, 'sort': 'id'}
order = await dbp.select_one("orders", {"id": order_id, "org_id": org_id}) orders = await sor.sqlExe(
if not order: "SELECT id, contract_id, customer_id, amount, owner_id FROM orders WHERE id=${id}$ AND org_id=${org_id}$",
{'id': order_id, 'org_id': org_id}
)
if not orders:
raise ValueError(f"订单 {order_id} 不存在") raise ValueError(f"订单 {order_id} 不存在")
# 计算应收日期和到期日期 order = dict(orders[0]) if hasattr(orders[0], 'keys') else orders[0]
receivable_date = order.get("acceptance_deadline") or order.get("created_at")
if isinstance(receivable_date, str):
receivable_date = datetime.strptime(receivable_date, "%Y-%m-%d").date()
elif isinstance(receivable_date, datetime):
receivable_date = receivable_date.date()
credit_period = int(order.get("credit_period", 0)) if order.get("credit_period") else 0 # 计算到期日期(使用订单创建日期+30天默认账期
due_date = receivable_date + timedelta(days=credit_period) if credit_period > 0 else None created_at = order.get('created_at')
if isinstance(created_at, str):
created_at = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S")
due_date = (created_at + timedelta(days=30)).date() if created_at else None
# 创建应收记录 receivable_id = str(uuid.uuid4()).replace('-', '')[:32]
receivable_id = str(uuid.uuid4()).replace('-', '')
receivable_data = { await sor.sqlExe("""
"id": receivable_id, INSERT INTO receivables (id, order_id, contract_id, customer_id, receivable_amount, received_amount, due_date, status, org_id, created_at, updated_at)
"order_id": order_id, VALUES (${id}$, ${order_id}$, ${contract_id}$, ${customer_id}$, ${amount}$, 0, ${due_date}$, 'pending', ${org_id}$, NOW(), NOW())
"contract_id": order["contract_id"], """, {
"customer_id": order["customer_id"], 'id': receivable_id,
"receivable_amount": order["amount"], 'order_id': order_id,
"received_amount": Decimal("0.00"), 'contract_id': order.get('contract_id', ''),
"receivable_date": receivable_date, 'customer_id': order.get('customer_id', ''),
"due_date": due_date, 'amount': order.get('amount', 0),
"credit_period": credit_period, 'due_date': due_date.isoformat() if due_date else None,
"status": "pending", 'org_id': org_id
"sales_owner_id": order.get("owner_id"), })
"org_id": org_id,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
await dbp.insert("receivables", receivable_data)
return receivable_id return receivable_id
async def create_receipt(self, receipt_data: Dict, user_id: str, org_id: str) -> str:
"""收款录入"""
dbp = await self.get_db_connection(org_id)
# 验证关联的订单和金额 async def create_receipt(receipt_data: Dict, user_id: str, org_id: str) -> str:
allocations = receipt_data.get("allocations", []) """收款录入"""
total_allocated = Decimal("0.00") async with await get_financial_db() as sor:
receipt_id = str(uuid.uuid4()).replace('-', '')[:32]
for alloc in allocations: await sor.sqlExe("""
order_id = alloc["order_id"] INSERT INTO receipts (id, customer_id, total_amount, receipt_date, receipt_method, status, org_id, created_by, created_at, updated_at)
allocated_amount = Decimal(str(alloc["allocated_amount"])) VALUES (${id}$, ${customer_id}$, ${total_amount}$, ${receipt_date}$, ${method}$, 'processed', ${org_id}$, ${user_id}$, NOW(), NOW())
""", {
# 验证订单存在且属于当前组织 'id': receipt_id,
order = await dbp.select_one("orders", {"id": order_id, "org_id": org_id}) 'customer_id': receipt_data.get('customer_id', ''),
if not order: 'total_amount': receipt_data.get('total_amount', 0),
raise ValueError(f"订单 {order_id} 不存在或不属于当前组织") 'receipt_date': receipt_data.get('receipt_date', datetime.now().date().isoformat()),
'method': receipt_data.get('receipt_method', 'bank_transfer'),
# 验证应收记录存在 'org_id': org_id,
receivable = await dbp.select_one("receivables", {"order_id": order_id, "org_id": org_id}) 'user_id': user_id
if not receivable: })
raise ValueError(f"订单 {order_id} 没有对应的应收记录")
# 验证分配金额不超过应收金额
remaining_amount = Decimal(str(receivable["receivable_amount"])) - Decimal(str(receivable["received_amount"]))
if allocated_amount > remaining_amount:
raise ValueError(f"订单 {order_id} 分配金额 {allocated_amount} 超过剩余应收金额 {remaining_amount}")
total_allocated += allocated_amount
# 验证总分配金额等于收款总额
total_receipt_amount = Decimal(str(receipt_data["total_amount"]))
if abs(total_allocated - total_receipt_amount) > Decimal("0.01"): # 允许小数点误差
raise ValueError(f"分配总金额 {total_allocated} 与收款总额 {total_receipt_amount} 不匹配")
# 创建收款记录
receipt_id = str(uuid.uuid4()).replace('-', '')
receipt_number = f"REC-{datetime.now().strftime('%Y%m%d')}-{receipt_id[:8].upper()}"
final_receipt_data = {
"id": receipt_id,
"receipt_number": receipt_number,
"customer_id": receipt_data["customer_id"],
"total_amount": total_receipt_amount,
"receipt_date": receipt_data["receipt_date"],
"receipt_method": receipt_data.get("receipt_method", "bank_transfer"),
"receipt_status": "processed",
"description": receipt_data.get("description"),
"created_by": user_id,
"org_id": org_id,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
await dbp.insert("receipts", final_receipt_data)
# 创建收款分配记录
for alloc in allocations:
allocation_id = str(uuid.uuid4()).replace('-', '')
receivable = await dbp.select_one("receivables", {"order_id": alloc["order_id"], "org_id": org_id})
allocation_data = {
"id": allocation_id,
"receipt_id": receipt_id,
"order_id": alloc["order_id"],
"receivable_id": receivable["id"],
"allocated_amount": Decimal(str(alloc["allocated_amount"])),
"allocation_percentage": Decimal(str(alloc.get("allocation_percentage", "0"))) if alloc.get("allocation_percentage") else None,
"contract_id": receivable["contract_id"],
"org_id": org_id,
"created_at": datetime.now()
}
await dbp.insert("receipt_allocations", allocation_data)
# 更新应收记录的已收金额
new_received_amount = Decimal(str(receivable["received_amount"])) + Decimal(str(alloc["allocated_amount"]))
new_status = "completed" if new_received_amount >= Decimal(str(receivable["receivable_amount"])) else "partial"
await dbp.update(
"receivables",
{
"received_amount": new_received_amount,
"status": new_status,
"updated_at": datetime.now()
},
{"id": receivable["id"]}
)
# 如果订单已完成收款,更新订单状态并触发合同履约
if new_status == "completed":
await self._update_order_and_contract_fulfillment(alloc["order_id"], receivable["contract_id"], org_id)
# 生成财务凭证
await self._generate_financial_vouchers(receipt_id, org_id)
return receipt_id return receipt_id
async def _update_order_and_contract_fulfillment(self, order_id: str, contract_id: str, org_id: str):
"""更新订单状态并触发合同履约"""
dbp = await self.get_db_connection(org_id)
# 更新订单状态为已收款
await dbp.update(
"orders",
{"status": "paid", "updated_at": datetime.now()},
{"id": order_id, "org_id": org_id}
)
# 触发合同履约进度更新(这里应该调用合同管理模块)
# 简化实现:更新合同里程碑状态
milestone_type_map = {
"advance": "预付款到账",
"progress": "进度款到账",
"final": "尾款到账",
"acceptance": "验收款到账"
}
order = await dbp.select_one("orders", {"id": order_id, "org_id": org_id})
if order:
order_type = order.get("order_type", "other")
milestone_name = milestone_type_map.get(order_type, f"{order_type}到账")
# 查找对应的里程碑并标记为完成
milestones = await dbp.query(
"SELECT id FROM contract_milestones WHERE contract_id = %(contract_id)s AND milestone_name LIKE %(milestone_name)s",
{"contract_id": contract_id, "milestone_name": f"%{milestone_name}%"}
)
for milestone in milestones:
await dbp.update(
"contract_milestones",
{"status": "completed", "actual_date": datetime.now().date(), "updated_at": datetime.now()},
{"id": milestone["id"]}
)
async def _generate_financial_vouchers(self, receipt_id: str, org_id: str):
"""生成财务凭证"""
dbp = await self.get_db_connection(org_id)
# 获取收款记录
receipt = await dbp.select_one("receipts", {"id": receipt_id, "org_id": org_id})
if not receipt:
return
# 获取分配记录
allocations = await dbp.query(
"SELECT * FROM receipt_allocations WHERE receipt_id = %(receipt_id)s AND org_id = %(org_id)s",
{"receipt_id": receipt_id, "org_id": org_id}
)
for alloc in allocations:
voucher_id = str(uuid.uuid4()).replace('-', '')
voucher_number = f"VOU-{datetime.now().strftime('%Y%m%d')}-{voucher_id[:8].upper()}"
# 获取合同和订单信息用于凭证描述
contract = await dbp.select_one("contract", {"id": alloc["contract_id"], "org_id": org_id})
order = await dbp.select_one("orders", {"id": alloc["order_id"], "org_id": org_id})
contract_number = contract["contract_number"] if contract else "UNKNOWN"
order_number = order["order_number"] if order else "UNKNOWN"
description = f"合同 {contract_number} - 订单 {order_number} 收款凭证"
voucher_data = {
"id": voucher_id,
"voucher_number": voucher_number,
"voucher_type": "receipt",
"contract_id": alloc["contract_id"],
"order_id": alloc["order_id"],
"amount": alloc["allocated_amount"],
"voucher_date": receipt["receipt_date"],
"description": description,
"reference_id": receipt_id,
"org_id": org_id,
"created_at": datetime.now()
}
await dbp.insert("financial_vouchers", voucher_data)
async def get_contract_financial_summary(self, contract_id: str, org_id: str) -> Dict:
"""获取合同层面的财务数据汇总"""
dbp = await self.get_db_connection(org_id)
# 获取合同总金额
contract = await dbp.select_one("contract", {"id": contract_id, "org_id": org_id})
if not contract:
return {}
contract_amount = Decimal(str(contract["amount"]))
# 获取所有关联订单的应收和已收金额
sql = """
SELECT
SUM(r.receivable_amount) as total_receivable,
SUM(r.received_amount) as total_received
FROM receivables r
JOIN orders o ON r.order_id = o.id
WHERE o.contract_id = %(contract_id)s
AND r.org_id = %(org_id)s
"""
result = await dbp.doQuery(sql, {"contract_id": contract_id, "org_id": org_id})
if result and result[0]["total_receivable"]:
total_receivable = Decimal(str(result[0]["total_receivable"]))
total_received = Decimal(str(result[0]["total_received"])) if result[0]["total_received"] else Decimal("0.00")
total_remaining = total_receivable - total_received
completion_rate = (total_received / contract_amount * 100) if contract_amount > 0 else Decimal("0.00")
else:
total_receivable = Decimal("0.00")
total_received = Decimal("0.00")
total_remaining = Decimal("0.00")
completion_rate = Decimal("0.00")
return {
"contract_total_amount": contract_amount,
"total_receivable_amount": total_receivable,
"total_received_amount": total_received,
"total_remaining_amount": total_remaining,
"completion_rate": float(completion_rate),
"contract_id": contract_id
}
async def get_overdue_receivables(self, org_id: str, days_overdue: int = 30) -> List[Dict]:
"""获取逾期应收记录(超期指定天数)"""
dbp = await self.get_db_connection(org_id)
sql = """
SELECT
r.*,
o.order_number,
c.contract_number,
cu.name as customer_name,
u.username as sales_owner_name
FROM receivables r
JOIN orders o ON r.order_id = o.id
JOIN contract c ON r.contract_id = c.id
JOIN customers cu ON r.customer_id = cu.id
LEFT JOIN users u ON r.sales_owner_id = u.id
WHERE r.org_id = %(org_id)s
AND r.status IN ('pending', 'partial')
AND r.due_date IS NOT NULL
AND r.due_date <= DATE_SUB(CURDATE(), INTERVAL %(days)s DAY)
ORDER BY r.due_date ASC
"""
overdue_receivables = await dbp.doQuery(sql, {"org_id": org_id, "days": days_overdue})
return overdue_receivables
async def send_overdue_notifications(self, org_id: str, days_overdue: int = 30) -> int:
"""发送逾期通知给销售和财务"""
overdue_receivables = await self.get_overdue_receivables(org_id, days_overdue)
# 这里应该集成消息通知系统
# 目前只记录日志,实际应用中应发送邮件/站内信等
notification_count = len(overdue_receivables)
if notification_count > 0:
print(f"发现 {notification_count} 个逾期应收记录需要通知")
for receivable in overdue_receivables:
print(f"通知: 销售 {receivable.get('sales_owner_name', '未知')} 和财务 - "
f"合同 {receivable['contract_number']} 订单 {receivable['order_number']} "
f"逾期 {datetime.now().date() - receivable['due_date']}")
return notification_count
async def create_payment(self, payment_data: Dict, user_id: str, org_id: str) -> str:
"""创建支出记录"""
dbp = await self.get_db_connection(org_id)
# 验证关联合同的收款已核销
contract_id = payment_data["contract_id"]
contract_summary = await self.get_contract_financial_summary(contract_id, org_id)
if contract_summary.get("total_received_amount", Decimal("0.00")) <= Decimal("0.00"):
raise ValueError("关联合同没有已核销的收款,不能创建支出")
# 创建支出记录
payment_id = str(uuid.uuid4()).replace('-', '')
payment_number = f"PMT-{datetime.now().strftime('%Y%m%d')}-{payment_id[:8].upper()}"
final_payment_data = {
"id": payment_id,
"payment_number": payment_number,
"contract_id": contract_id,
"vendor_id": payment_data["vendor_id"],
"payment_amount": Decimal(str(payment_data["payment_amount"])),
"payment_date": payment_data["payment_date"],
"payment_method": payment_data.get("payment_method", "bank_transfer"),
"payment_status": "processed",
"description": payment_data.get("description"),
"approved_by": payment_data.get("approved_by"),
"created_by": user_id,
"org_id": org_id,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
await dbp.insert("payments", final_payment_data)
# 生成支出凭证
await self._generate_payment_voucher(payment_id, org_id)
return payment_id
async def _generate_payment_voucher(self, payment_id: str, org_id: str):
"""生成支出凭证"""
dbp = await self.get_db_connection(org_id)
payment = await dbp.select_one("payments", {"id": payment_id, "org_id": org_id})
if not payment:
return
contract = await dbp.select_one("contract", {"id": payment["contract_id"], "org_id": org_id})
contract_number = contract["contract_number"] if contract else "UNKNOWN"
voucher_id = str(uuid.uuid4()).replace('-', '')
voucher_number = f"VOU-{datetime.now().strftime('%Y%m%d')}-{voucher_id[:8].upper()}"
description = f"合同 {contract_number} 支出凭证"
voucher_data = {
"id": voucher_id,
"voucher_number": voucher_number,
"voucher_type": "payment",
"contract_id": payment["contract_id"],
"order_id": None, # 支出通常不关联具体订单
"amount": payment["payment_amount"],
"voucher_date": payment["payment_date"],
"description": description,
"reference_id": payment_id,
"org_id": org_id,
"created_at": datetime.now()
}
await dbp.insert("financial_vouchers", voucher_data)
# Global instance
financial_manager = FinancialManager()
# Export functions
async def create_receivable_from_order(order_id: str, org_id: str) -> str:
return await financial_manager.create_receivable_from_order(order_id, org_id)
async def create_receipt(receipt_data: Dict, user_id: str, org_id: str) -> str:
return await financial_manager.create_receipt(receipt_data, user_id, org_id)
async def get_contract_financial_summary(contract_id: str, org_id: str) -> Dict: async def get_contract_financial_summary(contract_id: str, org_id: str) -> Dict:
return await financial_manager.get_contract_financial_summary(contract_id, org_id) """获取合同层面的财务数据汇总"""
async with await get_financial_db() as sor:
ns = {'page': 1, 'rows': 50, 'sort': 'id'}
sql = """
SELECT
SUM(r.receivable_amount) as total_receivable,
SUM(r.received_amount) as total_received
FROM receivables r
WHERE r.contract_id = ${contract_id}$ AND r.org_id = ${org_id}$
"""
rows = await sor.sqlExe(sql, {'contract_id': contract_id, 'org_id': org_id})
result = {'contract_id': contract_id, 'total_receivable': 0, 'total_received': 0, 'total_remaining': 0}
if rows:
r = dict(rows[0]) if hasattr(rows[0], 'keys') else rows[0]
result['total_receivable'] = float(r.get('total_receivable', 0) or 0)
result['total_received'] = float(r.get('total_received', 0) or 0)
result['total_remaining'] = result['total_receivable'] - result['total_received']
return result
async def get_overdue_receivables(org_id: str, days_overdue: int = 30) -> List[Dict]: async def get_overdue_receivables(org_id: str, days_overdue: int = 30) -> List[Dict]:
return await financial_manager.get_overdue_receivables(org_id, days_overdue) """获取逾期应收记录"""
async with await get_financial_db() as sor:
ns = {'page': 1, 'rows': 100, 'sort': 'due_date'}
sql = """
SELECT r.*, c.contract_number, o.order_id as order_number
FROM receivables r
LEFT JOIN contract c ON r.contract_id = c.id
WHERE r.org_id = ${org_id}$
AND r.status IN ('pending', 'partial')
AND r.due_date IS NOT NULL
AND r.due_date <= DATE_SUB(CURDATE(), INTERVAL ${days}$ DAY)
ORDER BY r.due_date ASC
"""
rows = await sor.sqlExe(sql, {'org_id': org_id, 'days': days_overdue})
return [dict(r) if hasattr(r, 'keys') else r for r in rows] if rows else []
async def send_overdue_notifications(org_id: str, days_overdue: int = 30) -> int: async def send_overdue_notifications(org_id: str, days_overdue: int = 30) -> int:
return await financial_manager.send_overdue_notifications(org_id, days_overdue) """发送逾期通知"""
overdue = await get_overdue_receivables(org_id, days_overdue)
return len(overdue)
async def create_payment(payment_data: Dict, user_id: str, org_id: str) -> str: async def create_payment(payment_data: Dict, user_id: str, org_id: str) -> str:
return await financial_manager.create_payment(payment_data, user_id, org_id) """创建支出记录"""
async with await get_financial_db() as sor:
payment_id = str(uuid.uuid4()).replace('-', '')[:32]
await sor.sqlExe("""
INSERT INTO payments (id, contract_id, payment_amount, payment_date, payment_method, status, org_id, created_by, created_at, updated_at)
VALUES (${id}$, ${contract_id}$, ${amount}$, ${date}$, ${method}$, 'processed', ${org_id}$, ${user_id}$, NOW(), NOW())
""", {
'id': payment_id,
'contract_id': payment_data.get('contract_id', ''),
'amount': payment_data.get('payment_amount', 0),
'date': payment_data.get('payment_date', datetime.now().date().isoformat()),
'method': payment_data.get('payment_method', 'bank_transfer'),
'org_id': org_id,
'user_id': user_id
})
return payment_id

View File

@ -1,149 +1,23 @@
{ {
"summary": { "table_name": "receivables",
"tablename": "receivables", "fields": [
"label": "应收记录", {"name": "id", "type": "varchar(64)", "not_null": true, "comment": "主键ID"},
"comment": "按订单维度的应收记录管理" {"name": "order_id", "type": "varchar(64)", "comment": "订单ID"},
}, {"name": "contract_id", "type": "varchar(64)", "comment": "合同ID"},
"fields": [ {"name": "customer_id", "type": "varchar(64)", "comment": "客户ID"},
{ {"name": "receivable_amount", "type": "decimal(15,2)", "comment": "应收金额"},
"name": "id", {"name": "received_amount", "type": "decimal(15,2)", "comment": "已收金额"},
"title": "ID", {"name": "due_date", "type": "date", "comment": "到期日期"},
"type": "str", {"name": "status", "type": "varchar(32)", "comment": "状态"},
"length": 64, {"name": "description", "type": "varchar(500)", "comment": "描述"},
"nullable": false, {"name": "org_id", "type": "varchar(64)", "comment": "组织ID"},
"comments": "主键" {"name": "created_at", "type": "timestamp", "comment": "创建时间"},
}, {"name": "updated_at", "type": "timestamp", "comment": "更新时间"}
{ ],
"name": "order_id", "indexes": [
"title": "订单ID", {"name": "idx_receivables_customer", "fields": ["customer_id"]},
"type": "str", {"name": "idx_receivables_contract", "fields": ["contract_id"]},
"length": 64, {"name": "idx_receivables_status", "fields": ["status"]},
"nullable": false, {"name": "idx_receivables_due_date", "fields": ["due_date"]}
"comments": "关联的订单ID" ]
},
{
"name": "contract_id",
"title": "合同ID",
"type": "str",
"length": 64,
"nullable": false,
"comments": "关联合同ID"
},
{
"name": "customer_id",
"title": "客户ID",
"type": "str",
"length": 64,
"nullable": false,
"comments": "客户ID"
},
{
"name": "receivable_amount",
"title": "应收金额",
"type": "decimal",
"length": "15,2",
"nullable": false,
"comments": "订单应收金额"
},
{
"name": "received_amount",
"title": "已收金额",
"type": "decimal",
"length": "15,2",
"nullable": false,
"comments": "已收款金额默认为0"
},
{
"name": "receivable_date",
"title": "应收日期",
"type": "date",
"nullable": false,
"comments": "应收日期"
},
{
"name": "due_date",
"title": "到期日期",
"type": "date",
"nullable": true,
"comments": "账期到期日期"
},
{
"name": "credit_period",
"title": "账期天数",
"type": "long",
"nullable": true,
"comments": "账期天数"
},
{
"name": "status",
"title": "状态",
"type": "str",
"length": 32,
"nullable": false,
"comments": "状态: pending(待收), partial(部分收款), completed(已完成), overdue(逾期)"
},
{
"name": "sales_owner_id",
"title": "销售负责人",
"type": "str",
"length": 64,
"nullable": true,
"comments": "负责该订单跟进的销售ID"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 64,
"nullable": false,
"comments": "组织ID用于多租户隔离"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": false,
"comments": "创建时间"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"nullable": false,
"comments": "更新时间"
}
],
"indexes": [
{
"name": "idx_receivables_order_id",
"idxtype": "index",
"columns": ["order_id"]
},
{
"name": "idx_receivables_contract_id",
"idxtype": "index",
"columns": ["contract_id"]
},
{
"name": "idx_receivables_customer_id",
"idxtype": "index",
"columns": ["customer_id"]
},
{
"name": "idx_receivables_status",
"idxtype": "index",
"columns": ["status"]
},
{
"name": "idx_receivables_org_id",
"idxtype": "index",
"columns": ["org_id"]
},
{
"name": "idx_receivables_due_date",
"idxtype": "index",
"columns": ["due_date"]
}
],
"codes": []
} }

82
mysql.ddl.sql Normal file
View File

@ -0,0 +1,82 @@
-- Receivables table
CREATE TABLE IF NOT EXISTS `receivables` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`order_id` VARCHAR(64) COMMENT '订单ID',
`contract_id` VARCHAR(64) COMMENT '合同ID',
`customer_id` VARCHAR(64) COMMENT '客户ID',
`receivable_amount` DECIMAL(15,2) COMMENT '应收金额',
`received_amount` DECIMAL(15,2) DEFAULT 0 COMMENT '已收金额',
`due_date` DATE COMMENT '到期日期',
`status` VARCHAR(32) DEFAULT 'pending' COMMENT '状态: pending/partial/completed',
`description` VARCHAR(500) COMMENT '描述',
`org_id` VARCHAR(64) COMMENT '组织ID',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_receivables_customer` (`customer_id`),
KEY `idx_receivables_contract` (`contract_id`),
KEY `idx_receivables_status` (`status`),
KEY `idx_receivables_due_date` (`due_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应收管理';
-- Receipts table
CREATE TABLE IF NOT EXISTS `receipts` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`customer_id` VARCHAR(64) COMMENT '客户ID',
`total_amount` DECIMAL(15,2) COMMENT '收款总额',
`receipt_date` DATE COMMENT '收款日期',
`receipt_method` VARCHAR(32) COMMENT '收款方式',
`status` VARCHAR(32) DEFAULT 'processed' COMMENT '状态',
`org_id` VARCHAR(64) COMMENT '组织ID',
`created_by` VARCHAR(64) COMMENT '创建人',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_receipts_customer` (`customer_id`),
KEY `idx_receipts_date` (`receipt_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收款记录';
-- Payments table
CREATE TABLE IF NOT EXISTS `payments` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`contract_id` VARCHAR(64) COMMENT '合同ID',
`payment_amount` DECIMAL(15,2) COMMENT '支付金额',
`payment_date` DATE COMMENT '支付日期',
`payment_method` VARCHAR(32) COMMENT '支付方式',
`status` VARCHAR(32) DEFAULT 'processed' COMMENT '状态',
`org_id` VARCHAR(64) COMMENT '组织ID',
`created_by` VARCHAR(64) COMMENT '创建人',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_payments_contract` (`contract_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支出记录';
-- Financial vouchers table
CREATE TABLE IF NOT EXISTS `financial_vouchers` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`voucher_type` VARCHAR(32) COMMENT '凭证类型',
`contract_id` VARCHAR(64) COMMENT '合同ID',
`order_id` VARCHAR(64) COMMENT '订单ID',
`amount` DECIMAL(15,2) COMMENT '金额',
`voucher_date` DATE COMMENT '凭证日期',
`description` TEXT COMMENT '描述',
`reference_id` VARCHAR(64) COMMENT '关联ID',
`org_id` VARCHAR(64) COMMENT '组织ID',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_vouchers_contract` (`contract_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='财务凭证';
-- Receipt allocations table
CREATE TABLE IF NOT EXISTS `receipt_allocations` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`receipt_id` VARCHAR(64) COMMENT '收款ID',
`receivable_id` VARCHAR(64) COMMENT '应收ID',
`allocated_amount` DECIMAL(15,2) COMMENT '分配金额',
`org_id` VARCHAR(64) COMMENT '组织ID',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_allocations_receipt` (`receipt_id`),
KEY `idx_allocations_receivable` (`receivable_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收款分配';

View File

@ -0,0 +1,18 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
result = {'columns': []}
try:
dbname = get_module_dbname('financial_management')
async with DBPools().sqlorContext(dbname) as sor:
sql = "SELECT COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='crm_db' AND TABLE_NAME='receivables'"
rows = await sor.sqlExe(sql, {'page': 1, 'rows': 50, 'sort': 'COLUMN_NAME'})
if isinstance(rows, dict):
rows = rows.get('rows', [])
for r in rows:
d = dict(r)
result['columns'].append(d.get('column_name', '') + ' ' + d.get('column_type', ''))
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Receivables list API"""
import json
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('financial_management')
ns = {
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 20)),
'sort': 'created_at desc'
}
sql = "SELECT id, order_id, contract_id, customer_id, receivable_amount, received_amount, due_date, status, description, org_id, created_at FROM receivables"
async with DBPools().sqlorContext(dbname) as sor:
data = await sor.sqlExe(sql, ns)
if isinstance(data, dict):
result['total'] = data.get('total', 0)
result['rows'] = [dict(r) for r in data.get('rows', [])]
else:
result['rows'] = [dict(r) for r in (data or [])]
result['total'] = len(result['rows'])
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,38 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Receivable create API"""
import json, uuid, time
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
contract_id = params_kw.get('contract_id', '').strip()
customer_id = params_kw.get('customer_id', '').strip()
receivable_amount = params_kw.get('receivable_amount', '0').strip()
due_date = params_kw.get('due_date', '').strip()
if not due_date:
result['options'] = {'title': 'Error', 'message': '到期日不能为空', 'type': 'error'}
else:
dbname = get_module_dbname('financial_management')
new_id = str(uuid.uuid4()).replace('-', '')
now = time.strftime('%Y-%m-%d %H:%M:%S')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe("""INSERT INTO receivables (id, order_id, contract_id, customer_id, receivable_amount, received_amount, due_date, status, description, org_id, created_at, updated_at)
VALUES (${id}$, ${order_id}$, ${contract_id}$, ${customer_id}$, ${receivable_amount}$, ${received_amount}$, ${due_date}$, ${status}$, ${description}$, ${org_id}$, ${created_at}$, ${updated_at}$)""", {
'id': new_id, 'order_id': params_kw.get('order_id', '').strip(),
'contract_id': contract_id, 'customer_id': customer_id,
'receivable_amount': float(receivable_amount) if receivable_amount else 0,
'received_amount': 0, 'due_date': due_date,
'status': params_kw.get('status', 'pending').strip(),
'description': params_kw.get('description', '').strip(),
'org_id': params_kw.get('org_id', '').strip(),
'created_at': now, 'updated_at': now
})
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '应收账款创建成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'创建失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,25 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Receivable delete API"""
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '').strip()
if not record_id:
result['options'] = {'title': 'Error', 'message': '记录ID不能为空', 'type': 'error'}
else:
dbname = get_module_dbname('financial_management')
async with DBPools().sqlorContext(dbname) as sor:
existing = await sor.sqlExe("SELECT id FROM receivables WHERE id = ${id}$", {'id': record_id})
if not existing:
result['options'] = {'title': 'Error', 'message': '记录不存在', 'type': 'error'}
else:
await sor.sqlExe("DELETE FROM receivables WHERE id = ${id}$", {'id': record_id})
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '删除成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""List receivables"""
import json
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('financial_management')
async with DBPools().sqlorContext(dbname) as sor:
ns = {'page': 1, 'rows': 20, 'sort': 'id'}
sql = 'SELECT id, order_id, contract_id, customer_id, receivable_amount, received_amount, due_date, status, description, org_id, created_at, updated_at FROM receivables'
rows = await sor.sqlExe(sql, ns)
if isinstance(rows, dict):
result['rows'] = rows.get('rows', [])
result['total'] = rows.get('total', 0)
elif rows:
result['rows'] = [dict(r) if hasattr(r, 'keys') else r for r in rows]
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Receivable update API"""
import json, time
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '').strip()
if not record_id:
result['options'] = {'title': 'Error', 'message': '记录ID不能为空', 'type': 'error'}
else:
dbname = get_module_dbname('financial_management')
now = time.strftime('%Y-%m-%d %H:%M:%S')
fields = ['order_id', 'contract_id', 'customer_id', 'receivable_amount', 'received_amount', 'due_date', 'status', 'description', 'org_id']
set_parts = []
params = {'id': record_id}
for f in fields:
val = params_kw.get(f, None)
if val is not None:
set_parts.append(f"{f} = %({f})s")
params[f] = val
set_parts.append("updated_at = ${updated_at}$")
params['updated_at'] = now
if len(set_parts) <= 1:
result['options'] = {'title': 'Error', 'message': '没有可更新的字段', 'type': 'error'}
else:
sql = f"UPDATE receivables SET {', '.join(set_parts)} WHERE id = ${id}$"
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe(sql, params)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '更新成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'更新失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

14
wwwroot/api/test_env.dspy Normal file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
result = {'success': True}
try:
dbname = get_module_dbname('financial_management')
result['dbname'] = dbname
async with DBPools().sqlorContext(dbname) as sor:
sql = "SELECT count(*) rcnt FROM receivables"
r = await sor.sqlExe(sql, {'page': 1, 'rows': 10, 'sort': 'id'})
result['count'] = str(r)
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,30 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "20px",
"align": "center",
"justify": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "Financial Vouchers",
"fontSize": "24px",
"fontWeight": "bold",
"color": "#1E40AF",
"marginBottom": "10px"
}
},
{
"widgettype": "Text",
"options": {
"label": "Feature under development",
"fontSize": "16px",
"color": "#6B7280"
}
}
]
}

211
wwwroot/index.ui Normal file
View File

@ -0,0 +1,211 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "20px",
"backgroundColor": "#F9FAFB"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "Financial Management",
"fontSize": "24px",
"fontWeight": "bold",
"color": "#1E40AF",
"marginBottom": "20px"
}
},
{
"widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "250px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"borderRadius": "8px",
"padding": "20px",
"boxShadow": "0 1px 3px rgba(0,0,0,0.1)",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.financial_content",
"options": {
"url": "{{entire_url('receivables.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "Receivables",
"fontSize": "18px",
"fontWeight": "bold",
"color": "#1E40AF"
}
},
{
"widgettype": "Text",
"options": {
"label": "Manage accounts receivable",
"fontSize": "14px",
"color": "#6B7280",
"marginTop": "8px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"borderRadius": "8px",
"padding": "20px",
"boxShadow": "0 1px 3px rgba(0,0,0,0.1)",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.financial_content",
"options": {
"url": "{{entire_url('receipts.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "Receipts",
"fontSize": "18px",
"fontWeight": "bold",
"color": "#059669"
}
},
{
"widgettype": "Text",
"options": {
"label": "Manage incoming payments",
"fontSize": "14px",
"color": "#6B7280",
"marginTop": "8px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"borderRadius": "8px",
"padding": "20px",
"boxShadow": "0 1px 3px rgba(0,0,0,0.1)",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.financial_content",
"options": {
"url": "{{entire_url('payments.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "Payments",
"fontSize": "18px",
"fontWeight": "bold",
"color": "#D97706"
}
},
{
"widgettype": "Text",
"options": {
"label": "Manage outgoing payments",
"fontSize": "14px",
"color": "#6B7280",
"marginTop": "8px"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"borderRadius": "8px",
"padding": "20px",
"boxShadow": "0 1px 3px rgba(0,0,0,0.1)",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.financial_content",
"options": {
"url": "{{entire_url('financial_vouchers.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "Vouchers",
"fontSize": "18px",
"fontWeight": "bold",
"color": "#7C3AED"
}
},
{
"widgettype": "Text",
"options": {
"label": "Financial vouchers",
"fontSize": "14px",
"color": "#6B7280",
"marginTop": "8px"
}
}
]
}
]
},
{
"widgettype": "VBox",
"id": "financial_content",
"options": {
"width": "100%",
"flex": "1",
"marginTop": "20px",
"backgroundColor": "#FFFFFF",
"borderRadius": "8px",
"padding": "20px",
"boxShadow": "0 1px 3px rgba(0,0,0,0.1)"
}
}
]
}

30
wwwroot/payments.ui Normal file
View File

@ -0,0 +1,30 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "20px",
"align": "center",
"justify": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "Payments Management",
"fontSize": "24px",
"fontWeight": "bold",
"color": "#1E40AF",
"marginBottom": "10px"
}
},
{
"widgettype": "Text",
"options": {
"label": "Feature under development",
"fontSize": "16px",
"color": "#6B7280"
}
}
]
}

30
wwwroot/receipts.ui Normal file
View File

@ -0,0 +1,30 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "20px",
"align": "center",
"justify": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "Receipts Management",
"fontSize": "24px",
"fontWeight": "bold",
"color": "#1E40AF",
"marginBottom": "10px"
}
},
{
"widgettype": "Text",
"options": {
"label": "Feature under development",
"fontSize": "16px",
"color": "#6B7280"
}
}
]
}

View File

@ -0,0 +1,45 @@
{
"widgettype": "Page",
"options": {
"title": "编辑应收账款",
"style": {"height": "100vh", "padding": "0"}
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"style": {"padding": "16px", "flex": 1, "overflow": "auto"}},
"subwidgets": [
{
"widgettype": "Form",
"id": "receivable_form",
"options": {
"submit_url": "{{entire_url('api/receivables_create.dspy')}}",
"method": "POST",
"layout": "vertical",
"style": {"maxWidth": "600px"},
"fields": [
{"name": "order_id", "label": "订单号", "uitype": "text"},
{"name": "contract_id", "label": "合同ID", "uitype": "text"},
{"name": "customer_id", "label": "客户ID", "uitype": "text"},
{"name": "receivable_amount", "label": "应收金额", "uitype": "number", "required": true},
{"name": "due_date", "label": "到期日", "uitype": "date", "required": true},
{"name": "status", "label": "状态", "uitype": "code", "data": [
{"value": "pending", "text": "待收"},
{"value": "partial", "text": "部分收款"},
{"value": "received", "text": "已收款"},
{"value": "overdue", "text": "逾期"}
]},
{"name": "received_amount", "label": "已收金额", "uitype": "number"},
{"name": "description", "label": "说明", "uitype": "textarea"},
{"name": "org_id", "label": "组织ID", "uitype": "text"}
],
"buttons": [
{"type": "submit", "text": "保存", "variant": "primary"},
{"type": "button", "text": "取消", "action": "navigate('main/financial_management/receivables.ui')"}
]
}
}
]
}
]
}

46
wwwroot/receivables.ui Normal file
View File

@ -0,0 +1,46 @@
{
"widgettype": "Page",
"options": {
"title": "应收账款",
"style": {"height": "100vh", "padding": "0"}
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"style": {"padding": "16px", "flex": 1, "overflow": "hidden"}},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"style": {"marginBottom": "16px", "gap": "8px"}},
"subwidgets": [
{"widgettype": "TextField", "id": "search_keyword", "options": {"label": "搜索", "placeholder": "合同/客户", "style": {"flex": 1}}},
{"widgettype": "Button", "id": "btn_search", "options": {"text": "搜索", "variant": "primary"}},
{"widgettype": "Button", "id": "btn_add", "options": {"text": "新增", "variant": "primary", "action": "navigate('main/financial_management/receivable_edit.ui')"}}
]
},
{
"widgettype": "DataGrid",
"id": "receivables_grid",
"options": {
"url": "{{entire_url('api/receivables_list.dspy')}}",
"style": {"flex": 1},
"columns": [
{"field": "id", "header": "编号", "width": 150},
{"field": "contract_id", "header": "合同", "width": 140},
{"field": "customer_id", "header": "客户", "width": 140},
{"field": "receivable_amount", "header": "应收金额", "width": 120},
{"field": "received_amount", "header": "已收金额", "width": 120},
{"field": "due_date", "header": "到期日", "width": 110},
{"field": "status", "header": "状态", "width": 90},
{"field": "description", "header": "说明", "width": 200}
],
"toolbar": [
{"type": "button", "text": "编辑", "icon": "edit", "action": "navigate('main/financial_management/receivable_edit.ui?id={% raw %}{{selectedRow.id}}{% endraw %}')"},
{"type": "button", "text": "删除", "icon": "delete", "action": "doDelete('{% raw %}{{selectedRow.id}}{% endraw %}')"}
]
}
}
]
}
]
}