feat: P0-5 收款财务闭环(收款登记/作废/统计/账户管理/财务页)
This commit is contained in:
@@ -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
@@ -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`(合同额-已收)。
|
||||
|
||||
@@ -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: '售后管理' } },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 为安全表达式)。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user