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
+2
View File
@@ -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)
+17
View File
@@ -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`(合同额-已收)。
+1
View File
@@ -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: '售后管理' } },
+49 -1
View File
@@ -16,6 +16,12 @@
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="count" label="数量" width="70" />
<el-table-column prop="money" label="金额" width="110" />
<el-table-column label="已收/未收" width="150">
<template #default="{ row }">
<span class="received">¥{{ row.received ?? 0 }}</span>
<span class="unreceived"> / ¥{{ row.unreceived ?? row.money }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'info' : row.status === 1 ? 'primary' : 'success'" size="small">
@@ -23,11 +29,12 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 0" size="small" type="primary"
@click="convert(row)">转正式单</el-button>
<el-button size="small" @click="createOrder(row)">下订单</el-button>
<el-button size="small" type="success" plain @click="openReceipt(row)">收款</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
</template>
</el-table-column>
@@ -55,6 +62,26 @@
<el-button type="primary" @click="save">创建</el-button>
</template>
</el-dialog>
<!-- 登记收款 -->
<el-dialog v-model="receiptVisible" :title="`登记收款 — ${receiptForm.contract_no}`" width="440px">
<el-form :model="receiptForm" label-width="80px">
<el-form-item label="合同金额">¥{{ receiptForm.money }}(未收 ¥{{ receiptForm.unreceived }})</el-form-item>
<el-form-item label="收款金额"><el-input-number v-model="receiptForm.amount" :min="0.01" :precision="2" style="width:180px" /></el-form-item>
<el-form-item label="收款方式">
<el-select v-model="receiptForm.method" style="width:180px">
<el-option :value="1" label="现金" /><el-option :value="2" label="银行转账" />
<el-option :value="3" label="微信" /><el-option :value="4" label="支付宝" />
<el-option :value="5" label="其他" />
</el-select>
</el-form-item>
<el-form-item label="备注"><el-input v-model="receiptForm.note" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="receiptVisible = false">取消</el-button>
<el-button type="primary" @click="saveReceipt">登记</el-button>
</template>
</el-dialog>
</el-card>
</template>
@@ -123,9 +150,30 @@ async function remove(row: any) {
load()
}
// ---- 登记收款 ----
const receiptVisible = ref(false)
const receiptForm = reactive<any>({ 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)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.received { color: #67c23a; font-weight: 600; }
.unreceived { color: #e6a23c; }
</style>
+238
View File
@@ -0,0 +1,238 @@
<template>
<div>
<!-- 统计卡 -->
<el-row :gutter="12" class="stat-row">
<el-col :span="6">
<el-card shadow="never"><div class="stat"><span class="stat-label">{{ year }} 年收款总额</span>
<span class="stat-num green">¥{{ stats.total_received }}</span></div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never"><div class="stat"><span class="stat-label">合同应收总额</span>
<span class="stat-num">¥{{ stats.contract_total }}</span></div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never"><div class="stat"><span class="stat-label">未收金额</span>
<span class="stat-num red">¥{{ stats.unreceived }}</span></div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never"><div class="stat"><span class="stat-label">收款账户</span>
<div class="stat-ops"><el-button size="small" type="primary" plain @click="accVisible = true">管理账户</el-button></div>
</div></el-card>
</el-col>
</el-row>
<!-- 月度收款 -->
<el-card shadow="never" style="margin-bottom:12px">
<template #header>
<div class="toolbar">
<span>月度收款(¥)</span>
<div style="flex:1" />
<el-date-picker v-model="year" type="year" placeholder="选择年份" value-format="YYYY"
style="width:110px" @change="loadStats" />
</div>
</template>
<div class="bar-chart">
<div v-for="(v, m) in stats.monthly" :key="m" class="bar-col">
<el-tooltip :content="`${Number(m) + 1}月 ¥${v}`" placement="top">
<div class="bar" :style="{ height: barHeight(v) }" />
</el-tooltip>
<span class="bar-label">{{ Number(m) + 1 }}</span>
</div>
</div>
</el-card>
<!-- 收款流水 -->
<el-card shadow="never">
<div class="toolbar">
<el-input v-model="keyword" placeholder="合同号/客户" clearable style="width:200px" @keyup.enter="loadReceipts" />
<el-button type="primary" @click="loadReceipts">查询</el-button>
<div style="flex:1" />
<el-button type="primary" plain @click="openCreate">登记收款</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="receipt_no" label="收款单号" width="150" />
<el-table-column prop="contract_no" label="合同号" width="150" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column prop="amount" label="金额" width="110">
<template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column>
<el-table-column prop="method_label" label="方式" width="100" />
<el-table-column prop="receipt_date" label="收款日期" width="110" />
<el-table-column prop="note" label="备注" min-width="110" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '正常' : '已作废' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 1" size="small" type="warning" plain @click="voidReceipt(row)">作废</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="loadReceipts" />
</el-card>
<!-- 登记收款 -->
<el-dialog v-model="createVisible" title="登记收款" width="480px">
<el-form :model="form" label-width="80px">
<el-form-item label="合同">
<el-select v-model="form.contract_id" filterable style="width:100%" placeholder="选择合同">
<el-option v-for="c in contractOptions" :key="c.id"
:label="`${c.contract_no} ${c.customer_name}(未收 ¥${c.unreceived})`" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="金额"><el-input-number v-model="form.amount" :min="0.01" :precision="2" style="width:180px" /></el-form-item>
<el-form-item label="收款方式">
<el-select v-model="form.method" style="width:180px">
<el-option v-for="(l, v) in METHODS" :key="v" :label="l" :value="Number(v)" />
</el-select>
</el-form-item>
<el-form-item label="收款账户">
<el-select v-model="form.account_id" clearable style="width:220px" placeholder="可选">
<el-option v-for="a in accounts" :key="a.id" :label="`${a.name}${a.bank ? ' ' + a.bank : ''}`" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="收款日期"><el-date-picker v-model="form.receipt_date" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.note" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" @click="save">登记</el-button>
</template>
</el-dialog>
<!-- 账户管理 -->
<el-dialog v-model="accVisible" title="收款账户管理" width="560px">
<el-table :data="accounts" size="small" border>
<el-table-column prop="name" label="户名" min-width="110" />
<el-table-column prop="bank" label="银行" width="130" />
<el-table-column prop="number" label="账号" min-width="140" show-overflow-tooltip />
<el-table-column label="默认" width="70">
<template #default="{ row }">
<el-tag v-if="row.is_default" size="small" type="success">默认</el-tag>
<el-button v-else size="small" text @click="setDefault(row)">设默认</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button size="small" type="danger" plain @click="removeAcc(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<div class="acc-form">
<el-input v-model="accForm.name" placeholder="户名" style="width:130px" />
<el-input v-model="accForm.bank" placeholder="银行" style="width:130px" />
<el-input v-model="accForm.number" placeholder="账号" style="width:170px" />
<el-button type="primary" plain @click="addAcc">新增账户</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const METHODS: Record<number, string> = { 1: '现金', 2: '银行转账', 3: '微信', 4: '支付宝', 5: '其他' }
const year = ref(String(new Date().getFullYear()))
const stats = reactive<any>({ total_received: 0, contract_total: 0, unreceived: 0, monthly: Array(12).fill(0) })
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 15
const keyword = ref('')
const loading = ref(false)
const accounts = ref<any[]>([])
const contractOptions = ref<any[]>([])
const createVisible = ref(false)
const accVisible = ref(false)
const form = reactive<any>({ contract_id: undefined, amount: 0, method: 2, account_id: undefined, receipt_date: '', note: '' })
const accForm = reactive<any>({ name: '', bank: '', number: '' })
const maxMonthly = computed(() => Math.max(...stats.monthly, 1))
const barHeight = (v: number) => `${Math.max(2, Math.round((v / maxMonthly.value) * 120))}px`
async function loadStats() {
const d = await api<any>('get', `/api/receipts/statistics?year=${year.value}`)
Object.assign(stats, d)
}
async function loadReceipts() {
loading.value = true
try {
const d = await api<any>('get', `/api/receipts?page=${page.value}&page_size=${page_size}&keyword=${keyword.value}&year=${year.value}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
async function loadAccounts() {
accounts.value = (await api<any[]>('get', '/api/receipt-accounts')) ?? []
}
async function openCreate() {
const d = await api<any>('get', '/api/contracts?page=1&page_size=100')
contractOptions.value = d.contracts?.data ?? []
form.receipt_date = new Date().toISOString().slice(0, 10)
createVisible.value = true
}
async function save() {
if (!form.contract_id) return ElMessage.warning('请选择合同')
await api('post', `/api/contracts/${form.contract_id}/receipts`, form)
ElMessage.success('收款已登记')
createVisible.value = false
loadStats(); loadReceipts()
}
async function voidReceipt(row: any) {
await ElMessageBox.confirm(`确定作废收款单 ${row.receipt_no}(¥${row.amount})?`, '提示', { type: 'warning' })
await api('post', `/api/receipts/${row.id}/void`)
ElMessage.success('已作废')
loadStats(); loadReceipts()
}
async function setDefault(row: any) {
await api('put', `/api/receipt-accounts/${row.id}`, { ...row, is_default: 1 })
loadAccounts()
}
async function removeAcc(row: any) {
await ElMessageBox.confirm(`确定删除账户「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/receipt-accounts/${row.id}`)
ElMessage.success('已删除')
loadAccounts()
}
async function addAcc() {
if (!accForm.name.trim()) return ElMessage.warning('请填写户名')
await api('post', '/api/receipt-accounts', { type: accForm.number ? 1 : 2, ...accForm })
ElMessage.success('账户已新增')
Object.assign(accForm, { name: '', bank: '', number: '' })
loadAccounts()
}
onMounted(() => { loadStats(); loadReceipts(); loadAccounts() })
</script>
<style scoped>
.stat-row { margin-bottom: 12px; }
.stat { display: flex; flex-direction: column; gap: 4px; }
.stat-label { font-size: 13px; color: #909399; }
.stat-num { font-size: 22px; font-weight: 700; color: #303133; }
.stat-num.green { color: #67c23a; }
.stat-num.red { color: #e6483d; }
.stat-ops { display: flex; align-items: center; height: 28px; }
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; align-items: center; }
.bar-chart { display: flex; gap: 10px; align-items: flex-end; height: 150px; padding: 0 8px; }
.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; }
.bar { width: 60%; min-height: 2px; background: linear-gradient(180deg, #6b9ee8, #4076c4); border-radius: 3px 3px 0 0; }
.bar-label { font-size: 11px; color: #909399; }
.acc-form { display: flex; gap: 8px; margin-top: 10px; }
</style>
+1
View File
@@ -13,6 +13,7 @@
<el-menu-item index="/sunny"><el-icon><OfficeBuilding /></el-icon>阳光房</el-menu-item>
<el-menu-item index="/quotations"><el-icon><PriceTag /></el-icon>报价单</el-menu-item>
<el-menu-item index="/contracts"><el-icon><Document /></el-icon>合同管理</el-menu-item>
<el-menu-item index="/financial"><el-icon><Money /></el-icon>财务管理</el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon>客户管理</el-menu-item>
<el-menu-item index="/aftersale"><el-icon><Service /></el-icon>售后管理</el-menu-item>
</el-menu-item-group>
+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 为安全表达式)。"""