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 -*-
"""
Financial Management Module - Core Business Logic
Implements comprehensive receivables and payments management with order-level granularity
"""
import uuid
from datetime import datetime, date, timedelta
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional
from decimal import Decimal
# Import database module (following sqlor-database-module pattern)
from appPublic.jsonconfig import getConfig
from sqlor.dbp import getDBP
from sqlor.dbpools import DBPools
from ahserver.serverenv import ServerEnv
class FinancialManager:
"""Financial management core class"""
def __init__(self):
self.config = getConfig()
async def get_financial_db():
"""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:
"""自动立应收:按订单生成应收计划"""
dbp = await self.get_db_connection(org_id)
# 获取订单信息(假设订单管理模块已存在)
order = await dbp.select_one("orders", {"id": order_id, "org_id": org_id})
if not order:
async def create_receivable_from_order(order_id: str, org_id: str) -> str:
"""自动立应收:按订单生成应收计划"""
async with await get_financial_db() as sor:
# 获取订单信息
ns = {'page': 1, 'rows': 1, 'sort': 'id'}
orders = await sor.sqlExe(
"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} 不存在")
# 计算应收日期和到期日期
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()
order = dict(orders[0]) if hasattr(orders[0], 'keys') else orders[0]
credit_period = int(order.get("credit_period", 0)) if order.get("credit_period") else 0
due_date = receivable_date + timedelta(days=credit_period) if credit_period > 0 else None
# 计算到期日期(使用订单创建日期+30天默认账期
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('-', '')
receivable_data = {
"id": receivable_id,
"order_id": order_id,
"contract_id": order["contract_id"],
"customer_id": order["customer_id"],
"receivable_amount": order["amount"],
"received_amount": Decimal("0.00"),
"receivable_date": receivable_date,
"due_date": due_date,
"credit_period": credit_period,
"status": "pending",
"sales_owner_id": order.get("owner_id"),
"org_id": org_id,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
receivable_id = str(uuid.uuid4()).replace('-', '')[:32]
await sor.sqlExe("""
INSERT INTO receivables (id, order_id, contract_id, customer_id, receivable_amount, received_amount, due_date, status, org_id, created_at, updated_at)
VALUES (${id}$, ${order_id}$, ${contract_id}$, ${customer_id}$, ${amount}$, 0, ${due_date}$, 'pending', ${org_id}$, NOW(), NOW())
""", {
'id': receivable_id,
'order_id': order_id,
'contract_id': order.get('contract_id', ''),
'customer_id': order.get('customer_id', ''),
'amount': order.get('amount', 0),
'due_date': due_date.isoformat() if due_date else None,
'org_id': org_id
})
await dbp.insert("receivables", receivable_data)
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)
# 验证关联的订单和金额
allocations = receipt_data.get("allocations", [])
total_allocated = Decimal("0.00")
async def create_receipt(receipt_data: Dict, user_id: str, org_id: str) -> str:
"""收款录入"""
async with await get_financial_db() as sor:
receipt_id = str(uuid.uuid4()).replace('-', '')[:32]
for alloc in allocations:
order_id = alloc["order_id"]
allocated_amount = Decimal(str(alloc["allocated_amount"]))
# 验证订单存在且属于当前组织
order = await dbp.select_one("orders", {"id": order_id, "org_id": org_id})
if not order:
raise ValueError(f"订单 {order_id} 不存在或不属于当前组织")
# 验证应收记录存在
receivable = await dbp.select_one("receivables", {"order_id": order_id, "org_id": org_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)
await sor.sqlExe("""
INSERT INTO receipts (id, customer_id, total_amount, receipt_date, receipt_method, status, org_id, created_by, created_at, updated_at)
VALUES (${id}$, ${customer_id}$, ${total_amount}$, ${receipt_date}$, ${method}$, 'processed', ${org_id}$, ${user_id}$, NOW(), NOW())
""", {
'id': receipt_id,
'customer_id': receipt_data.get('customer_id', ''),
'total_amount': receipt_data.get('total_amount', 0),
'receipt_date': receipt_data.get('receipt_date', datetime.now().date().isoformat()),
'method': receipt_data.get('receipt_method', 'bank_transfer'),
'org_id': org_id,
'user_id': user_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:
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]:
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:
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:
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": {
"tablename": "receivables",
"label": "应收记录",
"comment": "按订单维度的应收记录管理"
},
"fields": [
{
"name": "id",
"title": "ID",
"type": "str",
"length": 64,
"nullable": false,
"comments": "主键"
},
{
"name": "order_id",
"title": "订单ID",
"type": "str",
"length": 64,
"nullable": false,
"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": []
"table_name": "receivables",
"fields": [
{"name": "id", "type": "varchar(64)", "not_null": true, "comment": "主键ID"},
{"name": "order_id", "type": "varchar(64)", "comment": "订单ID"},
{"name": "contract_id", "type": "varchar(64)", "comment": "合同ID"},
{"name": "customer_id", "type": "varchar(64)", "comment": "客户ID"},
{"name": "receivable_amount", "type": "decimal(15,2)", "comment": "应收金额"},
{"name": "received_amount", "type": "decimal(15,2)", "comment": "已收金额"},
{"name": "due_date", "type": "date", "comment": "到期日期"},
{"name": "status", "type": "varchar(32)", "comment": "状态"},
{"name": "description", "type": "varchar(500)", "comment": "描述"},
{"name": "org_id", "type": "varchar(64)", "comment": "组织ID"},
{"name": "created_at", "type": "timestamp", "comment": "创建时间"},
{"name": "updated_at", "type": "timestamp", "comment": "更新时间"}
],
"indexes": [
{"name": "idx_receivables_customer", "fields": ["customer_id"]},
{"name": "idx_receivables_contract", "fields": ["contract_id"]},
{"name": "idx_receivables_status", "fields": ["status"]},
{"name": "idx_receivables_due_date", "fields": ["due_date"]}
]
}

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