1、新增结算模块

2、调整业务模块
3、调整客商模块
This commit is contained in:
2026-08-20 06:33:01 +08:00
parent 4199255186
commit fd5b1484e6
59 changed files with 11745 additions and 1104 deletions
@@ -0,0 +1,764 @@
<template>
<el-dialog v-model="visible" :title="title" width="96%" top="2vh" append-to-body destroy-on-close>
<div v-loading="loading" class="formal-editor">
<section class="formal-editor__section">
<div class="dialog-section-title">结算基本信息</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="auto"
>
<el-row :gutter="24">
<el-col v-for="field in fields" :key="field.prop" :span="8">
<el-form-item :label="field.label" :prop="field.prop">
<el-date-picker
v-if="field.type === 'date' && editable"
v-model="form[field.prop]"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
<el-select
v-else-if="field.type === 'contract' && editable"
v-model="form.contractId"
filterable
remote
:remote-method="loadContracts"
placeholder="请选择合同"
@change="handleContractChange"
>
<el-option
v-for="item in contracts"
:key="item.id"
:label="`${item.contractNo} / ${item.contractName}`"
:value="item.id"
/>
</el-select>
<el-input-number
v-else-if="field.type === 'number' && editable"
v-model="form[field.prop]"
:min="0.000001"
:precision="6"
:controls="false"
/>
<span v-else-if="field.money">{{ formatMoney(form[field.prop]) }}</span>
<span v-else-if="field.type === 'contract'">{{
displayValue(form.contractName)
}}</span>
<span v-else>{{ displayValue(form[field.prop]) }}</span>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input
v-if="editable"
v-model="form.remark"
type="textarea"
maxlength="200"
show-word-limit
/>
<span v-else>{{ displayValue(form.remark) }}</span>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section>
<section class="formal-editor__section">
<div class="formal-editor__section-head">
<div class="dialog-section-title">来源预结算单</div>
<div class="formal-editor__actions">
<el-button v-if="editable" type="primary" plain @click="openCandidateDialog"
>选择预结算单</el-button
>
<el-button v-if="editable" type="primary" plain @click="openDetailDialog"
>选择结算明细</el-button
>
</div>
</div>
<el-table :data="sources" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in sourceTableColumns"
:key="column.prop"
v-bind="column"
align="center"
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="90" align="center">
<template #default="{ $index }"
><el-link type="danger" @click="removeSource($index)">删除</el-link></template
>
</el-table-column>
</el-table>
</section>
<section v-if="details.length" class="formal-editor__section">
<div class="dialog-section-title">结算明细</div>
<el-table :data="details" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in detailTableColumns"
:key="column.prop"
v-bind="column"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="150" fixed="right" align="center">
<template #default="{ row, $index }">
<div class="formal-editor__links">
<el-link v-if="row.formalSettlementId" type="primary" @click="openAdjustDialog(row)"
>调整</el-link
>
<el-link
v-if="!row.sourcePreSettlementId"
type="danger"
@click="removeDetail($index)"
>删除</el-link
>
</div>
</template>
</el-table-column>
</el-table>
</section>
<section class="formal-editor__section">
<div class="dialog-section-title">附件</div>
<vehicle-attachment-upload
v-model="attachments"
:readonly="!editable"
:multiple="true"
:limit="20"
:max-size="500"
:file-types="attachmentFileTypes"
/>
</section>
<section v-if="readonly && payments.length" class="formal-editor__section">
<div class="dialog-section-title">付款信息</div>
<el-table :data="payments" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in paymentTableColumns"
:key="column.prop"
v-bind="column"
align="center"
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else-if="column.prop === 'paymentTypeName'">{{
row.paymentType === 'final' ? '尾款' : displayValue(row.paymentType)
}}</span>
<span v-else-if="column.prop === 'billStatusName'">{{
row.billStatus === 'reviewing' ? '审批中' : displayValue(row.billStatus)
}}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
</el-table>
</section>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button v-if="editable" type="primary" :loading="saving" @click="handleSave"
>提交</el-button
>
</template>
<el-dialog v-model="candidate.visible" title="选择预结算单" width="88%" append-to-body>
<el-form :model="candidate.query" inline label-position="right" label-width="160px">
<el-form-item label="预结算单号"
><el-input v-model="candidate.query.preSettlementNo" clearable
/></el-form-item>
<el-form-item label="合同编号"
><el-input v-model="candidate.query.contractNo" clearable
/></el-form-item>
<el-form-item
><el-button @click="resetCandidates">重置</el-button
><el-button type="primary" @click="loadCandidates">查询</el-button></el-form-item
>
</el-form>
<el-table
ref="candidateTable"
v-loading="candidate.loading"
:data="candidate.rows"
border
@selection-change="candidate.selected = $event"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in candidateTableColumns"
:key="column.prop"
v-bind="column"
align="center"
>
<template #default="{ row }"
><span v-if="column.money">{{ formatMoney(row[column.prop]) }}</span
><span v-else>{{ displayValue(row[column.prop]) }}</span></template
>
</el-table-column>
</el-table>
<div class="formal-editor__pagination">
<el-pagination
v-model:current-page="candidate.page.current"
v-model:page-size="candidate.page.size"
:total="candidate.page.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadCandidates"
@size-change="loadCandidates"
/>
</div>
<template #footer
><el-button @click="candidate.visible = false">取消</el-button
><el-button type="primary" @click="confirmCandidates">确定</el-button></template
>
</el-dialog>
<el-dialog v-model="detailCandidate.visible" title="选择结算明细" width="92%" append-to-body>
<el-form :model="detailCandidate.query" inline label-position="right" label-width="160px">
<el-form-item label="批次号"
><el-input v-model="detailCandidate.query.batchNo" clearable
/></el-form-item>
<el-form-item label="费用日期"
><el-date-picker
v-model="detailCandidate.query.feeDateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
/></el-form-item>
<el-form-item
><el-button @click="resetDetailCandidates">重置</el-button
><el-button type="primary" @click="loadDetailCandidates">查询</el-button></el-form-item
>
</el-form>
<el-table
v-loading="detailCandidate.loading"
:data="detailCandidate.rows"
border
@selection-change="detailCandidate.selected = $event"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in candidateDetailTableColumns"
:key="column.prop"
v-bind="column"
align="center"
>
<template #default="{ row }"
><span v-if="column.money">{{ formatMoney(row[column.prop]) }}</span
><span v-else>{{ displayValue(row[column.prop]) }}</span></template
>
</el-table-column>
</el-table>
<div class="formal-editor__pagination">
<el-pagination
v-model:current-page="detailCandidate.page.current"
v-model:page-size="detailCandidate.page.size"
:total="detailCandidate.page.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadDetailCandidates"
@size-change="loadDetailCandidates"
/>
</div>
<template #footer
><el-button @click="detailCandidate.visible = false">取消</el-button
><el-button type="primary" @click="confirmDetailCandidates">确定</el-button></template
>
</el-dialog>
<el-dialog v-model="adjust.visible" title="结算明细调整" width="92%" append-to-body>
<el-table v-loading="adjust.loading" :data="adjust.rows" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="130" align="center" />
<el-table-column prop="cargoType" label="货物类型" min-width="130" align="center" />
<el-table-column label="运输总量" min-width="130" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.transportQuantity"
:min="0"
:precision="6"
:controls="false" /></template
></el-table-column>
<el-table-column label="里程(KM" min-width="130" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.mileage"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="运输单价" min-width="130" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.unitPrice"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="运费" min-width="130" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.freightAmount"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="结算金额(含税)" min-width="165" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.settlementAmountTax"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="结算金额(不含税)" min-width="180" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.settlementAmountNoTax"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="备注" min-width="180" align="center"
><template #default="{ row }"><el-input v-model="row.remark" maxlength="200" /></template
></el-table-column>
</el-table>
<el-form label-position="right" label-width="auto" class="formal-editor__adjust-reason">
<el-form-item label="调整原因" required
><el-input v-model="adjust.reason" type="textarea" maxlength="200" show-word-limit
/></el-form-item>
</el-form>
<template #footer
><el-button @click="adjust.visible = false">取消</el-button
><el-button type="primary" :loading="adjust.saving" @click="saveAdjustment"
>保存</el-button
></template
>
</el-dialog>
</el-dialog>
</template>
<script>
import {
adjustDetail,
getCandidateDetails,
getCandidates,
getContractOptions,
getDetail,
getDetailFees,
save,
} from '@/api/settlement/formalSettlement';
import {
createFormalSettlementForm,
formalSettlementFormFields,
} from '@/option/settlement/formalSettlementForm';
import {
candidateColumns,
candidateDetailColumns,
detailColumns,
paymentColumns,
sourceColumns,
} from '@/option/settlement/formalSettlementTable';
export default {
name: 'FormalSettlementEditor',
props: { modelValue: Boolean, recordId: [String, Number], readonly: Boolean },
emits: ['update:modelValue', 'success'],
data() {
return {
loading: false,
saving: false,
form: createFormalSettlementForm(),
sources: [],
details: [],
payments: [],
attachments: [],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
'rar',
],
contracts: [],
fields: formalSettlementFormFields,
sourceTableColumns: sourceColumns,
detailTableColumns: detailColumns,
paymentTableColumns: paymentColumns,
candidateTableColumns: candidateColumns,
candidateDetailTableColumns: candidateDetailColumns,
rules: {
contractId: [{ required: true, message: '请选择合同', trigger: 'change' }],
exchangeRateDate: [{ required: true, message: '请选择汇率日期', trigger: 'change' }],
exchangeRate: [{ required: true, message: '请输入结算汇率', trigger: 'blur' }],
},
candidate: {
visible: false,
loading: false,
query: {},
rows: [],
selected: [],
page: { current: 1, size: 10, total: 0 },
},
detailCandidate: {
visible: false,
loading: false,
query: {},
rows: [],
selected: [],
page: { current: 1, size: 10, total: 0 },
},
adjust: {
visible: false,
loading: false,
saving: false,
detailId: null,
reason: '',
rows: [],
},
};
},
computed: {
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
editable() {
return !this.readonly;
},
title() {
return this.readonly ? '查看正式结算单' : this.recordId ? '编辑正式结算单' : '新增正式结算单';
},
},
watch: {
modelValue(value) {
if (value) this.initialize();
},
},
methods: {
async initialize() {
this.form = createFormalSettlementForm();
this.sources = [];
this.details = [];
this.payments = [];
this.attachments = [];
await this.loadContracts();
if (!this.recordId) {
this.form.exchangeRateDate = this.$dayjs().format('YYYY-MM-DD');
return;
}
this.loading = true;
try {
const response = await getDetail(this.recordId);
const data = this.unwrapData(response);
this.form = {
...createFormalSettlementForm(),
...data,
sourcePreSettlementIds: (data.sources || []).map(item => item.preSettlementId),
sourceDetailIds: (data.details || [])
.filter(item => !item.sourcePreSettlementId)
.map(item => item.sourceDetailId),
};
this.sources = data.sources || [];
this.details = data.details || [];
this.payments = data.payments || [];
this.attachments = this.parseAttachments(data.attachmentsJson);
} finally {
this.loading = false;
}
},
async loadContracts(keyword = '') {
const response = await getContractOptions(keyword);
this.contracts = this.unwrapData(response) || [];
},
handleContractChange(id) {
const contract = this.contracts.find(item => String(item.id) === String(id));
if (!contract) return;
Object.assign(this.form, contract, {
contractId: contract.id,
settlementTypeName: contract.settlementType === 'receivable' ? '应收' : '应付',
});
this.sources = [];
this.details = [];
this.form.sourcePreSettlementIds = [];
this.form.sourceDetailIds = [];
},
openCandidateDialog() {
this.candidate.visible = true;
this.candidate.page.current = 1;
this.loadCandidates();
},
async loadCandidates() {
this.candidate.loading = true;
try {
const response = await getCandidates(
this.candidate.page.current,
this.candidate.page.size,
{
...this.candidate.query,
contractId: this.form.contractId || undefined,
}
);
const data = this.unwrapData(response);
this.candidate.rows = data.records || [];
this.candidate.page.total = data.total || 0;
} finally {
this.candidate.loading = false;
}
},
resetCandidates() {
this.candidate.query = {};
this.candidate.page.current = 1;
this.loadCandidates();
},
confirmCandidates() {
const merged = [...this.sources, ...this.candidate.selected];
const map = new Map(
merged.map(item => [
String(item.preSettlementId || item.id),
{ ...item, preSettlementId: item.preSettlementId || item.id },
])
);
this.sources = [...map.values()];
this.form.sourcePreSettlementIds = this.sources.map(item => item.preSettlementId);
if (this.sources.length) {
Object.assign(this.form, this.sources[0], {
contractId: this.sources[0].contractId,
formalSettlementNo: this.form.formalSettlementNo,
});
}
this.form.settlementAmount = this.sources.reduce(
(sum, item) => sum + Number(item.settlementAmount || 0),
0
);
this.form.localSettlementAmount =
this.form.settlementAmount * Number(this.form.exchangeRate || 1);
this.candidate.visible = false;
},
removeSource(index) {
this.sources.splice(index, 1);
this.form.sourcePreSettlementIds = this.sources.map(item => item.preSettlementId || item.id);
},
openDetailDialog() {
if (!this.form.contractId) return this.$message.warning('请先选择合同');
this.detailCandidate.visible = true;
this.detailCandidate.page.current = 1;
this.loadDetailCandidates();
},
async loadDetailCandidates() {
this.detailCandidate.loading = true;
try {
const range = this.detailCandidate.query.feeDateRange || [];
const params = {
contractId: this.form.contractId,
settlementType: this.form.settlementType,
batchNo: this.detailCandidate.query.batchNo,
feeStartDate: range[0],
feeEndDate: range[1],
};
const response = await getCandidateDetails(
this.detailCandidate.page.current,
this.detailCandidate.page.size,
params
);
const data = this.unwrapData(response);
this.detailCandidate.rows = data.records || [];
this.detailCandidate.page.total = data.total || 0;
} finally {
this.detailCandidate.loading = false;
}
},
resetDetailCandidates() {
this.detailCandidate.query = {};
this.detailCandidate.page.current = 1;
this.loadDetailCandidates();
},
confirmDetailCandidates() {
const existing = new Map(
this.details
.filter(item => !item.formalSettlementId)
.map(item => [String(item.sourceDetailId || item.id), item])
);
this.detailCandidate.selected.forEach(item =>
existing.set(String(item.id), {
...item,
sourceDetailId: item.id,
settlementAmountTax: item.totalAmount,
})
);
this.details = [
...this.details.filter(item => item.formalSettlementId),
...existing.values(),
];
this.form.sourceDetailIds = [...existing.values()].map(item => item.sourceDetailId);
this.form.settlementAmount =
this.sources.reduce((sum, item) => sum + Number(item.settlementAmount || 0), 0) +
[...existing.values()].reduce((sum, item) => sum + Number(item.totalAmount || 0), 0);
this.detailCandidate.visible = false;
},
removeDetail(index) {
this.details.splice(index, 1);
this.form.sourceDetailIds = this.details
.filter(item => !item.sourcePreSettlementId)
.map(item => item.sourceDetailId);
},
async openAdjustDialog(row) {
this.adjust = {
visible: true,
loading: true,
saving: false,
detailId: row.id,
reason: '',
rows: [],
};
try {
const response = await getDetailFees(row.id);
const data = this.unwrapData(response);
this.adjust.rows = (data || []).map(item => ({
...item,
feeItems: this.parseFeeItems(item.feeItemsJson),
}));
} finally {
this.adjust.loading = false;
}
},
async saveAdjustment() {
if (!this.adjust.reason.trim()) return this.$message.warning('请输入调整原因');
this.adjust.saving = true;
try {
await adjustDetail({
detailId: this.adjust.detailId,
changeReason: this.adjust.reason,
rows: this.adjust.rows,
});
this.$message.success('调整保存成功');
this.adjust.visible = false;
await this.initialize();
} finally {
this.adjust.saving = false;
}
},
async handleSave() {
await this.$refs.formRef.validate();
if (!this.form.sourcePreSettlementIds.length && !this.form.sourceDetailIds.length) {
return this.$message.warning('请选择预结算单或结算明细');
}
this.saving = true;
try {
await save({
id: this.form.id,
contractId: this.form.contractId,
settlementType: this.form.settlementType,
sourcePreSettlementIds: this.form.sourcePreSettlementIds,
sourceDetailIds: this.form.sourceDetailIds,
exchangeRateDate: this.form.exchangeRateDate,
exchangeRate: this.form.exchangeRate,
attachmentsJson: JSON.stringify(this.attachments || []),
remark: this.form.remark,
});
this.$message.success('保存成功');
this.visible = false;
this.$emit('success');
} finally {
this.saving = false;
}
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
},
formatMoney(value) {
return `${Number(value || 0).toFixed(2)} RMB`;
},
parseFeeItems(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
return JSON.parse(value);
} catch {
return {};
}
},
parseAttachments(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
},
},
};
</script>
<style scoped lang="scss">
.formal-editor__section {
margin-bottom: 24px;
}
.formal-editor__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.formal-editor__actions {
display: flex;
gap: 8px;
}
.formal-editor__links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.formal-editor :deep(.el-form-item) {
margin-bottom: 16px;
}
.formal-editor :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
.formal-editor :deep(.el-table__body tr:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
.formal-editor__pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.formal-editor__adjust-reason {
margin-top: 16px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,396 @@
<template>
<el-dialog v-model="visible" :title="title" width="94%" top="3vh" append-to-body destroy-on-close>
<div v-loading="loading" class="settlement-adjustment-editor">
<section class="settlement-adjustment-editor__section">
<div class="dialog-section-title">基本信息</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="auto"
>
<el-row :gutter="24">
<el-col v-for="field in fields" :key="field.prop" :span="8"
><el-form-item :label="field.label" :prop="field.prop"
><el-select
v-if="field.prop === 'formalSettlementId' && editable"
v-model="form.formalSettlementId"
filterable
remote
clearable
:remote-method="loadFormalSettlements"
placeholder="请选择正式结算单"
@change="handleFormalChange"
><el-option
v-for="item in candidates"
:key="item.id"
:label="`${item.formalSettlementNo} / ${item.projectName || ''}`"
:value="item.id" /></el-select
><el-input
v-else-if="field.prop === 'adjustmentNo'"
v-model="form[field.prop]"
disabled
/><el-input-number
v-else-if="field.money && editable"
v-model="form[field.prop]"
:precision="2"
:controls="false"
disabled
/><span v-else>{{
displayValue(
field.prop === 'settlementTypeName' ? settlementTypeName : form[field.prop]
)
}}</span></el-form-item
></el-col
>
<el-col :span="24"
><el-form-item label="备注" prop="remark"
><el-input
v-if="editable"
v-model="form.remark"
type="textarea"
maxlength="200"
show-word-limit
/><span v-else>{{ displayValue(form.remark) }}</span></el-form-item
></el-col
>
</el-row>
</el-form>
</section>
<section class="settlement-adjustment-editor__section">
<div class="settlement-adjustment-editor__section-head">
<div class="dialog-section-title">调整费用</div>
<el-button
v-if="editable && form.formalSettlementId"
type="primary"
plain
@click="openFeeDialog"
>添加费用</el-button
>
</div>
<el-table :data="details" border>
<el-table-column type="index" label="序号" width="64" align="center" /><el-table-column
prop="feeType"
label="费用类型"
min-width="150"
align="center"
/><el-table-column
prop="feeItem"
label="费用项目"
min-width="180"
align="center"
show-overflow-tooltip
/><el-table-column
prop="originalAmountTax"
label="原金额(含税)"
min-width="145"
align="center"
><template #default="{ row }">{{
formatMoney(row.originalAmountTax)
}}</template></el-table-column
>
<el-table-column label="调整金额(含税)" min-width="160" align="center"
><template #default="{ row }"
><el-input-number
v-if="editable"
v-model="row.adjustmentAmountTax"
:precision="2"
:controls="false"
@change="recalculate"
/><span v-else :class="{ negative: Number(row.adjustmentAmountTax) < 0 }">{{
formatMoney(row.adjustmentAmountTax)
}}</span></template
></el-table-column
>
<el-table-column label="调整金额(不含税)" min-width="170" align="center"
><template #default="{ row }"
><el-input-number
v-if="editable"
v-model="row.adjustmentAmountNoTax"
:precision="2"
:controls="false"
/><span v-else>{{ formatMoney(row.adjustmentAmountNoTax) }}</span></template
></el-table-column
>
<el-table-column label="备注" min-width="180" align="center"
><template #default="{ row }"
><el-input v-if="editable" v-model="row.remark" maxlength="200" /><span v-else>{{
displayValue(row.remark)
}}</span></template
></el-table-column
>
<el-table-column v-if="editable" label="操作" width="90" align="center"
><template #default="{ $index }"
><el-link
type="danger"
@click="
details.splice($index, 1);
recalculate();
"
>删除</el-link
></template
></el-table-column
>
<template #empty><el-empty description="请选择调整费用明细" /></template>
</el-table>
<div class="settlement-adjustment-editor__summary">
调整金额合计<strong :class="{ negative: Number(form.adjustmentAmount) < 0 }">{{
formatMoney(form.adjustmentAmount)
}}</strong
>调整后结算金额<strong>{{ formatMoney(form.adjustedSettlementAmount) }}</strong>
</div>
</section>
</div>
<template #footer
><el-button @click="visible = false">取消</el-button
><el-button v-if="editable" type="primary" :loading="saving" @click="handleSave"
>保存</el-button
></template
>
<el-dialog v-model="feeDialog.visible" title="选择调整费用" width="88%" append-to-body>
<el-table
ref="feeTable"
:data="feeRows"
border
@selection-change="feeDialog.selected = $event"
><el-table-column type="selection" width="52" align="center" /><el-table-column
type="index"
label="序号"
width="64"
align="center"
/><el-table-column
prop="documentNo"
label="单据号"
min-width="150"
align="center"
/><el-table-column
prop="feeType"
label="费用类型"
min-width="140"
align="center"
/><el-table-column
prop="feeItem"
label="费用项目"
min-width="180"
align="center"
show-overflow-tooltip
/><el-table-column
prop="originalAmountTax"
label="原金额(含税)"
min-width="145"
align="center"
><template #default="{ row }">{{
formatMoney(row.originalAmountTax)
}}</template></el-table-column
></el-table
>
<template #footer
><el-button @click="feeDialog.visible = false">取消</el-button
><el-button type="primary" @click="confirmFees">确定</el-button></template
>
</el-dialog>
</el-dialog>
</template>
<script>
import * as api from '@/api/settlement/settlementAdjustment';
import {
createSettlementAdjustmentForm,
settlementAdjustmentFormFields,
} from '@/option/settlement/settlementAdjustmentForm';
export default {
name: 'SettlementAdjustmentEditor',
props: { modelValue: Boolean, recordId: [String, Number], readonly: Boolean },
emits: ['update:modelValue', 'success'],
data: () => ({
loading: false,
saving: false,
form: createSettlementAdjustmentForm(),
fields: settlementAdjustmentFormFields,
details: [],
candidates: [],
feeRows: [],
rules: {
formalSettlementId: [{ required: true, message: '请选择关联正式结算单', trigger: 'change' }],
},
feeDialog: { visible: false, selected: [] },
}),
computed: {
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
editable() {
return !this.readonly;
},
title() {
return this.readonly ? '查看结算调整单' : this.recordId ? '编辑结算调整单' : '新增结算调整单';
},
settlementTypeName() {
return this.form.settlementType === 'receivable'
? '应收'
: this.form.settlementType === 'payable'
? '应付'
: '';
},
},
watch: {
modelValue(value) {
if (value) this.initialize();
},
},
methods: {
async initialize() {
this.form = createSettlementAdjustmentForm();
this.details = [];
this.candidates = [];
this.feeRows = [];
if (!this.recordId) {
await this.loadFormalSettlements('');
return;
}
this.loading = true;
try {
const { data } = await api.getDetail(this.recordId);
this.form = { ...createSettlementAdjustmentForm(), ...data };
this.details = (data.details || []).map(item => ({
...item,
adjustmentAmountTax: Number(item.adjustmentAmountTax || 0),
adjustmentAmountNoTax:
item.adjustmentAmountNoTax == null ? null : Number(item.adjustmentAmountNoTax),
}));
await this.loadFormalSettlements(this.form.formalSettlementNo || '');
} finally {
this.loading = false;
}
},
async loadFormalSettlements(keyword = '') {
const { data } = await api.getFormalSettlements(keyword);
this.candidates = data?.data || data || [];
},
async handleFormalChange(id) {
const item = this.candidates.find(row => String(row.id) === String(id));
if (!item) return;
Object.assign(this.form, item, {
formalSettlementId: id,
formalSettlementNo: item.formalSettlementNo,
originalSettlementAmount: Number(item.settlementAmount || 0),
settlementTypeName: item.settlementTypeName,
});
this.details = [];
this.recalculate();
},
async openFeeDialog() {
const { data } = await api.getFormalDetails(this.form.formalSettlementId);
const used = new Set(this.details.map(item => String(item.formalSettlementDetailFeeId)));
this.feeRows = (data?.data || data || []).filter(
item => !used.has(String(item.formalSettlementDetailFeeId))
);
this.feeDialog = { visible: true, selected: [] };
},
confirmFees() {
this.feeDialog.selected.forEach(item =>
this.details.push({
...item,
adjustmentAmountTax: 0,
adjustmentAmountNoTax: null,
remark: '',
})
);
this.recalculate();
this.feeDialog.visible = false;
},
recalculate() {
const total = this.details.reduce(
(sum, row) => sum + Number(row.adjustmentAmountTax || 0),
0
);
this.form.adjustmentAmount = Number(total.toFixed(2));
this.form.adjustedSettlementAmount = Number(
(Number(this.form.originalSettlementAmount || 0) + total).toFixed(2)
);
},
async handleSave() {
await this.$refs.formRef.validate();
if (!this.details.length) return this.$message.warning('请至少添加一条调整费用');
this.saving = true;
try {
await api.save({
id: this.form.id,
formalSettlementId: this.form.formalSettlementId,
remark: this.form.remark,
details: this.details.map(item => ({
formalSettlementDetailId: item.formalSettlementDetailId,
formalSettlementDetailFeeId: item.formalSettlementDetailFeeId,
feeType: item.feeType,
feeItem: item.feeItem,
adjustmentAmountTax: item.adjustmentAmountTax,
adjustmentAmountNoTax: item.adjustmentAmountNoTax,
remark: item.remark,
})),
});
this.$message.success('保存成功');
this.visible = false;
this.$emit('success');
} finally {
this.saving = false;
}
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
formatMoney(value) {
return Number(value || 0).toFixed(2);
},
},
};
</script>
<style scoped lang="scss">
.settlement-adjustment-editor__section {
margin-bottom: 24px;
}
.settlement-adjustment-editor__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.settlement-adjustment-editor__summary {
padding: 12px 0;
text-align: right;
}
.settlement-adjustment-editor :deep(.el-form-item) {
margin-bottom: 16px;
}
.settlement-adjustment-editor :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
.settlement-adjustment-editor :deep(.el-table__body tr:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
.negative {
color: #f56c6c;
}
.dialog-section-title {
display: flex;
align-items: center;
min-height: 22px;
font-size: 16px;
font-weight: 600;
}
.dialog-section-title::before {
width: 4px;
height: 18px;
margin-right: 8px;
background: #409eff;
content: '';
}
</style>
@@ -0,0 +1,832 @@
<template>
<el-dialog v-model="visible" :title="title" width="98%" top="2vh" append-to-body destroy-on-close>
<div v-loading="loading" class="reconciliation-editor">
<section class="reconciliation-editor__section">
<div class="dialog-section-title">导入外部账单与内部账单核对</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="auto"
>
<el-row :gutter="24">
<el-col v-for="field in formFields" :key="field.prop" :span="8">
<el-form-item :label="field.label" :prop="field.prop">
<el-input
v-if="field.prop === 'formalSettlementNo' && editable"
v-model="form.formalSettlementNo"
readonly
placeholder="请选择正式结算单"
@click="openFormalDialog"
>
<template #append><el-button @click="openFormalDialog">选择</el-button></template>
</el-input>
<el-select
v-else-if="field.prop === 'reconciliationMode' && editable"
v-model="form.reconciliationMode"
>
<el-option label="整车总额对账" value="vehicle" />
<el-option label="货物明细对账" value="cargo" />
</el-select>
<el-date-picker
v-else-if="field.type === 'date' && editable"
v-model="form.reconciliationDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
<el-input-number
v-else-if="field.money"
v-model="form[field.prop]"
:controls="false"
:precision="2"
disabled
/>
<span v-else>{{ displayValue(form[field.prop]) }}</span>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input
v-if="editable"
v-model="form.remark"
type="textarea"
maxlength="200"
show-word-limit
placeholder="请输入备注"
/>
<span v-else>{{ displayValue(form.remark) }}</span>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section>
<section class="reconciliation-editor__section">
<div class="dialog-section-title">内部账单</div>
<div class="reconciliation-editor__stats">
<div class="stat-card">
<b>{{ form.internalBillCount || 0 }}</b
><span>我方账单数</span
><small
>对方 {{ form.externalBillCount || 0 }} / 差异
{{ form.differenceCount || 0 }} </small
>
</div>
<div class="stat-card">
<b>{{ formatNumber(form.internalQuantity) }}</b
><span>我方货量</span
><small
>对方 {{ formatNumber(form.externalQuantity) }} / 差异
{{ formatNumber(form.differenceQuantity) }}</small
>
</div>
<div class="stat-card">
<b>{{ formatMoney(form.internalAmount) }}</b
><span>我方结算金额</span
><small
>对方 {{ formatMoney(form.externalAmount) }} / 差异
{{ formatMoney(form.differenceAmount) }}</small
>
</div>
<div class="stat-card stat-card--success">
<b>{{ form.matchedCount || 0 }}</b
><span>成功匹配</span>
</div>
<div class="stat-card stat-card--danger">
<b>{{ form.unmatchedCount || 0 }}</b
><span>无法匹配</span>
</div>
</div>
<div class="reconciliation-editor__filter">
<el-input v-model="internalQuery.documentNo" placeholder="单据号" clearable />
<el-input v-model="internalQuery.vehicleNo" placeholder="车号" clearable />
<el-input v-model="internalQuery.batchNo" placeholder="批次号" clearable />
<el-input v-model="internalQuery.cargoName" placeholder="货物名称" clearable />
<el-button @click="resetInternalQuery">重置</el-button>
<el-button type="primary" @click="internalFilterTick++">查询</el-button>
</div>
<el-table :data="filteredInternalRows" border max-height="420">
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in internalColumns"
:key="column.prop"
v-bind="column"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-tag v-if="column.prop === 'matchResult'" :type="matchTagType(row.matchResult)">{{
matchName(row.matchResult)
}}</el-tag>
<el-tag
v-else-if="column.prop === 'updateResult'"
:type="updateTagType(row.updateResult)"
>{{ updateName(row.updateResult) }}</el-tag
>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<div class="reconciliation-editor__links">
<el-link v-if="editable" type="primary" @click="openAdjust(row)">调整</el-link>
<el-link
v-if="editable && row.matchResult === 'matched'"
type="primary"
@click="handleUnmatch(row)"
>取消匹配</el-link
>
<el-link
v-if="editable && row.matchResult !== 'matched'"
type="primary"
@click="openManualMatch(row)"
>人工匹配</el-link
>
</div>
</template>
</el-table-column>
</el-table>
</section>
<section class="reconciliation-editor__section">
<div class="reconciliation-editor__section-head">
<div class="dialog-section-title">导入外部账单</div>
<div class="reconciliation-editor__actions" v-if="editable">
<el-button type="primary" plain @click="downloadTemplate('vehicle')"
>下载整车对账模板</el-button
>
<el-button type="primary" plain @click="downloadTemplate('cargo')"
>下载货物明细对账模板</el-button
>
<el-button type="primary" plain @click="chooseImport">导入</el-button>
<el-button type="primary" @click="handleMatch">开始匹配内部账单</el-button>
<input
ref="fileInput"
type="file"
accept=".xls,.xlsx"
class="hidden-file"
@change="handleImport"
/>
</div>
</div>
<el-tabs v-model="externalTab">
<el-tab-pane :label="`导入明细(${externalDetails.length}`" name="all" />
<el-tab-pane :label="`疑似重复(${duplicateRows.length}`" name="duplicate" />
</el-tabs>
<el-table :data="visibleExternalRows" border max-height="420">
<el-table-column
v-for="column in externalColumns"
:key="column.prop"
v-bind="column"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-tag v-if="column.prop === 'matchStatus'" :type="matchTagType(row.matchStatus)">{{
matchName(row.matchStatus)
}}</el-tag>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }"
><el-link
v-if="editable && row.matchStatus !== 'matched'"
type="primary"
@click="openManualMatchByExternal(row)"
>匹配</el-link
></template
>
</el-table-column>
</el-table>
</section>
<section class="reconciliation-editor__section">
<el-collapse>
<el-collapse-item title="操作说明" name="help">
<div class="reconciliation-editor__help">
<p>1. 新增对账单时选择一张已审批通过的正式结算单系统自动带出内部账单明细</p>
<p>
2. 导入对方 Excel
账单后点击开始匹配内部账单系统按车号货物地址批次发货时间运输量和金额匹配
</p>
<p>3. 外部账单存在重复候选时会标记为疑似重复需要人工选择唯一明细匹配</p>
<p>4. 只有所有内外部明细一一匹配且差异为 0才允许按匹配结果更新账单或完成对账</p>
<p>
5.
已付金额大于外部匹配金额时禁止更新整车模式一车多货会跳过自动更新可通过调整逐货物修改
</p>
<p>6. 按匹配结果更新成功后生成变更记录完成对账后单据不可再编辑和删除</p>
</div>
</el-collapse-item>
</el-collapse>
</section>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<template v-if="editable">
<el-button type="primary" plain :loading="saving" @click="handleSave">保存草稿</el-button>
<el-button type="primary" plain :loading="actionLoading" @click="handleUpdate"
>按匹配结果更新账单</el-button
>
<el-button type="primary" :loading="actionLoading" @click="handleComplete"
>完成对账</el-button
>
</template>
</template>
<el-dialog v-model="formalDialog.visible" title="选择正式结算单" width="84%" append-to-body>
<el-form :model="formalDialog.query" inline label-position="right" label-width="160px">
<el-form-item label="正式结算单号"
><el-input v-model="formalDialog.query.keyword" clearable
/></el-form-item>
<el-form-item><el-button @click="loadFormalOptions">查询</el-button></el-form-item>
</el-form>
<el-table
v-loading="formalDialog.loading"
:data="formalDialog.rows"
border
@selection-change="formalDialog.selected = $event"
>
<el-table-column type="selection" width="52" :selectable="row => !row.isSelected" />
<el-table-column type="index" label="序号" width="64" />
<el-table-column prop="formalSettlementNo" label="正式结算单号" min-width="150" />
<el-table-column prop="contractNo" label="合同编号" min-width="130" />
<el-table-column prop="contractName" label="合同名称" min-width="160" />
<el-table-column prop="payerName" label="付款方" min-width="150" />
<el-table-column prop="payeeName" label="收款方" min-width="150" />
<el-table-column prop="settlementAmount" label="结算金额" min-width="120"
><template #default="{ row }">{{
formatMoney(row.settlementAmount, row.currency)
}}</template></el-table-column
>
</el-table>
<div class="reconciliation-editor__pagination">
<el-pagination
v-model:current-page="formalDialog.page.current"
v-model:page-size="formalDialog.page.size"
:total="formalDialog.page.total"
layout="total, prev, pager, next"
@current-change="loadFormalOptions"
/>
</div>
<template #footer
><el-button @click="formalDialog.visible = false">取消</el-button
><el-button type="primary" @click="confirmFormal">确定</el-button></template
>
</el-dialog>
<el-dialog v-model="adjustDialog.visible" title="结算明细调整" width="90%" append-to-body>
<el-table :data="adjustDialog.rows" border>
<el-table-column prop="cargoName" label="货物名称" min-width="140" />
<el-table-column prop="cargoType" label="货物类型" min-width="120" />
<el-table-column prop="transportQuantity" label="运输量" min-width="120"
><template #default="{ row }"
><el-input-number
v-model="row.transportQuantity"
:min="0"
:controls="false" /></template
></el-table-column>
<el-table-column prop="unitPrice" label="运输单价" min-width="120"
><template #default="{ row }"
><el-input-number v-model="row.unitPrice" :min="0" :controls="false" /></template
></el-table-column>
<el-table-column prop="freightAmount" label="运输费" min-width="120"
><template #default="{ row }"
><el-input-number v-model="row.freightAmount" :min="0" :controls="false" /></template
></el-table-column>
<el-table-column prop="settlementAmount" label="结算金额" min-width="140"
><template #default="{ row }"
><el-input-number v-model="row.settlementAmount" :min="0" :controls="false" /></template
></el-table-column>
</el-table>
<el-form class="dialog-form" label-position="right" label-width="auto"
><el-form-item label="调整原因" required
><el-input
v-model="adjustDialog.reason"
type="textarea"
maxlength="200"
show-word-limit /></el-form-item
></el-form>
<template #footer
><el-button @click="adjustDialog.visible = false">取消</el-button
><el-button type="primary" :loading="adjustDialog.saving" @click="saveAdjust"
>保存</el-button
></template
>
</el-dialog>
<el-dialog v-model="manualDialog.visible" title="选择外部账单明细" width="86%" append-to-body>
<el-table :data="unmatchedExternalRows" border @row-click="manualDialog.selected = $event">
<el-table-column type="index" label="序号" width="64" />
<el-table-column prop="externalLineNo" label="外部行号" width="90" />
<el-table-column prop="vehicleNo" label="车牌号" width="110" />
<el-table-column prop="cargoName" label="货物名称" min-width="140" />
<el-table-column prop="transportQuantity" label="运输量" width="110" />
<el-table-column prop="settlementAmount" label="结算金额" width="130"
><template #default="{ row }">{{
formatMoney(row.settlementAmount)
}}</template></el-table-column
>
<el-table-column label="选择" width="80"
><template #default="{ row }"
><el-radio v-model="manualDialog.selected" :label="row">&nbsp;</el-radio></template
></el-table-column
>
</el-table>
<template #footer
><el-button @click="manualDialog.visible = false">取消</el-button
><el-button type="primary" @click="confirmManualMatch">确定</el-button></template
>
</el-dialog>
</el-dialog>
</template>
<script>
import * as api from '@/api/settlement/transportReconciliation';
import { downloadXls } from '@/utils/util';
import { transportReconciliationFormFields } from '@/option/settlement/transportReconciliationForm';
import {
internalColumns,
externalCargoColumns,
externalVehicleColumns,
} from '@/option/settlement/transportReconciliationTable';
export default {
name: 'TransportReconciliationEditor',
props: {
modelValue: Boolean,
recordId: [String, Number],
settlementType: { type: String, default: 'payable' },
readonly: Boolean,
},
emits: ['update:modelValue', 'success'],
data() {
return {
loading: false,
saving: false,
actionLoading: false,
currentId: null,
form: this.emptyForm(),
formFields: transportReconciliationFormFields,
internalColumns,
internalDetails: [],
externalDetails: [],
internalQuery: { documentNo: '', vehicleNo: '', batchNo: '', cargoName: '' },
internalFilterTick: 0,
externalTab: 'all',
formalDialog: {
visible: false,
loading: false,
rows: [],
selected: [],
query: { keyword: '' },
page: { current: 1, size: 10, total: 0 },
},
adjustDialog: { visible: false, saving: false, rows: [], reason: '' },
manualDialog: { visible: false, internal: null, selected: null },
rules: {
formalSettlementNo: [{ required: true, message: '请选择正式结算单', trigger: 'change' }],
reconciliationMode: [{ required: true, message: '请选择对账模式', trigger: 'change' }],
remark: [{ max: 200, message: '备注不能超过200个字', trigger: 'blur' }],
},
};
},
computed: {
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
editable() {
return !this.readonly;
},
title() {
return this.readonly
? '查看运输对账单'
: this.currentId
? '编辑运输对账单'
: '新增运输对账单';
},
filteredInternalRows() {
void this.internalFilterTick;
const query = this.internalQuery;
return this.internalDetails.filter(
row =>
(!query.documentNo || String(row.documentNo || '').includes(query.documentNo)) &&
(!query.vehicleNo || String(row.vehicleNo || '').includes(query.vehicleNo)) &&
(!query.batchNo || String(row.batchNo || '').includes(query.batchNo)) &&
(!query.cargoName || String(row.cargoName || '').includes(query.cargoName))
);
},
externalColumns() {
return this.form.reconciliationMode === 'cargo'
? externalCargoColumns
: externalVehicleColumns;
},
duplicateRows() {
return this.externalDetails.filter(
row => row.suspectedDuplicate || row.matchStatus === 'suspected_duplicate'
);
},
visibleExternalRows() {
return this.externalTab === 'duplicate' ? this.duplicateRows : this.externalDetails;
},
unmatchedExternalRows() {
return this.externalDetails.filter(
row => row.matchStatus !== 'matched' && !row.suspectedDuplicate
);
},
},
watch: {
modelValue(value) {
if (value) this.initialize();
},
},
methods: {
emptyForm() {
return {
reconciliationNo: '',
reconciliationMode: 'vehicle',
formalSettlementId: null,
formalSettlementNo: '',
payerName: '',
payeeName: '',
projectName: '',
deptName: '',
contractName: '',
paidAmount: 0,
reconcilerName: '',
reconciliationDate: '',
remark: '',
internalBillCount: 0,
externalBillCount: 0,
differenceCount: 0,
internalQuantity: 0,
externalQuantity: 0,
differenceQuantity: 0,
internalAmount: 0,
externalAmount: 0,
differenceAmount: 0,
matchedCount: 0,
unmatchedCount: 0,
};
},
async initialize() {
this.currentId = this.recordId;
this.form = this.emptyForm();
this.internalDetails = [];
this.externalDetails = [];
this.externalTab = 'all';
this.internalQuery = { documentNo: '', vehicleNo: '', batchNo: '', cargoName: '' };
if (!this.currentId) {
this.form.reconciliationMode = 'vehicle';
this.form.reconcilerName = '当前用户';
this.form.reconciliationDate = this.$dayjs().format('YYYY-MM-DD');
return;
}
this.loading = true;
try {
await this.loadDetail();
} finally {
this.loading = false;
}
},
async loadDetail() {
const { data } = await api.getDetail(this.currentId);
this.form = { ...this.emptyForm(), ...data };
this.internalDetails = data.internalDetails || [];
this.externalDetails = data.externalDetails || [];
},
openFormalDialog() {
if (!this.editable) return;
this.formalDialog.visible = true;
this.formalDialog.page.current = 1;
this.loadFormalOptions();
},
async loadFormalOptions() {
this.formalDialog.loading = true;
try {
const { data } = await api.getFormalOptions(
this.formalDialog.page.current,
this.formalDialog.page.size,
{ settlementType: this.settlementType, keyword: this.formalDialog.query.keyword }
);
this.formalDialog.rows = data.records || [];
this.formalDialog.page.total = data.total || 0;
} finally {
this.formalDialog.loading = false;
}
},
confirmFormal() {
if (this.formalDialog.selected.length !== 1)
return this.$message.warning('请选择一张正式结算单');
const selected = this.formalDialog.selected[0];
this.form = {
...this.form,
...selected,
formalSettlementId: selected.id,
formalSettlementNo: selected.formalSettlementNo,
reconciliationNo: this.form.reconciliationNo,
};
this.formalDialog.visible = false;
},
async handleSave() {
const valid = await this.$refs.formRef.validate().catch(() => false);
if (!valid || !this.form.formalSettlementId) return this.$message.warning('请选择正式结算单');
this.saving = true;
try {
const { data } = await api.save({
id: this.currentId,
formalSettlementId: this.form.formalSettlementId,
reconciliationMode: this.form.reconciliationMode,
reconciliationDate: this.form.reconciliationDate,
remark: this.form.remark,
});
this.currentId = data;
await this.loadDetail();
this.$message.success('草稿保存成功');
this.$emit('success');
} finally {
this.saving = false;
}
},
async handleMatch() {
if (!this.currentId) {
await this.handleSave();
if (!this.currentId) return;
}
this.actionLoading = true;
try {
await api.match(this.currentId);
await this.loadDetail();
this.$message.success('匹配完成');
} finally {
this.actionLoading = false;
}
},
async handleUpdate() {
if (!this.currentId) return this.$message.warning('请先保存对账单');
await this.$confirm('将以外部账单匹配金额更新内部结算明细,是否继续?', '更新账单', {
type: 'warning',
});
this.actionLoading = true;
try {
await api.updateByMatch(this.currentId);
await this.loadDetail();
this.$message.success('账单更新完成');
} finally {
this.actionLoading = false;
}
},
async handleComplete() {
if (!this.currentId) return this.$message.warning('请先保存对账单');
await this.$confirm('完成后对账单不可修改和删除,是否继续?', '完成对账', {
type: 'warning',
});
this.actionLoading = true;
try {
await api.complete(this.currentId);
await this.loadDetail();
this.$message.success('对账完成');
this.$emit('success');
} finally {
this.actionLoading = false;
}
},
chooseImport() {
if (!this.currentId) return this.$message.warning('请先保存对账单');
this.$refs.fileInput?.click();
},
async handleImport(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
const response =
this.form.reconciliationMode === 'cargo'
? await api.importCargo(this.currentId, file)
: await api.importVehicle(this.currentId, file);
const contentType = response.headers?.['content-type'] || response.data?.type || '';
if (contentType.includes('spreadsheetml') || contentType.includes('ms-excel')) {
downloadXls(
response.data,
`运输对账导入失败明细${this.$dayjs().format('YYYY-MM-DD HH-mm-ss')}.xlsx`
);
this.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const result = JSON.parse(await response.data.text());
if (result.code !== 200) throw new Error(result.msg || '导入失败');
this.$message.success('外部账单导入成功');
}
await this.loadDetail();
} catch (error) {
this.$message.error(error.message || '外部账单导入失败');
}
},
async downloadTemplate(mode) {
const response = await api.template(mode);
downloadXls(
response.data,
`${mode === 'cargo' ? '货物明细对账模板' : '整车总额对账模板'}.xlsx`
);
},
resetInternalQuery() {
this.internalQuery = { documentNo: '', vehicleNo: '', batchNo: '', cargoName: '' };
this.internalFilterTick++;
},
openAdjust(row) {
this.adjustDialog = {
visible: true,
saving: false,
reason: '',
rows: this.internalDetails
.filter(item => item.formalSettlementDetailId === row.formalSettlementDetailId)
.map(item => ({ ...item })),
};
},
async saveAdjust() {
if (!this.adjustDialog.reason.trim()) return this.$message.warning('请输入调整原因');
this.adjustDialog.saving = true;
try {
for (const row of this.adjustDialog.rows)
await api.adjust({
...row,
reconciliationId: this.currentId,
updateMessage: this.adjustDialog.reason,
});
this.adjustDialog.visible = false;
await this.loadDetail();
this.$message.success('调整保存成功');
} finally {
this.adjustDialog.saving = false;
}
},
async handleUnmatch(row) {
await api.unmatch(row.id);
await this.loadDetail();
},
openManualMatch(row) {
this.manualDialog = { visible: true, internal: row, selected: null };
},
openManualMatchByExternal(row) {
this.manualDialog = { visible: true, internal: null, selected: row };
},
async confirmManualMatch() {
if (!this.manualDialog.selected) return this.$message.warning('请选择外部账单明细');
if (!this.manualDialog.internal) return this.$message.warning('请从内部账单行发起人工匹配');
await api.manualMatch({
reconciliationId: this.currentId,
internalId: this.manualDialog.internal.id,
externalId: this.manualDialog.selected.id,
});
this.manualDialog.visible = false;
await this.loadDetail();
this.$message.success('人工匹配成功');
},
matchName(value) {
return (
{
matched: '已匹配',
suspected_duplicate: '疑似重复',
partial: '部分匹配',
unmatched: '未匹配',
}[value] || '未匹配'
);
},
matchTagType(value) {
return value === 'matched'
? 'success'
: value === 'suspected_duplicate'
? 'danger'
: value === 'partial'
? 'warning'
: 'info';
},
updateName(value) {
return (
{
updated: '已更新',
skipped_multi_cargo: '跳过更新',
manually_adjusted: '手工调整',
not_updated: '未更新',
}[value] ||
value ||
'未更新'
);
},
updateTagType(value) {
return value === 'updated' || value === 'manually_adjusted'
? 'success'
: value === 'skipped_multi_cargo'
? 'warning'
: 'info';
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
formatMoney(value, currency = 'RMB') {
return `${Number(value || 0).toFixed(2)} ${currency || 'RMB'}`;
},
formatNumber(value) {
return Number(value || 0).toFixed(2);
},
},
};
</script>
<style scoped lang="scss">
.reconciliation-editor__section {
margin-bottom: 24px;
}
.reconciliation-editor__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.reconciliation-editor__actions,
.reconciliation-editor__links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.reconciliation-editor__stats {
display: grid;
grid-template-columns: repeat(5, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
min-height: 104px;
border: 1px solid #eff1f7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
background: #fff;
}
.stat-card b {
font-size: 20px;
color: #303133;
}
.stat-card span {
color: #606266;
}
.stat-card small {
color: #909399;
}
.stat-card--success b {
color: #67c23a;
}
.stat-card--danger b {
color: #f56c6c;
}
.reconciliation-editor__filter {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto auto;
gap: 8px;
margin-bottom: 12px;
}
.reconciliation-editor__pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.reconciliation-editor__help {
color: #606266;
line-height: 1.8;
}
.dialog-form {
margin-top: 16px;
}
.hidden-file {
display: none;
}
.reconciliation-editor :deep(.el-form-item) {
margin-bottom: 16px;
}
.reconciliation-editor :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
.reconciliation-editor :deep(.el-table__body tr:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
@media (max-width: 1200px) {
.reconciliation-editor__stats {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
.reconciliation-editor__filter {
grid-template-columns: repeat(2, minmax(160px, 1fr));
}
}
</style>