feat(finance): 重构收票单新增功能并完善相关逻辑

- 新增收票计划选择器组件及交互逻辑
- 优化收票单表单结构,支持从应付单合并生成收票单
- 增加收票计划金额和比例计算功能
- 完善收票单提交校验逻辑,确保数据完整性
- 更新收票单详情展示字段,修正显示错误
- 调整收票单列表操作按钮权限控制
- 扩展后端服务接口,支持根据收票单编号查询明细
- 优化收票单审批状态管理及相关业务逻辑处理
dev_1.0.0
chenhao 2025-12-15 17:12:08 +08:00
parent 49cd27c221
commit dc1f5f7302
6 changed files with 382 additions and 99 deletions

View File

@ -1,94 +1,202 @@
<template>
<el-dialog title="新增收票单" :visible.sync="internalVisible" width="500px" @close="handleClose">
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item label="收票单类型" prop="receiptBillType">
<el-select v-model="form.receiptBillType" disabled placeholder="请选择收票单类型">
<el-dialog title="新增收票单" :visible.sync="internalVisible" width="80%" @close="handleClose" append-to-body>
<div class="dialog-body">
<el-form ref="form" :model="queryParams" :inline="true" label-width="120px">
<el-row>
<el-col :span="8">
<el-form-item label="收票单类型" prop="ticketBillType">
<!-- Mapping receiptBillType to ticketBillType for consistency with the merge logic -->
<el-select disabled v-model="form.receiptBillType" placeholder="请选择收票单类型" clearable>
<!-- Using dicts.receipt_bill_type if available, or dict.type.payment_bill_type if that was the intent.
The original code used dicts.receipt_bill_type. I'll stick to that but ensure it's passed or available.
The user instructions imply 'MergeReceiptDialog' logic which used payment_bill_type.
I will use the existing props 'dicts' -->
<el-option
v-for="dict in dicts.receipt_bill_type"
v-for="dict in dict.type.payment_bill_type"
:key="dict.value"
:label="dict.label"
:value="dict.value"
></el-option>
/>
</el-select>
</el-form-item>
<el-form-item label="制造商名称" prop="vendorCode">
<el-select v-model="form.vendorCode" placeholder="请选择制造商" style="width:100%" >
<el-option v-for="item in vendorOptions" :key="item.vendorCode" :label="item.vendorName" :value="item.vendorCode"></el-option>
</el-col>
<el-col :span="8">
<el-form-item label="制造商名称">
<el-select
v-model="queryParams.vendorCode"
placeholder="请选择制造商"
filterable
clearable
@change="handleVendorChange"
>
<el-option
v-for="item in vendorOptions"
:key="item.vendorCode"
:label="item.vendorName"
:value="item.vendorCode"
/>
</el-select>
</el-form-item>
<el-form-item label="预计收票时间" prop="receiptTime">
</el-col>
<el-col :span="8">
<el-form-item label="厂家开票时间" prop="vendorTicketTime">
<el-date-picker
v-model="form.receiptTime"
type="datetime"
placeholder="选择日期"
v-model="form.vendorTicketTime"
type="date"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择日期"
></el-date-picker>
</el-form-item>
<el-form-item label="含税总价" prop="totalPriceWithTax">
<el-input-number v-model="form.totalPriceWithTax" @change="handlePriceChange" :precision="2"
:step="1"></el-input-number>
</el-form-item>
<el-form-item label="税率" prop="taxRate">
<el-input-number v-model="form.taxRate" @change="handlePriceChange" :precision="2" :step="0.01"
:max="1"></el-input-number>
</el-form-item>
<el-form-item label="未税总价" prop="totalPriceWithoutTax">
<el-input v-model="form.totalPriceWithoutTax" :disabled="true"/>
</el-form-item>
<el-form-item label="税额" prop="taxAmount">
<el-input v-model="form.taxAmount" :disabled="true"/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider content-position="left">采购应付单表</el-divider>
<el-table
:data="payableOrdersWithPlans"
border
max-height="300px"
style="margin-bottom: 20px;"
row-key="id"
@selection-change="handleSelectionChange"
ref="table"
>
<el-table-column type="selection" :reserve-selection="true" width="55" align="center" />
<el-table-column label="应付单编号" align="center" prop="payableBillCode" width="150"/>
<el-table-column label="预计收票时间" align="center" prop="planTicketDate" width="180"/>
<el-table-column label="收票计划" align="center" width="100" prop="planTicketAmount">
</el-table-column>
<el-table-column label="项目名称" align="center" prop="projectName" width="150"/>
<el-table-column label="合同编号" align="center" prop="orderCode" width="150"/>
<el-table-column label="出入库单号" align="center" prop="inventoryCode" width="150"/>
<el-table-column label="收票状态" align="center" prop="invoiceStatus" width="120">
<template slot-scope="scope">
<dict-tag :options="dict.type.invoice_status" :value="scope.row.invoiceStatus"/>
</template>
</el-table-column>
<el-table-column label="含税总价" align="center" prop="totalPriceWithTax" width="120"/>
<el-table-column label="未收票金额" align="center" prop="unInvoicedAmount" width="120"/>
<el-table-column label="本次收票金额" align="center" width="120">
<template slot-scope="scope">
{{ calculateOrderCurrentTicketAmount(scope.row.id).toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="本次收票比例" align="center" width="120">
<template slot-scope="scope">
{{ calculateOrderCurrentTicketRate(scope.row.id) }}%
</template>
</el-table-column>
<el-table-column label="已收票金额" align="center" prop="invoicedAmount" width="120"/>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleOpenTicketPlanSelector(scope.row, scope.$index)"
>选择
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<div class="total-info">
<span style="margin-left: 20px;">计划收票总金额: <el-tag type="success">{{ totalPlannedAmount.toFixed(2) }}</el-tag></span>
<span>计划收票比例: <el-tag type="info">{{ totalPayableAmountWithTax ? this.$calc.mul(this.$calc.div(totalPlannedAmount,totalPayableAmountWithTax,4),100) : 0 }}%</el-tag></span>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="handleClose"> </el-button>
<el-button type="primary" @click="handleSubmit"> </el-button>
</div>
<el-dialog :title="planTitle" :visible.sync="isTicketPlanSelectorOpen" width="70%"
@close="isTicketPlanSelectorOpen=false" append-to-body>
<receiving-ticket-plan
ref="planSelector"
:payable-data="choosePayable"
:selected-plans="choosePayable.ticketPlans"
@confirm="handleTicketPlanConfirm"
/>
<div slot="footer" class="dialog-footer">
<el-button @click="isTicketPlanSelectorOpen=false"> </el-button>
<el-button type="primary" @click="handleChooseConfirm"> </el-button>
</div>
</el-dialog>
</el-dialog>
</template>
<script>
import {listAllVendor} from "@/api/base/vendor";
import { listAllVendor } from "@/api/base/vendor";
import { listPayable } from "@/api/finance/payable";
// Importing from the payable component directory as it likely shares logic
import ReceivingTicketPlan from "@/views/finance/payable/components/ReceivingTicketPlan.vue";
export default {
name: "AddForm",
components: { ReceivingTicketPlan },
props: {
visible: {
type: Boolean,
default: false
},
dicts: {
type: Object,
default: () => ({})
}
},
dicts:['payment_bill_type','invoice_status'],
data() {
return {
internalVisible: this.visible,
vendorOptions: [],
form: {
receiptBillType:'STANDARD_INVOICE', // Default type, assuming STANDARD_INVOICE exists or user changes it
vendorName: null,
receiptTime: null,
totalPriceWithTax: 0,
taxRate: 0.13, // Default tax rate
totalPriceWithoutTax: 0,
taxAmount: 0
// Search params
queryParams: {
pageNum: 1,
pageSize: 10,
ticketBillType: 'FROM_PAYABLE', // Used for filtering if needed
vendorId: null,
invoiceStatus: null,
},
rules: {
receiptBillType: [{required: true, message: "收票单类型不能为空", trigger: "change"}],
vendorName: [{required: true, message: "制造商名称不能为空", trigger: "blur"}],
receiptTime: [{required: true, message: "预计收票时间不能为空", trigger: "change"}],
totalPriceWithTax: [{required: true, message: "含税总价不能为空", trigger: "blur"}]
}
// Form data for submission
form: {
receiptBillType: 'FROM_PAYABLE',
vendorTicketTime: null,
},
payableOrdersWithPlans: [], // Current page data
selectedRows: [], // Cross-page selection
total: 0,
// Plan Selector State
planTitle: '',
isTicketPlanSelectorOpen: false,
choosePayable: {},
currentPayableOrderIndexForPlan: -1,
loadingTicketPlans: false,
};
},
computed: {
// Calculate totals based on SELECTED rows
totalPayableAmountWithTax() {
return this.selectedRows.reduce((sum, order) => sum + (order.totalPriceWithTax || 0), 0);
},
totalPlannedAmount() {
return this.selectedRows.reduce((orderSum, order) => {
const orderPlansTotal = (order.ticketPlans || []).reduce((planSum, plan) => planSum + (plan.planAmount || 0), 0);
return orderSum + orderPlansTotal;
}, 0);
},
},
watch: {
visible(newVal) {
this.internalVisible = newVal;
if (newVal) {
this.resetForm();
this.getVendorList();
}
},
internalVisible(newVal) {
@ -96,47 +204,201 @@ export default {
}
},
methods: {
/** 获取厂商列表 */
getVendorList() {
return listAllVendor().then(res => {
this.vendorOptions = res.data;
listAllVendor().then(res => {
this.vendorOptions = res.data || [];
})
},
handlePriceChange() {
const taxed = this.form.totalPriceWithTax || 0;
const rate = this.form.taxRate || 0;
const unTaxed = this.$calc.div(taxed, this.$calc.add(1, rate));
const taxAmount = this.$calc.sub(taxed, unTaxed);
this.form.totalPriceWithoutTax = unTaxed;
this.form.taxAmount = taxAmount;
handleVendorChange() {
this.queryParams.pageNum = 1;
this.selectedRows = [];
if (this.$refs.table) {
this.$refs.table.clearSelection();
}
this.getList();
},
getList() {
if (!this.queryParams.vendorCode) {
this.payableOrdersWithPlans = [];
this.total = 0;
return;
}
this.loadingTicketPlans = true;
listPayable(this.queryParams).then(response => {
this.payableOrdersWithPlans = response.rows.map(order => {
// Initialize ticket plans
const ticketPlans = order.ticketPlans ? [...order.ticketPlans] : [];
if (ticketPlans.length === 0 && order.lastTicketPlanId) {
ticketPlans.push({
id: order.lastTicketPlanId,
planAmount: order.planTicketAmount,
planTicketDate: order.planTicketDate,
planRate: order.totalPriceWithTax ? this.$calc.mul(this.$calc.div(order.planTicketAmount, order.totalPriceWithTax, 4), 100) : 0
});
}
return {
...order,
ticketPlans: ticketPlans,
totalPriceWithTax: order.totalPriceWithTax || 0,
unInvoicedAmount: order.unInvoicedAmount || 0,
invoicedAmount: order.invoicedAmount || 0,
};
});
this.total = response.total;
this.loadingTicketPlans = false;
});
},
handleSelectionChange(selection) {
this.selectedRows = selection;
},
handleClose() {
this.internalVisible = false;
this.resetForm();
},
handleChooseConfirm() {
if (!this.$refs.planSelector) {
this.$modal.msgError('无法获取计划选择器组件');
return;
}
const selectedPlans = this.$refs.planSelector.selectedPlan || [];
const orderIndex = this.payableOrdersWithPlans.findIndex(o => o.id === this.choosePayable.id);
if (orderIndex === -1) {
this.$modal.msgError('找不到要更新的应付单');
return;
}
const currentOrder = this.payableOrdersWithPlans[orderIndex];
// Update view
this.$set(currentOrder, 'ticketPlans', [...selectedPlans]);
// Update selectedRows
const selectedIndex = this.selectedRows.findIndex(o => o.id === this.choosePayable.id);
if (selectedIndex !== -1) {
this.$set(this.selectedRows[selectedIndex], 'ticketPlans', [...selectedPlans]);
}
this.isTicketPlanSelectorOpen = false;
this.$modal.msgSuccess(`已更新收票计划选择,共 ${selectedPlans.length}`);
},
handleOpenTicketPlanSelector(row, index) {
this.planTitle = `选择收票计划 - ${row.payableBillCode}`;
this.choosePayable = row;
this.currentPayableOrderIndexForPlan = index;
this.isTicketPlanSelectorOpen = true;
},
handleTicketPlanConfirm(updatedPlans) {
// Callback from ReceivingTicketPlan if it emits 'confirm' directly,
// but here we use handleChooseConfirm triggered by the dialog button.
// Current logic relies on ref access in handleChooseConfirm.
},
calculateOrderCurrentTicketAmount(orderId) {
const order = this.payableOrdersWithPlans.find(o => o.id === orderId);
if (order && order.ticketPlans) {
return order.ticketPlans.reduce((sum, plan) => sum + (plan.planAmount || 0), 0);
}
return 0;
},
calculateOrderCurrentTicketRate(orderId) {
const order = this.payableOrdersWithPlans.find(o => o.id === orderId);
if (order && order.ticketPlans && order.unInvoicedAmount >= 0) {
const currentAmount = this.calculateOrderCurrentTicketAmount(orderId);
return order.totalPriceWithTax ? this.$calc.mul(this.$calc.div(currentAmount ,order.totalPriceWithTax,4 ),100) : 0;
}
return 0;
},
handleSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
this.$emit("submit", this.form);
if (!this.queryParams.vendorCode) {
this.$modal.msgError('请选择制造商');
return;
}
});
if (!this.form.vendorTicketTime) {
this.$modal.msgError('请选择厂家开票时间');
return;
}
if (this.selectedRows.length === 0) {
this.$modal.msgError('请至少勾选一条应付单');
return;
}
const ordersToSubmit = this.selectedRows;
// Validate plans
for (const order of ordersToSubmit) {
if (!order.ticketPlans || order.ticketPlans.length === 0) {
this.$modal.msgError(`应付单 ${order.payableBillCode} 至少需要一条收票计划`);
return;
}
for (const plan of order.ticketPlans) {
if (!plan.planTicketDate) {
this.$modal.msgError(`应付单 ${order.payableBillCode} 的收票计划中预计收票时间不能为空。`);
return;
}
if (plan.planAmount === null || plan.planAmount === undefined || plan.planAmount <= 0) {
this.$modal.msgError(`应付单 ${order.payableBillCode} 的收票计划中预计收票金额必须大于0。`);
return;
}
}
}
// Construct payload
const data = {
receiptBillType: this.form.receiptBillType,
vendorTicketTime: this.form.vendorTicketTime,
vendorId: this.queryParams.vendorId,
payableOrders: ordersToSubmit.map(order => ({
id: order.id,
payableBillCode: order.payableBillCode,
ticketPlans: order.ticketPlans.map(plan => ({
planTicketDate: plan.planTicketDate,
planAmount: plan.planAmount,
planRate: plan.planRate,
remark: plan.remark,
id: plan.id,
})),
})),
totalMergeTicketAmount: this.totalPlannedAmount,
};
this.$emit("submit", data);
// internalVisible will be handled by parent or by success callback usually,
// but here we just emit submit. The parent likely handles the API call.
// If we want to close immediately:
// this.internalVisible = false;
},
resetForm() {
if (this.$refs.form) {
this.$refs.form.resetFields();
}
this.form = {
receiptBillType: 'STANDARD_INVOICE',
vendorName: null,
receiptTime: null,
totalPriceWithTax: 0,
taxRate: 0.13,
totalPriceWithoutTax: 0,
taxAmount: 0
receiptBillType: 'FROM_PAYABLE',
vendorTicketTime: null,
};
this.handlePriceChange();
this.queryParams = {
pageNum: 1,
pageSize: 10,
ticketBillType: 'FROM_PAYABLE',
vendorId: null,
invoiceStatus: null,
};
this.payableOrdersWithPlans = [];
this.selectedRows = [];
this.total = 0;
if (this.$refs.table) {
this.$refs.table.clearSelection();
}
}
}
};
</script>
<style scoped>
.dialog-body {
max-height: 70vh;
overflow-y: auto;
padding-right: 10px;
}
.total-info {
margin-top: 20px;
text-align: right;
font-weight: bold;
}
</style>

View File

@ -38,16 +38,16 @@
<div class="detail-item">税额: {{ detail.taxAmount }}</div>
</el-col>
<el-col :span="8">
<div class="detail-item">发票含税总价: {{ detail.actualReceiptTime || '-'}}</div>
<div class="detail-item">发票含税总价: {{ detail.ticketPriceWithTax || '-'}}</div>
</el-col>
<el-col :span="8">
<div class="detail-item">发票未税总价: {{ detail.actualReceiptTime || '-'}}</div>
<div class="detail-item">发票未税总价: {{ detail.ticketPriceWithoutTax || '-'}}</div>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<div class="detail-item">发票税额: {{ detail.taxAmount }}</div>
<div class="detail-item">发票税额: {{ detail.ticketAmount }}</div>
</el-col>
<el-col :span="8">
<div class="detail-item">收票状态:

View File

@ -176,11 +176,12 @@
type="text"
icon="el-icon-document"
@click="handleReceipt(scope.row)"
>收票附件</el-button>
>发票</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-document"
v-show="scope.row.approveStatus==='0'"
@click="handleReturn(scope.row)"
>退回</el-button>
<el-button
@ -262,7 +263,7 @@ export default {
addOpen: false,
//
receiptOpen: false,
currentRow: null
currentRow: {}
};
},
created() {

View File

@ -70,4 +70,6 @@ public interface IOmsPayableTicketDetailService
List<OmsPayableTicketDetail> listByPayableBillIdList(List<Long> collect);
List<OmsPayableTicketDetail> selectByTicketPlanIds(List<Long> ticketPlanIds);
List<OmsPayableTicketDetail> listDetailByTicketCode(String ticketBillCode);
}

View File

@ -157,4 +157,11 @@ public class OmsPayableTicketDetailServiceImpl implements IOmsPayableTicketDetai
public List<OmsPayableTicketDetail> selectByTicketPlanIds(List<Long> ticketPlanIds) {
return omsPayableTicketDetailMapper.selectByTicketPlanIds(ticketPlanIds);
}
@Override
public List<OmsPayableTicketDetail> listDetailByTicketCode(String ticketBillCode) {
OmsPayableTicketDetail omsPayableTicketDetail = new OmsPayableTicketDetail();
omsPayableTicketDetail.setTicketBillCode(ticketBillCode);
return omsPayableTicketDetailMapper.selectOmsPayableTicketDetailList(omsPayableTicketDetail);
}
}

View File

@ -2,23 +2,25 @@ package com.ruoyi.sip.service.impl;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import cn.hutool.core.collection.CollUtil;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.enums.ApproveStatusEnum;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.ShiroUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.file.FileUploadUtils;
import com.ruoyi.sip.domain.OmsFinAttachment;
import com.ruoyi.sip.domain.OmsPaymentBill;
import com.ruoyi.sip.domain.*;
import com.ruoyi.sip.domain.dto.PaymentBillPayableDetailDTO;
import com.ruoyi.sip.service.IOmsFinAttachmentService;
import com.ruoyi.sip.service.IOmsPayableBillService;
import com.ruoyi.sip.service.IOmsPayableTicketDetailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.sip.mapper.OmsTicketBillMapper;
import com.ruoyi.sip.domain.OmsTicketBill;
import com.ruoyi.sip.service.IOmsTicketBillService;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
@ -41,6 +43,8 @@ public class OmsTicketBillServiceImpl implements IOmsTicketBillService
private IOmsPayableTicketDetailService payableTicketDetailService;
@Autowired
private IOmsFinAttachmentService omsFinAttachmentService;
@Autowired
private IOmsPayableBillService payableBillService;
/**
*
@ -78,6 +82,9 @@ public class OmsTicketBillServiceImpl implements IOmsTicketBillService
@Override
public int insertOmsTicketBill(OmsTicketBill omsTicketBill)
{
if (StringUtils.isEmpty(omsTicketBill.getApproveStatus())){
omsTicketBill.setApproveStatus(ApproveStatusEnum.WAIT_COMMIT.getCode());
}
omsTicketBill.setTicketBillCode(generateTicketBillCode());
omsTicketBill.setCreateTime(DateUtils.getNowDate());
return omsTicketBillMapper.insertOmsTicketBill(omsTicketBill);
@ -169,12 +176,13 @@ public class OmsTicketBillServiceImpl implements IOmsTicketBillService
attachment.setCreateBy(loginUser.getUserId().toString());
omsFinAttachmentService.insertOmsFinAttachment(attachment);
omsTicketBill.setActualTicketTime(DateUtils.getNowDate());
omsTicketBill.setTicketStatus(OmsTicketBill.TicketStatusEnum.TICKET.getCode());
// omsTicketBill.setTicketStatus(OmsTicketBill.TicketStatusEnum.TICKET.getCode());
omsTicketBill.setApproveStatus(ApproveStatusEnum.WAIT_APPROVE.getCode());
omsTicketBill.setTicketPriceWithTax(bill.getTicketPriceWithTax());
omsTicketBill.setTicketPriceWithoutTax(bill.getTicketPriceWithoutTax());
omsTicketBill.setTicketType(bill.getTicketType());
updateOmsTicketBill(omsTicketBill);
//todo 开启审批流程
return AjaxResult.success(attachment);
}
@ -207,6 +215,7 @@ public class OmsTicketBillServiceImpl implements IOmsTicketBillService
refundBill.setTicketType(originalBill.getTicketType());
// 设置新属性
refundBill.setTicketBillType(OmsTicketBill.TicketBillTypeEnum.RED_RUSH.getCode());
refundBill.setTicketStatus(OmsTicketBill.TicketStatusEnum.TICKET.getCode());
refundBill.setRefundStatus(OmsTicketBill.RefundStatusEnum.REFUNDED.getCode());
refundBill.setApproveStatus(ApproveStatusEnum.WAIT_APPROVE.getCode());
refundBill.setOriginalBillId(originalBill.getId());
@ -244,7 +253,7 @@ public class OmsTicketBillServiceImpl implements IOmsTicketBillService
if (!OmsTicketBill.TicketBillTypeEnum.FROM_PAYABLE.getCode().equals(ticketBill.getTicketBillType())) {
return AjaxResult.error("只有由应付单合并生成的收票单才能执行退回操作");
}
List<OmsPayableTicketDetail> omsPayableTicketDetails = payableTicketDetailService.listDetailByTicketCode(ticketBill.getTicketBillCode());
// 3. 清楚关联
payableTicketDetailService.clearRelationPayable(ticketBill.getTicketBillCode());
@ -254,7 +263,9 @@ public class OmsTicketBillServiceImpl implements IOmsTicketBillService
if (result <= 0) {
throw new RuntimeException("删除付款单失败");
}
if (CollUtil.isNotEmpty(omsPayableTicketDetails)) {
payableBillService.updateTicketAmount(omsPayableTicketDetails.stream().map(OmsPayableTicketDetail::getPayableBillId).distinct().collect(Collectors.toList()));
}
return AjaxResult.success("付款单退回成功!");
} catch (Exception e) {