feat: P0-5 收款财务闭环(收款登记/作废/统计/账户管理/财务页)

This commit is contained in:
HHY
2026-09-25 03:20:10 -07:00
parent cc5b3de6b2
commit 436d006b5a
11 changed files with 528 additions and 4 deletions
+20 -3
View File
@@ -1,6 +1,7 @@
"""合同路由:意向单/正式单管理。"""
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
@@ -46,11 +47,27 @@ async def create_contract(body: ContractBody, user: CurrentUser, db: DbSession):
@router.get("")
async def list_contracts(user: CurrentUser, db: DbSession, page: int = 1,
page_size: int = 15, keyword: str = "", status: int | None = None):
"""合同分页列表(结构对齐目标站 {"contracts": {...}})。"""
"""合同分页列表(结构对齐目标站 {"contracts": {...}},含已收金额)。"""
items, total = await ContractService(db).repo.search(
user.company_id, page, page_size, keyword, status)
return ok(data={"contracts": paginated(
[_to_out(c) for c in items], page, page_size, total)})
# 当页合同的有效收款聚合(一次 GROUP BY,避免逐行查询)
received_map: dict[int, float] = {}
if items:
from sqlalchemy import func
from huamenchuang.models.business import ContractReceipt
ids = [c.id for c in items]
rows = (await db.execute(
select(ContractReceipt.contract_id, func.sum(ContractReceipt.amount))
.where(ContractReceipt.contract_id.in_(ids), ContractReceipt.status == 1)
.group_by(ContractReceipt.contract_id))).all()
received_map = {cid: float(v or 0) for cid, v in rows}
outs = []
for c in items:
out = _to_out(c)
out["received"] = round(received_map.get(c.id, 0), 2)
out["unreceived"] = round(float(c.money) - out["received"], 2)
outs.append(out)
return ok(data={"contracts": paginated(outs, page, page_size, total)})
@router.put("/{contract_id}")
+176
View File
@@ -0,0 +1,176 @@
"""财务路由:收款账户、合同收款登记/作废、收款统计(对齐目标站 financial 语义)。"""
import datetime
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.business import ContractReceipt
from huamenchuang.models.contract import Contract, ReceiptAccount
router = APIRouter(tags=["财务"])
METHOD_LABELS = {1: "现金", 2: "银行转账", 3: "微信", 4: "支付宝", 5: "其他"}
# ---------------- 收款账户 ----------------
class AccountBody(BaseModel):
type: int = Field(default=1, ge=1, le=3, description="1银行 2现金 3其他")
name: str = Field(min_length=1, max_length=64)
number: str = ""
bank: str = ""
note: str = ""
is_default: int = Field(default=0, ge=0, le=1)
@router.get("/receipt-accounts")
async def list_accounts(user: CurrentUser, db: DbSession):
stmt = (select(ReceiptAccount).where(ReceiptAccount.company_id == user.company_id)
.order_by(ReceiptAccount.id))
rows = list((await db.execute(stmt)).scalars())
return ok(data=[{"id": r.id, "type": r.type, "name": r.name, "number": r.number,
"bank": r.bank, "note": r.note, "is_default": r.is_default,
"balance": float(r.balance)} for r in rows])
@router.post("/receipt-accounts")
async def create_account(body: AccountBody, user: CurrentUser, db: DbSession):
acc = ReceiptAccount(company_id=user.company_id, **body.model_dump())
if body.is_default:
for other in (await db.execute(select(ReceiptAccount).where(
ReceiptAccount.company_id == user.company_id))).scalars():
other.is_default = 0
db.add(acc)
await db.flush()
return ok(data={"id": acc.id, "name": acc.name}, message="账户已创建")
@router.put("/receipt-accounts/{account_id}")
async def update_account(account_id: int, body: AccountBody, user: CurrentUser, db: DbSession):
acc = await db.get(ReceiptAccount, account_id)
if acc is None or acc.company_id != user.company_id:
return fail("账户不存在")
if body.is_default:
for other in (await db.execute(select(ReceiptAccount).where(
ReceiptAccount.company_id == user.company_id))).scalars():
other.is_default = 0
for k, v in body.model_dump().items():
setattr(acc, k, v)
return ok(message="账户已更新")
@router.delete("/receipt-accounts/{account_id}")
async def delete_account(account_id: int, user: CurrentUser, db: DbSession):
acc = await db.get(ReceiptAccount, account_id)
if acc is None or acc.company_id != user.company_id:
return fail("账户不存在")
await db.delete(acc)
return ok(message="账户已删除")
# ---------------- 收款登记/作废 ----------------
class ReceiptBody(BaseModel):
amount: float = Field(gt=0, description="收款金额")
method: int = Field(default=2, ge=1, le=5)
account_id: int | None = None
receipt_date: datetime.date | None = None
note: str = ""
async def _next_receipt_no(db, company_id: int) -> str:
today = datetime.date.today().strftime("%Y%m%d")
stmt = select(func.count(ContractReceipt.id)).where(
ContractReceipt.company_id == company_id,
func.strftime("%Y%m%d", ContractReceipt.created_at) == today)
n = (await db.execute(stmt)).scalar() or 0
return f"SK{today}{n + 1:03d}"
def _receipt_out(r: ContractReceipt, contract: Contract | None) -> dict:
return {"id": r.id, "receipt_no": r.receipt_no, "contract_id": r.contract_id,
"contract_no": contract.contract_no if contract else "",
"customer_name": contract.customer_name if contract else "",
"amount": float(r.amount), "method": r.method,
"method_label": METHOD_LABELS.get(r.method, "其他"),
"account_id": r.account_id, "receipt_date": str(r.receipt_date),
"note": r.note, "status": r.status, "created_at": str(r.created_at)[:19]}
@router.post("/contracts/{contract_id}/receipts")
async def create_receipt(contract_id: int, body: ReceiptBody, user: CurrentUser, db: DbSession):
"""登记合同收款(对齐 contract_receipt)。"""
contract = await db.get(Contract, contract_id)
if contract is None or contract.company_id != user.company_id:
return fail("合同不存在")
r = ContractReceipt(
company_id=user.company_id, contract_id=contract_id,
receipt_no=await _next_receipt_no(db, user.company_id),
amount=body.amount, method=body.method, account_id=body.account_id,
receipt_date=body.receipt_date or datetime.date.today(),
note=body.note, user_id=user.id)
db.add(r)
await db.flush()
return ok(data=_receipt_out(r, contract), message="收款已登记")
@router.post("/receipts/{receipt_id}/void")
async def void_receipt(receipt_id: int, user: CurrentUser, db: DbSession):
"""作废收款(对齐 void_contract_receipt:状态置 0,不参与统计/已收)。"""
r = await db.get(ContractReceipt, receipt_id)
if r is None or r.company_id != user.company_id:
return fail("收款记录不存在")
r.status = 0
return ok(message="已作废")
@router.get("/receipts")
async def list_receipts(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", year: int | None = None,
contract_id: int | None = None, status: int | None = None):
"""收款流水分页(联合同号/客户名)。"""
conds = [ContractReceipt.company_id == user.company_id]
if keyword:
conds.append(Contract.contract_no.contains(keyword) | Contract.customer_name.contains(keyword))
if year:
conds.append(func.strftime("%Y", ContractReceipt.receipt_date) == str(year))
if contract_id:
conds.append(ContractReceipt.contract_id == contract_id)
if status is not None:
conds.append(ContractReceipt.status == status)
base = (select(ContractReceipt, Contract)
.join(Contract, Contract.id == ContractReceipt.contract_id)
.where(*conds))
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar() or 0
rows = (await db.execute(base.order_by(ContractReceipt.id.desc())
.offset((page - 1) * page_size).limit(page_size))).all()
return ok(data=paginated([_receipt_out(r, c) for r, c in rows], page, page_size, total))
@router.get("/receipts/statistics")
async def receipt_statistics(user: CurrentUser, db: DbSession, year: int | None = None):
"""收款统计(对齐 contract_receipt_statistics(_tb):年度总额+月度分布+应收/未收)。"""
year = year or datetime.date.today().year
ycond = (ContractReceipt.company_id == user.company_id, ContractReceipt.status == 1,
func.strftime("%Y", ContractReceipt.receipt_date) == str(year))
total = (await db.execute(select(func.coalesce(func.sum(ContractReceipt.amount), 0))
.where(*ycond))).scalar() or 0
months_rows = (await db.execute(
select(func.strftime("%m", ContractReceipt.receipt_date), func.sum(ContractReceipt.amount))
.where(*ycond).group_by(func.strftime("%m", ContractReceipt.receipt_date)))).all()
months = {int(m): float(v or 0) for m, v in months_rows}
monthly = [months.get(m, 0) for m in range(1, 13)]
# 应收/未收:公司全部未作废合同的合同额 vs 有效收款
contract_total = (await db.execute(
select(func.coalesce(func.sum(Contract.money), 0)).where(
Contract.company_id == user.company_id, Contract.status < 3))).scalar() or 0
return ok(data={
"year": year,
"total_received": round(float(total), 2),
"contract_total": round(float(contract_total), 2),
"unreceived": round(float(contract_total) - float(total), 2),
"monthly": monthly,
})
+2
View File
@@ -6,6 +6,7 @@ from huamenchuang.api import (
business,
contracts,
customers,
finance,
optimize,
order_format,
orders,
@@ -27,6 +28,7 @@ api_router.include_router(optimize.router)
api_router.include_router(quote.router)
api_router.include_router(script.router)
api_router.include_router(order_format.router)
api_router.include_router(finance.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
+2
View File
@@ -2,6 +2,7 @@
from huamenchuang.models.announcement import Announcement
from huamenchuang.models.business import (
AfterSale,
ContractReceipt,
MeasureOrder,
PriceScript,
Product,
@@ -33,6 +34,7 @@ __all__ = [
"Article",
"CompanyParam",
"Contract",
"ContractReceipt",
"Customer",
"DEFAULT_PROCESSES",
"Device",
+20
View File
@@ -7,6 +7,26 @@ from sqlalchemy.orm import Mapped, mapped_column
from huamenchuang.core.database import Base
class ContractReceipt(Base):
"""合同收款记录(对齐目标站 contract_receipt:登记/作废/统计)。"""
__tablename__ = "contract_receipts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
contract_id: Mapped[int] = mapped_column(ForeignKey("contracts.id"), index=True)
receipt_no: Mapped[str] = mapped_column(String(32), unique=True) # SK+日期+流水
amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
# 1现金 2银行转账 3微信 4支付宝 5其他
method: Mapped[int] = mapped_column(default=2)
account_id: Mapped[int | None] = mapped_column(default=None) # 收款账户
receipt_date: Mapped[datetime.date] = mapped_column(default=datetime.date.today)
note: Mapped[str] = mapped_column(Text, default="")
status: Mapped[int] = mapped_column(default=1) # 1正常 0已作废
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class PriceScript(Base):
"""价格脚本(对齐目标站 script/windoorscript:按系列配置定价公式,formula 为安全表达式)。"""