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
+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>