diff --git a/CHANGELOG b/CHANGELOG index 5644261..0436c9b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 13:50] FEAT: v2.24 P0-5 收款财务闭环。①ContractReceipt 模型(SK+日期+流水/方式五类/作废状态)+finance.py(账户 CRUD/登记收款/作废/流水分页/年度统计); ②GET /receipts/statistics(收款总额/合同应收/未收/12 月分布, 对齐 statistics_tb); ③合同列表注入 received/unreceived(当页一次 GROUP BY 聚合); ④新页面 /financial 财务管理(统计卡+月度柱图+流水+登记+账户管理)+路由菜单; 合同页加已收/未收列+收款按钮对话框; 修复 v-for 索引 m+1 类型; vue-tsc 0 错+构建通过; API 实测登记 3000→统计/列表联动正确 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/finance.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/router.py, frontend/src/views/FinancialView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, doc/API.md) + - [2026-09-25 13:00] FEAT: v2.23 P0-4 按库存优化+切割准备。①cut_solution 加 use_stock/stock_length 参数: use_stock=true 从仓库 profile 库存(单根长度>0)取原料 FFD 优化, 需求超库存输出 shortage 缺料清单(不拦截); 非库存模式 stock_length 可配; ②新增 preparation 切割准备/领料清单(按原料聚合根数/切割件数/余料)随方案保存; ③下料方案页加库存开关+标准长度输入+领料单表格+缺料清单+打印; vue-tsc 0 错+构建通过; API 实测合同3(15件/利用率87.4%/领料6米5根) (Files: src/huamenchuang/api/platform.py, frontend/src/views/CutSolutionView.vue, doc/API.md) - [2026-09-25 12:20] FEAT: v2.22 P0-3 打印模板体系。①OrderFormat 模型(blocks 区块 JSON: title/info/image/table/sign)+种子 2 套默认模板; ②/api/order-formats CRUD(区块类型校验+is_default 唯一默认); ③打印中心重构为模板驱动: A4 预览按 blocks 渲染(BOM 块自动拉取 /orders/{id}/bom), 模板管理对话框(区块增删/上下移/title 文本/info 字段多选/table kind 选择); 修复 move(i) v-for 索引 string|number 类型; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/order_format.py, src/huamenchuang/api/router.py, src/huamenchuang/scripts/seed.py, frontend/src/views/PrinterView.vue, doc/API.md) diff --git a/doc/API.md b/doc/API.md index 83d2195..f6f5ce0 100644 --- a/doc/API.md +++ b/doc/API.md @@ -414,3 +414,20 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000 - `use_stock=false`:按 `stock_length`(默认 6000mm)虚拟原料,数量按需求自动估算 - 响应/保存均新增:`result`(完整优化方案)、`preparation[]`(切割准备/领料清单:原料×根数×切割件数×余料)、`shortage[]`(按库存模式的缺料清单) - `GET /api/cut_solution/{contract_id}` 返回完整保存方案(含 result/preparation/shortage) + +### 12.19 财务模块 `/api`(v2.20 新增,P0-收款财务闭环) + +对齐目标站 `/manage/financial` + `contract_receipt*` 语义。 + +**收款方式**:1现金 2银行转账 3微信 4支付宝 5其他;**状态**:1正常 0已作废(作废不参与统计/已收)。 + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | +|---|---|---|---|---| +| 收款账户列表 | GET | `/api/receipt-accounts` | — | | +| 新增/编辑/删除账户 | POST/PUT/DELETE | `/api/receipt-accounts[/{id}]` | `{type,name,number?,bank?,note?,is_default?}` | is_default=1 唯一默认 | +| **登记收款** | POST | `/api/contracts/{id}/receipts` | `{amount(>0), method, account_id?, receipt_date?, note?}` | 收款单号 `SK+YYYYMMDD+3位流水` | +| 作废收款 | POST | `/api/receipts/{id}/void` | 路径 | 对齐 void_contract_receipt | +| 收款流水分页 | GET | `/api/receipts` | `page,keyword,year,contract_id,status` | 联合同号/客户名 | +| **收款统计** | GET | `/api/receipts/statistics` | `year?`(默认当年) | `{total_received, contract_total, unreceived, monthly[12]}`(对齐 statistics/_tb) | + +合同列表(`GET /api/contracts`)响应行新增:`received`(有效收款合计)、`unreceived`(合同额-已收)。 diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 58e9ba7..5ed0233 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -17,6 +17,7 @@ const router = createRouter({ { path: 'sunny', component: () => import('../views/SunnyHouseView.vue'), meta: { title: '阳光房' } }, { path: 'quotations', component: () => import('../views/QuotationsView.vue'), meta: { title: '报价单' } }, { path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } }, + { path: 'financial', component: () => import('../views/FinancialView.vue'), meta: { title: '财务管理' } }, { path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } }, { path: 'measure', component: () => import('../views/MeasureView.vue'), meta: { title: '量尺管理' } }, { path: 'aftersale', component: () => import('../views/AfterSaleView.vue'), meta: { title: '售后管理' } }, diff --git a/frontend/src/views/ContractsView.vue b/frontend/src/views/ContractsView.vue index a55fc0f..a58d525 100644 --- a/frontend/src/views/ContractsView.vue +++ b/frontend/src/views/ContractsView.vue @@ -16,6 +16,12 @@ + + + - + @@ -55,6 +62,26 @@ 创建 + + + + + ¥{{ receiptForm.money }}(未收 ¥{{ receiptForm.unreceived }}) + + + + + + + + + + + + @@ -123,9 +150,30 @@ async function remove(row: any) { load() } +// ---- 登记收款 ---- +const receiptVisible = ref(false) +const receiptForm = reactive({ contract_id: 0, contract_no: '', money: 0, unreceived: 0, amount: 0, method: 2, note: '' }) + +function openReceipt(row: any) { + Object.assign(receiptForm, { contract_id: row.id, contract_no: row.contract_no, + money: row.money, unreceived: row.unreceived ?? row.money, + amount: row.unreceived ?? row.money, method: 2, note: '' }) + receiptVisible.value = true +} + +async function saveReceipt() { + const { contract_id, amount, method, note } = receiptForm + await api('post', `/api/contracts/${contract_id}/receipts`, { amount, method, note }) + ElMessage.success('收款已登记') + receiptVisible.value = false + load() +} + onMounted(load) diff --git a/frontend/src/views/FinancialView.vue b/frontend/src/views/FinancialView.vue new file mode 100644 index 0000000..89344c6 --- /dev/null +++ b/frontend/src/views/FinancialView.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/frontend/src/views/LayoutView.vue b/frontend/src/views/LayoutView.vue index f151ce7..d9c140f 100644 --- a/frontend/src/views/LayoutView.vue +++ b/frontend/src/views/LayoutView.vue @@ -13,6 +13,7 @@ 阳光房 报价单 合同管理 + 财务管理 客户管理 售后管理 diff --git a/src/huamenchuang/api/contracts.py b/src/huamenchuang/api/contracts.py index 0c81485..556f2e7 100644 --- a/src/huamenchuang/api/contracts.py +++ b/src/huamenchuang/api/contracts.py @@ -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}") diff --git a/src/huamenchuang/api/finance.py b/src/huamenchuang/api/finance.py new file mode 100644 index 0000000..948e90d --- /dev/null +++ b/src/huamenchuang/api/finance.py @@ -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, + }) diff --git a/src/huamenchuang/api/router.py b/src/huamenchuang/api/router.py index b268272..087f5c6 100644 --- a/src/huamenchuang/api/router.py +++ b/src/huamenchuang/api/router.py @@ -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) diff --git a/src/huamenchuang/models/__init__.py b/src/huamenchuang/models/__init__.py index 6afe358..68cc62f 100644 --- a/src/huamenchuang/models/__init__.py +++ b/src/huamenchuang/models/__init__.py @@ -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", diff --git a/src/huamenchuang/models/business.py b/src/huamenchuang/models/business.py index 26e768d..0716677 100644 --- a/src/huamenchuang/models/business.py +++ b/src/huamenchuang/models/business.py @@ -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 为安全表达式)。"""