feat: P0-5 收款财务闭环(收款登记/作废/统计/账户管理/财务页)
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user