Files
yunzerwebsiteallinone/uniapp/pages/erp/finance/fund-edit.vue
T
2026-10-19 01:13:17 +08:00

783 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view v-if="fundNo" class="no-bar">
<text class="no-text">单号 {{ fundNo }}</text>
<u-tag :text="fundStatusLabel(status)" :type="fundStatusType(status)" size="mini" />
</view>
<!-- 单据信息 -->
<view class="form-card">
<view class="field">
<text class="field-label">款项类型 <text class="required">*</text></text>
<view v-if="editable" class="type-grid">
<view
v-for="ft in FUND_TYPES"
:key="ft.value"
class="type-cell"
:class="{ active: form.fundType === ft.value }"
@tap="onType(ft.value)"
>
<text>{{ ft.label }}</text>
</view>
</view>
<text v-else class="field-value">{{ typeLabel }}</text>
</view>
<view class="field">
<text class="field-label">业务日期 <text class="required">*</text></text>
<picker v-if="editable" mode="date" :value="form.bizDate" @change="onDate">
<view class="picker-value">
<text>{{ form.bizDate }}</text>
<FaIcon name="calendar-days" color="#909399" :size="16" />
</view>
</picker>
<text v-else class="field-value">{{ form.bizDate }}</text>
</view>
<view class="field">
<text class="field-label">
往来单位<text v-if="needPartner" class="required">*</text>
</text>
<view v-if="editable" class="picker-value" @tap="openPartner">
<text :class="{ placeholder: !form.partnerName }">{{ form.partnerName || chooseText }}</text>
<FaIcon name="chevron-right" color="#909399" :size="16" />
</view>
<text v-else class="field-value">{{ form.partnerName || '—' }}</text>
</view>
<view class="field">
<text class="field-label">金额 <text class="required">*</text></text>
<view class="amount-box">
<text class="amount-sign" :class="income ? 'sign-in' : 'sign-out'">{{ income ? '+' : '-' }}</text>
<input
v-if="editable"
v-model="form.amount"
class="amount-input"
type="digit"
placeholder="0.00"
/>
<text v-else class="field-value">¥{{ moneyText(form.amount) }}</text>
</view>
</view>
<view class="field">
<text class="field-label">结算方式</text>
<view v-if="editable" class="chip-line">
<view
v-for="m in FUND_METHODS"
:key="m.value"
class="mini-chip"
:class="{ active: form.payMethod === m.value }"
@tap="form.payMethod = m.value"
>
<text>{{ m.label }}</text>
</view>
</view>
<text v-else class="field-value">{{ fundMethodLabel(form.payMethod) }}</text>
</view>
<view v-if="editable || form.payAccount" class="field">
<text class="field-label">收付账户</text>
<u-input
v-if="editable"
v-model="form.payAccount"
placeholder="账号 / 商户主体(选填)"
:maxlength="100"
border="none"
/>
<text v-else class="field-value">{{ form.payAccount }}</text>
</view>
<view v-if="editable || form.remark" class="field">
<text class="field-label">备注</text>
<u-textarea
v-if="editable"
v-model="form.remark"
placeholder="付款事由、凭证号等(选填)"
:maxlength="200"
height="80"
border="none"
/>
<text v-else class="field-value">{{ form.remark }}</text>
</view>
</view>
<!-- 核销:收款/付款 + 已选往来单位时才出现 -->
<view v-if="showSettle" class="settle-card">
<view class="settle-head">
<text class="settle-title">核销往来账</text>
<text class="settle-sum">已选 ¥{{ moneyText(pickedSum) }} / {{ moneyText(form.amount) }}</text>
</view>
<text class="settle-tip">
{{ bills.length
? '不勾选则确认时按到期日从早到晚自动冲抵'
: needPartner && !form.partnerId
? '选择往来单位后显示其未结账单'
: '该往来单位没有未结账单,确认后只记资金流水' }}
</text>
<view v-if="billsLoading" class="settle-loading">
<text>正在拉取待核销账单...</text>
</view>
<view v-for="b in bills" :key="b.id" class="bill-row">
<view class="bill-check" :class="{ on: isPicked(b.id) }" @tap="toggleBill(b)">
<FaIcon v-if="isPicked(b.id)" name="check" color="#ffffff" :size="12" />
</view>
<view class="bill-main">
<text class="bill-no">{{ b.source_doc_no || `账单#${b.id}` }}</text>
<text class="bill-sub">
{{ dayText(b.bill_date) }}{{ b.due_date ? ` · 到期 ${dayText(b.due_date)}` : '' }} · 未结 ¥{{ moneyText(b.balance) }}
</text>
</view>
<input
v-if="isPicked(b.id)"
v-model="picked[b.id]"
class="bill-input"
type="digit"
:placeholder="String(b.balance)"
/>
<text v-else class="bill-balance">¥{{ moneyText(b.balance) }}</text>
</view>
<view v-if="bills.length" class="settle-foot">
<text class="foot-link" @tap="pickAll">全部冲抵</text>
<text v-if="pickedCount" class="foot-link" @tap="clearPicks">清空({{ pickedCount }})</text>
</view>
</view>
<view class="footer">
<template v-if="editable">
<view class="btn-line">
<u-button
type="primary"
color="#2563eb"
shape="circle"
:loading="saving"
loading-text="保存中..."
text="保存草稿"
@click="onSave(false)"
/>
</view>
<view class="btn-line gap">
<u-button
type="primary"
color="#16a34a"
shape="circle"
:loading="saving"
loading-text="确认中..."
:text="`确认${typeLabel}`"
@click="onSave(true)"
/>
</view>
<view v-if="fundId" class="del-line" @tap="onDelete">
<text>删除草稿</text>
</view>
</template>
<template v-else>
<view v-if="status === 1" class="btn-line">
<u-button type="error" plain shape="circle" text="作废此单" @click="onVoid" />
</view>
<view v-if="settles.length" class="view-settles">
<text class="vs-title">核销记录</text>
<view v-for="s in settles" :key="s.id" class="vs-row">
<text class="vs-no">{{ s.partner_bill_no || `账单#${s.partner_bill_id}` }}</text>
<text class="vs-amount">¥{{ moneyText(s.amount) }}</text>
</view>
</view>
</template>
</view>
<ErpPicker
:show="picker.show"
:title="partnerTitle"
placeholder="搜索客商"
:options="picker.options"
:model-value="form.partnerId"
remote
:loading="picker.loading"
:tip="picker.tip"
@update:show="picker.show = false"
@search="loadPartners"
@select="onPartner"
/>
</view>
</template>
<script setup>
/**
* 收付款开单 / 查看。
* 草稿只是登记,确认那一刻后端才写核销明细并回写往来账余额;
* 已确认的单不可改,只能作废(余额同步回滚),所以 view 模式复用同一张表单只读渲染。
*/
import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import FaIcon from '@/components/FaIcon.vue'
import ErpPicker from '@/components/erp/ErpPicker.vue'
import {
FUND_METHODS,
FUND_TYPES,
confirmFund,
dayText,
deleteFund,
fetchOpenBills,
fetchPartners,
fundMethodLabel,
fundStatusLabel,
fundStatusType,
getFund,
moneyText,
saveFund,
voidFund
} from '@/api/erp.js'
function today() {
const d = new Date()
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
const fundId = ref(0)
const fundNo = ref('')
const status = ref(0)
const saving = ref(false)
const bills = ref([])
const billsLoading = ref(false)
const settles = ref([])
const picked = reactive({})
const form = reactive({
fundType: 1,
bizDate: today(),
partnerId: 0,
partnerName: '',
amount: '',
payMethod: 'cash',
payAccount: '',
remark: ''
})
const picker = reactive({ show: false, options: [], loading: false, tip: '' })
const editable = computed(() => !fundId.value || status.value === 0)
const typeLabel = computed(() => (FUND_TYPES.find((t) => Number(t.value) === Number(form.fundType)) || {}).label || '收付款')
const needPartner = computed(() => Number(form.fundType) === 1 || Number(form.fundType) === 2)
const income = computed(() => Number(form.fundType) === 1 || Number(form.fundType) === 3)
const showSettle = computed(() => editable.value && needPartner.value)
const partnerTitle = computed(() => (Number(form.fundType) === 2 ? '选择供应商' : '选择客户'))
const chooseText = computed(() => (Number(form.fundType) === 2 ? '选择供应商' : '选择客户'))
const pickedCount = computed(() => Object.keys(picked).filter((k) => isPicked(k)).length)
const pickedSum = computed(() =>
Object.keys(picked).reduce((sum, k) => (isPicked(k) ? sum + (Number(picked[k]) || 0) : sum), 0)
)
onLoad(async (query = {}) => {
if (query.fund_type) form.fundType = Number(query.fund_type)
if (query.biz_date) form.bizDate = String(query.biz_date)
if (query.partner_id) {
form.partnerId = Number(query.partner_id)
form.partnerName = decodeURIComponent(query.partner_name || '')
}
if (query.id) {
await loadFund(Number(query.id))
} else if (needPartner.value && form.partnerId) {
// 从账单详情跳过来时把这张账单预勾选,金额默认取未结额
await loadBills(Number(query.bill_id || 0))
}
})
async function loadFund(id) {
const data = await getFund(id)
if (!data || !data.fund) {
uni.showToast({ title: '收付款单不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 600)
return
}
const f = data.fund
fundId.value = f.id
fundNo.value = f.fund_no
status.value = Number(f.status || 0)
form.fundType = Number(f.fund_type)
form.bizDate = f.biz_date || today()
form.partnerId = Number(f.partner_id || 0)
form.partnerName = f.partner_name || ''
form.amount = String(f.amount ?? '')
form.payMethod = f.pay_method || 'cash'
form.payAccount = f.pay_account || ''
form.remark = f.remark || ''
settles.value = data.settles || []
uni.setNavigationBarTitle({ title: fundStatusLabel(status.value) === '草稿' ? '编辑收付款' : '收付款详情' })
}
async function onType(value) {
if (Number(form.fundType) === Number(value)) return
form.fundType = Number(value)
// 收款只核客户应收、付款只核供应商应付,切换后原往来单位与勾选一并作废
form.partnerId = 0
form.partnerName = ''
clearPicks()
bills.value = []
}
function onDate(e) {
form.bizDate = e.detail.value
}
function openPartner() {
picker.show = true
loadPartners('')
}
async function loadPartners(keyword) {
picker.loading = true
try {
const partnerType = Number(form.fundType) === 2 ? 'supplier' : 'customer'
const { list, total } = await fetchPartners({ partnerType, keyword, onlyEnabled: true, pageSize: 50 })
picker.options = list.map((p) => ({
value: p.partner_id,
label: p.partner_name,
sub: [p.contact_person, p.contact_phone].filter(Boolean).join(' · '),
extra: Number(p.open_amount) > 0 ? `未结 ¥${moneyText(p.open_amount)}` : ''
}))
picker.tip = total > list.length ? `共 ${total} 家,输入关键字继续找` : ''
} finally {
picker.loading = false
}
}
async function onPartner(opt) {
picker.show = false
form.partnerId = Number(opt.value)
form.partnerName = opt.label
clearPicks()
await loadBills(0)
}
async function loadBills(presetBillId) {
if (!needPartner.value || !form.partnerId) return
billsLoading.value = true
try {
bills.value = await fetchOpenBills(form.fundType, form.partnerId)
if (presetBillId) {
const hit = bills.value.find((b) => Number(b.id) === Number(presetBillId))
if (hit) {
picked[hit.id] = String(hit.balance)
if (!Number(form.amount)) form.amount = String(hit.balance)
}
}
} finally {
billsLoading.value = false
}
}
function isPicked(id) {
return picked[id] !== undefined
}
function toggleBill(b) {
if (isPicked(b.id)) delete picked[b.id]
else picked[b.id] = String(b.balance)
}
function pickAll() {
bills.value.forEach((b) => {
picked[b.id] = String(b.balance)
})
const sum = bills.value.reduce((acc, b) => acc + Number(b.balance || 0), 0)
if (!Number(form.amount)) form.amount = String(Math.round(sum * 100) / 100)
}
function clearPicks() {
Object.keys(picked).forEach((k) => delete picked[k])
}
function checkForm() {
if (!(Number(form.amount) > 0)) return '金额必须大于 0'
if (needPartner.value && !form.partnerId) return `请选择${Number(form.fundType) === 2 ? '供应商' : '客户'}`
if (pickedSum.value > Number(form.amount) + 0.005) {
return `核销合计 ¥${moneyText(pickedSum.value)} 不能大于收付金额`
}
return ''
}
async function onSave(confirm) {
const msg = checkForm()
if (msg) {
uni.showToast({ title: msg, icon: 'none' })
return
}
saving.value = true
try {
const res = await saveFund({
id: fundId.value,
fundType: form.fundType,
bizDate: form.bizDate,
partnerId: form.partnerId,
amount: Number(form.amount),
payMethod: form.payMethod,
payAccount: form.payAccount,
remark: form.remark
})
const id = res.id || fundId.value
if (confirm) {
const lines = Object.keys(picked)
.filter((k) => isPicked(k) && Number(picked[k]) > 0)
.map((k) => ({ partnerBillId: Number(k), amount: Number(picked[k]) }))
await confirmFund(id, lines)
uni.showToast({ title: `已确认${typeLabel.value}`, icon: 'success' })
} else {
fundId.value = id
uni.showToast({ title: '草稿已保存', icon: 'success' })
}
setTimeout(() => back(), 600)
} finally {
saving.value = false
}
}
function onVoid() {
uni.showModal({
title: '作废收付款单',
content: '作废后已核销的往来账余额会同步回滚,确定继续?',
confirmColor: '#f56c6c',
success: async (res) => {
if (!res.confirm) return
await voidFund(fundId.value)
uni.showToast({ title: '已作废', icon: 'success' })
setTimeout(() => back(), 600)
}
})
}
async function onDelete() {
uni.showModal({
title: '删除草稿',
content: `确定删除「${fundNo.value || typeLabel.value}」这张草稿?`,
confirmColor: '#f56c6c',
success: async (res) => {
if (!res.confirm) return
await deleteFund(fundId.value)
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => back(), 600)
}
})
}
/** 列表页跳来的栈可能是账单详情,优先回到它的来源列表 */
function back() {
const pages = getCurrentPages()
if (pages.length > 1) uni.navigateBack()
else uni.redirectTo({ url: '/pages/erp/finance/fund' })
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
padding: 24rpx $page-padding-x 40rpx;
}
.no-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8rpx 20rpx;
.no-text {
font-size: 24rpx;
color: $color-text-muted;
}
}
.form-card {
@include card;
padding: 8rpx 28rpx;
}
.field {
padding: 24rpx 0;
border-bottom: 1rpx solid $color-divider;
&:last-child {
border-bottom: none;
}
.field-label {
display: block;
margin-bottom: 14rpx;
font-size: 26rpx;
color: $color-text-secondary;
}
.field-value {
display: block;
font-size: 28rpx;
color: $color-text;
line-height: 1.6;
word-break: break-all;
}
}
.required {
color: $color-danger;
}
.picker-value {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
font-size: 28rpx;
color: $color-text;
.placeholder {
color: $color-text-muted;
}
}
.type-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.type-cell {
flex: 1;
min-width: 140rpx;
height: 68rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-md;
background: #f1f5f9;
font-size: 25rpx;
color: $color-text-secondary;
&.active {
background: $color-primary-bg;
color: $color-primary-dark;
font-weight: 600;
}
}
.amount-box {
display: flex;
align-items: center;
gap: 12rpx;
}
.amount-sign {
font-size: 34rpx;
font-weight: 600;
&.sign-in {
color: #16a34a;
}
&.sign-out {
color: $color-danger;
}
}
.amount-input {
flex: 1;
font-size: 40rpx;
font-weight: 600;
color: $color-text;
}
.chip-line {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.mini-chip {
padding: 10rpx 26rpx;
border-radius: $radius-full;
background: #f1f5f9;
font-size: 24rpx;
color: $color-text-secondary;
&.active {
background: $color-primary-bg;
color: $color-primary-dark;
font-weight: 500;
}
}
.settle-card {
@include card;
margin-top: 24rpx;
padding: 26rpx 28rpx;
}
.settle-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.settle-title {
@include section-title;
}
.settle-sum {
font-size: 23rpx;
color: $color-primary;
}
.settle-tip {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
color: $color-text-muted;
line-height: 1.5;
}
.settle-loading {
padding: 24rpx 0;
font-size: 24rpx;
color: $color-text-muted;
}
.bill-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid $color-divider;
}
.bill-check {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
border-radius: $radius-sm;
border: 2rpx solid #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
&.on {
background: $color-primary;
border-color: $color-primary;
}
}
.bill-main {
flex: 1;
min-width: 0;
.bill-no {
display: block;
font-size: 26rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bill-sub {
display: block;
margin-top: 4rpx;
font-size: 21rpx;
color: $color-text-muted;
}
}
.bill-input {
width: 160rpx;
flex-shrink: 0;
height: 60rpx;
padding: 0 16rpx;
border-radius: $radius-sm;
background: #f1f5f9;
font-size: 25rpx;
color: $color-text;
text-align: right;
}
.bill-balance {
flex-shrink: 0;
font-size: 25rpx;
color: $color-text-secondary;
}
.settle-foot {
display: flex;
gap: 32rpx;
padding-top: 18rpx;
}
.foot-link {
font-size: 24rpx;
color: $color-primary;
}
.footer {
margin-top: 32rpx;
}
.btn-line {
&.gap {
margin-top: 20rpx;
}
}
.del-line {
display: flex;
justify-content: center;
padding: 26rpx 0 6rpx;
font-size: 24rpx;
color: $color-danger;
}
.view-settles {
margin-top: 32rpx;
@include card;
padding: 24rpx 28rpx;
.vs-title {
display: block;
margin-bottom: 8rpx;
font-size: 28rpx;
font-weight: 600;
color: $color-text;
}
}
.vs-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid $color-divider;
&:last-child {
border-bottom: none;
}
.vs-no {
flex: 1;
min-width: 0;
font-size: 25rpx;
color: $color-text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vs-amount {
font-size: 26rpx;
font-weight: 600;
color: #16a34a;
}
}
</style>