Files
tms-erp-web/src/views/settlement/components/transport-reconciliation-editor.vue
T
b2894lxlx 6d4961f404 1、调整凭证
2、调整对账
3、解决正式结算冲突bug
2026-09-09 10:21:19 +08:00

1848 lines
67 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>
<component
:is="editorContainer"
v-bind="editorContainerProps"
@update:model-value="visible = $event"
>
<div
v-loading="loading"
class="reconciliation-editor"
:class="{ 'reconciliation-editor--page': pageMode }"
>
<section-card title="导入外部账单,与内部账单核对">
<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="6">
<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-card>
<section-card title="内部账单">
<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">
<div class="reconciliation-editor__filter-item">
<span class="reconciliation-editor__filter-label">单据号</span>
<el-input v-model="internalQuery.documentNo" placeholder="请输入" clearable />
</div>
<div class="reconciliation-editor__filter-item">
<span class="reconciliation-editor__filter-label">车号</span>
<el-input v-model="internalQuery.vehicleNo" placeholder="请输入" clearable />
</div>
<div class="reconciliation-editor__filter-item">
<span class="reconciliation-editor__filter-label">批次号</span>
<el-input v-model="internalQuery.batchNo" placeholder="请输入" clearable />
</div>
<div class="reconciliation-editor__filter-item">
<span class="reconciliation-editor__filter-label">货物名称</span>
<el-input v-model="internalQuery.cargoName" placeholder="请输入" clearable />
</div>
<el-button @click="resetInternalQuery">重置</el-button>
<el-button type="primary" @click="internalFilterTick++">查询</el-button>
</div>
<el-table
:data="filteredInternalRows"
border
max-height="420"
:row-class-name="internalRowClassName"
>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in internalTableColumns"
: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)" class="status-text">{{
matchName(row.matchResult)
}}</el-tag>
<el-tag
v-else-if="column.prop === 'updateResult'"
:type="updateTagType(row.updateResult)"
class="status-text"
>{{ updateName(row.updateResult) }}</el-tag
>
<span v-else-if="column.prop === 'matchedExternalLineNo'">
<el-input
:model-value="
hasMatchedExternal(row) ? formatMatchedExternalLineNo(row.matchedExternalLineNo) : ''
"
readonly
size="small"
:placeholder="hasMatchedExternal(row) ? '' : '请选择匹配账单'"
>
<template #append>
<el-button :icon="Search" :disabled="!editable" @click="openExternalMatchDialog(row)" />
</template>
</el-input>
</span>
<span v-else-if="column.prop === 'transportType'">
{{ transportTypeLabel(row.transportType) }}
</span>
<span v-else-if="column.prop === 'transportQuantity'">
{{ formatTransportQuantity(row.transportQuantity) }}
</span>
<span v-else-if="column.feeItemName">
{{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }}
</span>
<span v-else-if="column.prop === 'mileage'">{{ formatMileage(row.mileage) }}</span>
<span
v-else-if="
column.prop === 'actualDepartureTime' || column.prop === 'actualCompletionTime'
"
>{{ formatDate(row[column.prop]) }}</span
>
<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 && ['matched', 'partial'].includes(row.matchResult)"
type="primary"
@click="handleUnmatch(row)"
>取消匹配</el-link
>
</div>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card title="导入外部账单">
<template #extra>
<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" :disabled="!canStartMatch" @click="handleMatch"
>开始匹配内部账单</el-button
>
<input
ref="fileInput"
type="file"
accept=".xls,.xlsx"
class="hidden-file"
@change="handleImport"
/>
</div>
</template>
<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"
:row-class-name="externalRowClassName"
>
<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)" class="status-text">{{
matchName(row.matchStatus)
}}</el-tag>
<template v-else-if="isExternalRowEditing(row) && isExternalEditableColumn(column.prop)">
<el-input-number
v-if="column.feeItemName"
v-model="row.feeItems[column.feeItemName]"
:min="0"
:precision="2"
:controls="false"
size="small"
/>
<el-date-picker
v-else-if="isExternalDateColumn(column.prop)"
v-model="row[column.prop]"
type="date"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
size="small"
placeholder="请选择"
/>
<el-input-number
v-else-if="isExternalNumberColumn(column.prop)"
v-model="row[column.prop]"
:min="0"
:precision="2"
:controls="false"
size="small"
/>
<el-select
v-else-if="column.prop === 'transportType'"
v-model="row[column.prop]"
size="small"
clearable
placeholder="请选择"
>
<el-option
v-for="item in transportTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input v-else v-model="row[column.prop]" size="small" />
</template>
<span v-else-if="column.prop === 'transportType'">
{{ transportTypeLabel(row.transportType) }}
</span>
<span v-else-if="column.prop === 'transportQuantity'">
{{ formatTransportQuantity(row.transportQuantity) }}
</span>
<span v-else-if="column.feeItemName">
{{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }}
</span>
<span v-else-if="column.prop === 'mileage'">{{ formatMileage(row.mileage) }}</span>
<span
v-else-if="
column.prop === 'actualDepartureTime' || column.prop === 'actualCompletionTime'
"
>{{ formatDate(row[column.prop]) }}</span
>
<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="150" fixed="right" align="center">
<template #default="{ row }">
<div class="reconciliation-editor__links">
<template v-if="editable && isExternalRowEditing(row)">
<el-link type="primary" @click="finishExternalAdjust(row)">完成</el-link>
<el-link type="primary" @click="cancelExternalAdjust(row)">取消</el-link>
</template>
<el-link v-else-if="editable" type="primary" @click="openExternalAdjust(row)">
调整
</el-link>
</div>
</template>
</el-table-column>
</el-table>
</section-card>
</div>
<template v-if="!pageMode" #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>
<div v-if="pageMode" class="reconciliation-editor__page-actions">
<el-button @click="visible = false">取消</el-button>
<template v-if="editable">
<el-button type="primary" plain :loading="actionLoading" @click="handleUpdate">
按匹配结果更新账单
</el-button>
<el-button type="primary" :loading="actionLoading" @click="handleComplete">
完成对账
</el-button>
</template>
</div>
<el-dialog v-model="formalDialog.visible" title="选择正式结算单" width="84%" append-to-body>
<el-form :model="formalDialog.query" inline label-position="right" label-width="88px">
<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-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-link type="primary" @click="selectFormal(row)">选择</el-link>
</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"
:precision="2"
: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="manualMatchDialog.visible"
title="选择未匹配外部账单"
width="92%"
append-to-body
>
<el-table v-loading="manualMatchDialog.loading" :data="unmatchedExternalRows" border max-height="520">
<el-table-column
v-for="column in externalColumns"
:key="`manual-${column.prop}`"
v-bind="column"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.prop === 'transportType'">{{ transportTypeLabel(row.transportType) }}</span>
<span v-else-if="column.prop === 'transportQuantity'">{{ formatTransportQuantity(row.transportQuantity) }}</span>
<span v-else-if="column.feeItemName">{{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }}</span>
<span v-else-if="column.prop === 'mileage'">{{ formatMileage(row.mileage) }}</span>
<span v-else-if="column.prop === 'actualDepartureTime' || column.prop === 'actualCompletionTime'">{{ formatDate(row[column.prop]) }}</span>
<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="100" fixed="right" align="center">
<template #default="{ row }">
<el-link type="primary" @click="handleManualMatch(row)">选择</el-link>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="manualMatchDialog.visible = false">取消</el-button>
</template>
</el-dialog>
<el-dialog
v-model="completionDialog.visible"
title="汇总对账结果费用"
width="92%"
top="4vh"
append-to-body
destroy-on-close
class="completion-dialog"
>
<div class="completion-dialog__content">
<section class="completion-dialog__section">
<div class="completion-dialog__grid">
<div class="completion-dialog__field">
<span class="completion-dialog__label">对账单号</span>
<span class="completion-dialog__value">{{ displayValue(form.reconciliationNo) }}</span>
</div>
<div class="completion-dialog__field">
<span class="completion-dialog__label">预结算单号</span>
<span class="completion-dialog__value">{{
displayValue(form.preSettlementNo || form.formalSettlementNo)
}}</span>
</div>
<div class="completion-dialog__field">
<span class="completion-dialog__label">合同编号</span>
<span class="completion-dialog__value">{{ displayValue(form.contractNo) }}</span>
</div>
<div class="completion-dialog__field">
<span class="completion-dialog__label">单据周期</span>
<span class="completion-dialog__value">{{ completionPeriod }}</span>
</div>
<div class="completion-dialog__field">
<span class="completion-dialog__label">客户/承运商</span>
<span class="completion-dialog__value">{{ displayValue(form.customerName) }}</span>
</div>
<div class="completion-dialog__field">
<span class="completion-dialog__label">项目</span>
<span class="completion-dialog__value">{{ displayValue(form.projectName) }}</span>
</div>
<div class="completion-dialog__field">
<span class="completion-dialog__label">对账模式</span>
<span class="completion-dialog__value">{{ reconciliationModeLabel }}</span>
</div>
<div class="completion-dialog__field completion-dialog__field--remark">
<span class="completion-dialog__label">备注</span>
<span class="completion-dialog__value">{{ displayValue(form.remark) }}</span>
</div>
</div>
</section>
<section class="completion-dialog__section completion-dialog__section--fees">
<div class="completion-dialog__section-title">费用合计信息</div>
<div class="completion-dialog__fee-actions">
<el-button type="primary" plain @click="openFeeAdjustment('补款')">补款</el-button>
<el-button type="primary" plain @click="openFeeAdjustment('扣款')">扣款</el-button>
</div>
<el-table :data="completionSummaryRows" border>
<el-table-column type="index" label="序号" width="72" align="center" />
<el-table-column prop="name" label="费用项目" min-width="150" align="center" />
<el-table-column label="原金额(元)" min-width="160" align="right">
<template #default="{ row }">{{ formatCompletionMoney(row.originalAmount) }}</template>
</el-table-column>
<el-table-column label="调整金额(元)" min-width="160" align="right">
<template #default="{ row }"
><span :class="{ 'completion-dialog__negative': row.adjustAmount < 0 }">{{
formatCompletionMoney(row.adjustAmount)
}}</span></template
>
</el-table-column>
<el-table-column label="调整后金额(元)" min-width="180" align="right">
<template #default="{ row }">{{ formatCompletionMoney(row.afterAmount) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="220" />
</el-table>
</section>
</div>
<template #footer>
<div class="completion-dialog__footer">
<el-button type="primary" plain @click="completionDialog.visible = false"
>上一步</el-button
>
<el-button type="primary" :loading="completionDialog.loading" @click="confirmCompletion"
>确认</el-button
>
<el-button @click="completionDialog.visible = false">取消</el-button>
</div>
</template>
</el-dialog>
</component>
</template>
<script>
import { mapGetters } from 'vuex';
import * as api from '@/api/settlement/transportReconciliation';
import * as formalSettlementApi from '@/api/settlement/formalSettlement';
import { getDictionary } from '@/api/system/dictbiz';
import { Search } from '@element-plus/icons-vue';
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,
pageMode: Boolean,
},
emits: ['update:modelValue', 'success'],
data() {
return {
Search,
loading: false,
saving: false,
actionLoading: false,
currentId: null,
form: this.emptyForm(),
formFields: transportReconciliationFormFields,
internalColumns,
transportTypeOptions: [],
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: '' },
manualMatchDialog: { visible: false, loading: false, row: null },
completionDialog: { visible: false, loading: false },
externalEditing: {},
externalEditSnapshots: {},
matchingStarted: false,
rules: {
formalSettlementNo: [{ required: true, message: '请选择正式结算单', trigger: 'change' }],
reconciliationMode: [{ required: true, message: '请选择对账模式', trigger: 'change' }],
remark: [{ max: 200, message: '备注不能超过200个字', trigger: 'blur' }],
},
};
},
computed: {
...mapGetters(['userInfo']),
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
editorContainer() {
return this.pageMode ? 'div' : 'el-dialog';
},
editorContainerProps() {
if (this.pageMode) {
return { class: 'reconciliation-editor-shell reconciliation-editor-shell--page' };
}
return {
modelValue: this.visible,
title: this.title,
width: '98%',
top: '2vh',
appendToBody: true,
destroyOnClose: true,
};
},
editable() {
return !this.readonly;
},
title() {
return this.readonly
? '查看运输对账单'
: this.currentId
? '编辑运输对账单'
: '新增运输对账单';
},
filteredInternalRows() {
void this.internalFilterTick;
const query = this.internalQuery;
return this.groupedInternalRows.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))
);
},
groupedInternalRows() {
if (this.form.reconciliationMode === 'cargo') {
return this.internalDetails.map(row => ({ ...row, _sourceRows: [row] }));
}
const groups = new Map();
this.internalDetails.forEach((row, index) => {
const key = row.documentNo ? String(row.documentNo) : `__row_${index}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
});
return [...groups.values()].map(rows => this.mergeInternalRows(rows));
},
internalFeeItemNames() {
const names = new Set();
this.internalDetails.forEach(row => {
Object.keys(row.feeItems || this.parseFeeItems(row.feeItemsJson)).forEach(name => {
if (name) names.add(name);
});
});
return [...names];
},
internalTableColumns() {
const columns = internalColumns.filter(column => column.prop !== 'freightAmount');
const settlementIndex = columns.findIndex(column => column.prop === 'settlementAmount');
const feeColumns = this.internalFeeItemNames.map((name, index) => ({
prop: `feeItem_${index}`,
label: name,
minWidth: 120,
money: true,
feeItemName: name,
}));
columns.splice(settlementIndex < 0 ? columns.length : settlementIndex, 0, ...feeColumns);
return columns;
},
externalColumns() {
const columns = [
...(this.form.reconciliationMode === 'cargo'
? externalCargoColumns
: externalVehicleColumns),
];
const settlementIndex = columns.findIndex(column => column.prop === 'settlementAmount');
const feeColumns = this.externalFeeItemNames.map((name, index) => ({
prop: `externalFeeItem_${index}`,
label: name,
minWidth: 120,
money: true,
feeItemName: name,
}));
columns.splice(settlementIndex < 0 ? columns.length : settlementIndex, 0, ...feeColumns);
return columns;
},
externalFeeItemNames() {
const names = new Set();
this.externalDetails.forEach(row => {
Object.keys(row.feeItems || this.parseFeeItems(row.feeItemsJson)).forEach(name => {
if (name) names.add(name);
});
});
return [...names];
},
duplicateRows() {
return this.externalDetails.filter(
row => row.suspectedDuplicate || row.matchStatus === 'suspected_duplicate'
);
},
canStartMatch() {
return Boolean(this.form.formalSettlementId && this.externalDetails.length);
},
visibleExternalRows() {
return this.externalTab === 'duplicate' ? this.duplicateRows : this.externalDetails;
},
unmatchedExternalRows() {
return this.externalDetails.filter(row => row.matchStatus !== 'matched');
},
completionPeriod() {
const period = this.form.period || this.form.documentPeriod || this.form.settlementPeriod;
if (Array.isArray(period) && period.length) return period.filter(Boolean).join(' 至 ');
if (typeof period === 'string' && period.trim()) return period;
const start =
this.form.periodStart ||
this.form.periodStartDate ||
this.form.startDate ||
this.form.billStartDate ||
this.form.reconciliationStartDate ||
this.form.settlementStartDate;
const end =
this.form.periodEnd ||
this.form.periodEndDate ||
this.form.endDate ||
this.form.billEndDate ||
this.form.reconciliationEndDate ||
this.form.settlementEndDate;
if (start || end) return [start, end].filter(Boolean).join(' 至 ');
return '-';
},
reconciliationModeLabel() {
const labels = { vehicle: '整车总额对账', cargo: '货物明细对账' };
return this.form.reconciliationModeName || labels[this.form.reconciliationMode] || '-';
},
completionSummaryRows() {
const source =
this.form.feeSummary ||
this.form.feeSummaries ||
this.form.feeDetails ||
this.form.summaryFees;
if (Array.isArray(source) && source.length) {
return source.map((item, index) => this.normalizeCompletionFee(item, index));
}
if (source && typeof source === 'object') {
return Object.entries(source).map(([name, item], index) =>
this.normalizeCompletionFee(
typeof item === 'object' ? { ...item, name: item.name || name } : { name, afterAmount: item },
index
)
);
}
const names = new Set([...this.internalFeeItemNames, ...this.externalFeeItemNames]);
if (!names.size) names.add('运费');
return [...names].map((name, index) => {
const originalAmount = this.sumFeeAmount(this.internalDetails, name);
const externalAmount = this.sumFeeAmount(this.externalDetails, name);
const afterAmount = this.externalDetails.length
? externalAmount
: name === '运费'
? Number(this.form.internalAmount || 0)
: originalAmount;
return {
name,
originalAmount,
adjustAmount: afterAmount - originalAmount,
afterAmount,
remark: '',
_index: index,
};
});
},
},
watch: {
modelValue: {
immediate: true,
handler(value) {
if (value) this.initialize();
},
},
},
methods: {
emptyForm() {
return {
reconciliationNo: '',
reconciliationMode: 'vehicle',
formalSettlementId: null,
formalSettlementNo: '',
customerName: '',
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.loadTransportTypeOptions();
this.currentId = this.recordId;
this.form = this.emptyForm();
this.internalDetails = [];
this.externalDetails = [];
this.externalEditing = {};
this.externalEditSnapshots = {};
this.matchingStarted = false;
this.externalTab = 'all';
this.internalQuery = { documentNo: '', vehicleNo: '', batchNo: '', cargoName: '' };
if (!this.currentId) {
this.form.reconciliationMode = 'vehicle';
this.form.reconcilerName =
this.userInfo?.realName || this.userInfo?.userName || this.userInfo?.account || '当前用户';
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 = this.unwrapData(await api.getDetail(this.currentId)) || {};
this.form = {
...this.emptyForm(),
...data,
customerName:
data.customerName ||
((data.settlementType || this.settlementType) === 'receivable'
? data.payerName
: data.payeeName) ||
'',
};
const internalRows = data.internalDetails || data.internalBillDetails || data.internals || [];
this.internalDetails = internalRows.map(row => this.normalizeInternalRow(row));
const externalRows = data.externalDetails || data.externalBillDetails || data.externals || [];
this.externalDetails = externalRows.map((row, index) =>
this.normalizeExternalRow(row, index)
);
this.matchingStarted =
this.matchingStarted ||
data.matchStatus === 'partial' ||
this.internalDetails.some(row => row.matchResult === 'matched') ||
this.externalDetails.some(row => row.matchStatus === 'matched');
},
openFormalDialog() {
if (!this.editable) return;
this.formalDialog.visible = true;
this.formalDialog.page.current = 1;
this.loadFormalOptions();
},
parseFeeItems(value) {
if (!value) return {};
if (typeof value === 'object' && !Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
},
normalizeInternalRow(row) {
const feeItems =
row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length
? row.feeItems
: this.parseFeeItems(row.feeItemsJson);
return { ...row, feeItems };
},
normalizeExternalRow(row, index) {
const feeItems =
row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length
? row.feeItems
: this.parseFeeItems(row.feeItemsJson);
return {
...row,
feeItems,
_externalKey: row.id || row.externalLineNo || `external-${index}`,
};
},
mergeInternalRows(rows) {
const firstRow = rows[0] || {};
const feeItems = {};
rows.forEach(row => {
Object.entries(row.feeItems || this.parseFeeItems(row.feeItemsJson)).forEach(
([name, amount]) => {
feeItems[name] = Number(feeItems[name] || 0) + Number(amount || 0);
}
);
});
const matchResults = rows.map(row => row.matchResult);
const updateResults = rows.map(row => row.updateResult);
return {
...firstRow,
cargoName: this.joinInternalValues(rows, 'cargoName'),
cargoType: this.joinInternalValues(rows, 'cargoType'),
transportQuantity: this.sumInternalValues(rows, 'transportQuantity'),
settlementAmount: this.sumInternalValues(rows, 'settlementAmount'),
feeItems,
matchedExternalLineNo: this.joinInternalValues(rows, 'matchedExternalLineNo'),
matchResult: matchResults.every(value => value === 'matched')
? 'matched'
: matchResults.some(value => value === 'matched')
? 'partial'
: 'unmatched',
updateResult: updateResults.every(value => value === updateResults[0])
? updateResults[0]
: 'partial_updated',
_sourceRows: rows,
};
},
joinInternalValues(rows, prop) {
return [
...new Set(
rows
.map(row => row[prop])
.filter(value => value !== null && value !== undefined && value !== '')
),
].join('、');
},
sumInternalValues(rows, prop) {
return rows.reduce((total, row) => total + Number(row[prop] || 0), 0);
},
getFeeItemAmount(row, name) {
return (row.feeItems || this.parseFeeItems(row.feeItemsJson))[name] ?? 0;
},
async loadFormalOptions() {
this.formalDialog.loading = true;
try {
const params = {
settlementType: this.settlementType,
keyword: this.formalDialog.query.keyword,
};
const data = this.unwrapData(
await api.getFormalOptions(
this.formalDialog.page.current,
this.formalDialog.page.size,
params
)
);
let rows = data.records || [];
let total = data.total || 0;
if (!rows.length) {
const fallback = await formalSettlementApi.getList(
this.formalDialog.page.current,
this.formalDialog.page.size,
{ ...params, approvalStatus: 'approved' }
);
const fallbackData = this.unwrapData(fallback);
rows = fallbackData.records || [];
total = fallbackData.total || 0;
}
this.formalDialog.rows = rows;
this.formalDialog.page.total = total;
} finally {
this.formalDialog.loading = false;
}
},
async confirmFormal() {
if (this.formalDialog.selected.length !== 1)
return this.$message.warning('请选择一张正式结算单');
await this.selectFormal(this.formalDialog.selected[0]);
},
async selectFormal(selected) {
const formalSettlementChanged = this.form.formalSettlementId !== selected.id;
this.form = {
...this.form,
...selected,
formalSettlementId: selected.id,
formalSettlementNo: selected.formalSettlementNo,
customerName:
selected.customerName ||
((selected.settlementType || this.settlementType) === 'receivable'
? selected.payerName
: selected.payeeName) ||
'',
reconciliationNo: this.form.reconciliationNo,
};
if (formalSettlementChanged) {
this.externalDetails = [];
this.matchingStarted = false;
}
await this.loadFormalInternalPreview(selected.id);
this.formalDialog.visible = false;
},
async loadFormalInternalPreview(formalSettlementId) {
const formal = this.unwrapData(await formalSettlementApi.getDetail(formalSettlementId));
const details = formal.details || [];
const buildInternalRow = (detail, overrides = {}, index = 0) =>
this.normalizeInternalRow({
...detail,
...overrides,
id: null,
lineNo: index + 1,
formalSettlementDetailId: detail.id,
settlementAmount: overrides.settlementAmount ?? detail.settlementAmountTax ?? 0,
matchResult: 'unmatched',
updateResult: 'not_updated',
});
if (this.form.reconciliationMode === 'cargo') {
const feeGroups = await Promise.all(
details.map(async detail => ({
detail,
fees: this.unwrapData(await formalSettlementApi.getDetailFees(detail.id)) || [],
}))
);
this.internalDetails = feeGroups.flatMap(({ detail, fees }) => {
if (!fees.length) return [buildInternalRow(detail, {}, 0)];
return fees.map((fee, index) =>
buildInternalRow(
detail,
{
...fee,
settlementAmount: fee.settlementAmountTax ?? 0,
},
index
)
);
});
} else {
this.internalDetails = details.map((detail, index) => buildInternalRow(detail, {}, index));
}
const internalQuantity = this.internalDetails.reduce(
(total, row) => total + Number(row.transportQuantity || 0),
0
);
const internalAmount = this.internalDetails.reduce(
(total, row) => total + Number(row.settlementAmount || 0),
0
);
this.form = {
...this.form,
internalBillCount: this.internalDetails.length,
internalQuantity,
internalAmount,
externalBillCount: 0,
externalQuantity: 0,
externalAmount: 0,
differenceCount: this.internalDetails.length,
differenceQuantity: internalQuantity,
differenceAmount: internalAmount,
matchedCount: 0,
unmatchedCount: this.internalDetails.length,
};
},
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
},
async loadTransportTypeOptions() {
try {
const data = this.unwrapData(await getDictionary({ code: 'transport_type' }));
const records = Array.isArray(data) ? data : data?.records || [];
this.transportTypeOptions = records.map(item => ({
label: item.dictValue || item.label || item.name,
value: item.dictKey || item.value || item.dictValue || item.name,
}));
} catch {
this.transportTypeOptions = [];
}
},
async handleSave(silent = false) {
const valid = await this.$refs.formRef.validate().catch(() => false);
if (!valid || !this.form.formalSettlementId) {
this.$message.warning('请选择正式结算单');
return null;
}
this.saving = true;
try {
const data = this.unwrapData(
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();
if (!silent) {
this.$message.success('草稿保存成功');
this.$emit('success', this.currentId);
}
return data;
} finally {
this.saving = false;
}
},
serializeInternalRows() {
return this.internalDetails.map(row => {
const snapshot = { ...row };
delete snapshot._sourceRows;
delete snapshot._externalKey;
delete snapshot.feeItems;
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
return snapshot;
});
},
serializeExternalRows() {
return this.externalDetails.map(row => {
const snapshot = { ...row };
delete snapshot._sourceRows;
delete snapshot._externalKey;
delete snapshot.feeItems;
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
return snapshot;
});
},
async handleMatch() {
if (!this.currentId) return this.$message.warning('请先保存草稿后再匹配');
this.actionLoading = true;
try {
const data = this.unwrapData(
await api.matchPreview({
id: this.currentId,
reconciliationMode: this.form.reconciliationMode,
internalDetails: this.serializeInternalRows(),
externalDetails: this.serializeExternalRows(),
})
);
this.form = { ...this.form, ...data };
this.internalDetails = (data.internalDetails || []).map(row => this.normalizeInternalRow(row));
this.externalDetails = (data.externalDetails || []).map((row, index) =>
this.normalizeExternalRow(row, index)
);
this.matchingStarted = true;
this.$message.success('匹配完成');
} finally {
this.actionLoading = false;
}
},
async handleUpdate() {
await this.$confirm('将以外部账单数据更新运单及内部结算明细,是否继续?', '更新账单', {
type: 'warning',
});
this.actionLoading = true;
try {
await api.updateByMatch(this.currentId, {
id: this.currentId,
reconciliationMode: this.form.reconciliationMode,
internalDetails: this.serializeInternalRows(),
externalDetails: this.serializeExternalRows(),
});
await this.loadDetail();
this.$message.success('账单更新完成');
} finally {
this.actionLoading = false;
}
},
async handleComplete() {
if (
Number(this.form.differenceCount || 0) !== 0 ||
Number(this.form.differenceQuantity || 0) !== 0 ||
Number(this.form.differenceAmount || 0) !== 0
) {
return this.$message.warning('差异单数、差异货量和差异金额必须全部为0才可完成对账');
}
this.completionDialog.visible = true;
},
async confirmCompletion() {
if (this.completionDialog.loading) return;
this.completionDialog.loading = true;
try {
this.actionLoading = true;
const data = this.unwrapData(
await api.completeWithData({
id: this.currentId,
reconciliationDate: this.form.reconciliationDate,
remark: this.form.remark,
internalDetails: this.serializeInternalRows(),
externalDetails: this.serializeExternalRows(),
})
);
this.form = { ...this.form, ...data };
this.internalDetails = (data.internalDetails || []).map(row => this.normalizeInternalRow(row));
this.externalDetails = (data.externalDetails || []).map((row, index) =>
this.normalizeExternalRow(row, index)
);
this.$message.success('对账完成');
this.completionDialog.visible = false;
this.visible = false;
this.$emit('success', this.currentId);
} finally {
this.completionDialog.loading = false;
this.actionLoading = false;
}
},
openFeeAdjustment(type) {
this.$message.info(`${type}请在结算明细调整中维护`);
},
sumFeeAmount(rows, name) {
return rows.reduce((sum, row) => {
const feeItems = row.feeItems || this.parseFeeItems(row.feeItemsJson);
const fallback = name === '运费' ? row.freightAmount ?? row.settlementAmount : 0;
return sum + Number(feeItems[name] ?? fallback ?? 0);
}, 0);
},
normalizeCompletionFee(item, index) {
const originalAmount = Number(
item.originalAmount ?? item.original ?? item.originalSettlementAmount ?? 0
);
const afterAmount = Number(
item.afterAmount ?? item.adjustedAmount ?? item.settlementAmount ?? item.totalAmount ?? originalAmount
);
return {
name: item.name || item.feeItem || item.feeItemName || `费用${index + 1}`,
originalAmount,
adjustAmount: Number(item.adjustAmount ?? item.adjustmentAmount ?? afterAmount - originalAmount),
afterAmount,
remark: item.remark || item.adjustRemark || '',
_index: index,
};
},
formatCompletionMoney(value) {
return Number(value || 0).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
},
chooseImport() {
this.$refs.fileInput?.click();
},
async handleImport(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
const savedId = await this.handleSave(true);
if (!savedId) return;
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('外部账单导入成功');
}
this.matchingStarted = false;
await this.loadDetail();
this.matchingStarted = false;
} 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: (row._sourceRows || 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 {
const rowsById = new Map(this.adjustDialog.rows.map(row => [row.id, row]));
this.internalDetails = this.internalDetails.map(row => {
const adjusted = rowsById.get(row.id);
if (!adjusted) return row;
return {
...row,
transportQuantity: adjusted.transportQuantity,
unitPrice: adjusted.unitPrice,
mileage: adjusted.mileage,
freightAmount: adjusted.freightAmount,
feeItems: adjusted.feeItems || this.parseFeeItems(adjusted.feeItemsJson),
feeItemsJson: JSON.stringify(adjusted.feeItems || this.parseFeeItems(adjusted.feeItemsJson)),
settlementAmount: adjusted.settlementAmount,
updateResult: 'manually_adjusted',
updateMessage: this.adjustDialog.reason.trim(),
};
});
this.adjustDialog.visible = false;
this.refreshLocalStats();
this.$message.success('调整已暂存');
} finally {
this.adjustDialog.saving = false;
}
},
async handleUnmatch(row) {
const matchedRows = (row._sourceRows || [row]).filter(item => item.matchResult === 'matched');
matchedRows.forEach(item => {
const external = this.externalDetails.find(candidate => candidate.id === item.matchedExternalId);
if (external) {
external.matchedInternalId = null;
external.matchStatus = 'unmatched';
external.suspectedDuplicate = false;
}
item.matchedExternalId = null;
item.matchedExternalLineNo = null;
item.matchResult = 'unmatched';
});
this.refreshLocalStats();
this.$message.success('取消匹配已暂存');
},
hasMatchedExternal(row) {
return row.matchedExternalLineNo !== null && row.matchedExternalLineNo !== undefined && row.matchedExternalLineNo !== '';
},
getInternalMatchTarget(row) {
const sourceRows = row._sourceRows || [row];
return sourceRows.find(item => item.id && item.matchResult !== 'matched') || sourceRows.find(item => item.id);
},
openExternalMatchDialog(row) {
if (!this.editable) return;
if (!this.getInternalMatchTarget(row)) return this.$message.warning('当前内部账单明细无法匹配');
this.manualMatchDialog.row = row;
this.manualMatchDialog.visible = true;
},
async handleManualMatch(externalRow) {
const internalRow = this.getInternalMatchTarget(this.manualMatchDialog.row);
if (!internalRow) return this.$message.warning('当前内部账单明细无法匹配');
this.manualMatchDialog.loading = true;
try {
const internal = this.internalDetails.find(item => item.id === internalRow.id);
const external = this.externalDetails.find(item => item.id === externalRow.id);
if (!internal || !external) return this.$message.warning('匹配明细不存在');
if (external.matchedInternalId && external.matchedInternalId !== internal.id) {
const oldInternal = this.internalDetails.find(item => item.id === external.matchedInternalId);
if (oldInternal) {
oldInternal.matchedExternalId = null;
oldInternal.matchedExternalLineNo = null;
oldInternal.matchResult = 'unmatched';
}
}
if (internal.matchedExternalId && internal.matchedExternalId !== external.id) {
const oldExternal = this.externalDetails.find(item => item.id === internal.matchedExternalId);
if (oldExternal) {
oldExternal.matchedInternalId = null;
oldExternal.matchStatus = 'unmatched';
oldExternal.suspectedDuplicate = false;
}
}
internal.matchedExternalId = external.id;
internal.matchedExternalLineNo = external.externalLineNo;
internal.matchResult = 'matched';
external.matchedInternalId = internal.id;
external.matchStatus = 'matched';
external.suspectedDuplicate = false;
this.manualMatchDialog.visible = false;
this.matchingStarted = true;
this.refreshLocalStats();
this.$message.success('匹配成功');
} finally {
this.manualMatchDialog.loading = false;
}
},
internalRowClassName({ row }) {
return this.matchingStarted && row.matchResult !== 'matched'
? 'reconciliation-editor__row--unmatched'
: '';
},
externalRowClassName({ row }) {
return this.matchingStarted && row.matchStatus !== 'matched'
? 'reconciliation-editor__row--unmatched'
: '';
},
externalRowKey(row) {
return String(row.id || row.externalLineNo || row._externalKey || '');
},
isExternalRowEditing(row) {
return Boolean(this.externalEditing[this.externalRowKey(row)]);
},
isExternalEditableColumn(prop) {
return !['externalLineNo', 'matchStatus'].includes(prop);
},
isExternalDateColumn(prop) {
return ['actualDepartureTime', 'actualCompletionTime'].includes(prop);
},
isExternalNumberColumn(prop) {
return ['transportQuantity', 'mileage', 'unitPrice', 'freightAmount', 'settlementAmount'].includes(prop);
},
openExternalAdjust(row) {
const key = this.externalRowKey(row);
this.externalEditSnapshots = {
...this.externalEditSnapshots,
[key]: { ...row },
};
this.externalEditing = { ...this.externalEditing, [key]: true };
},
cancelExternalAdjust(row) {
const key = this.externalRowKey(row);
const snapshot = this.externalEditSnapshots[key];
if (snapshot) Object.assign(row, snapshot);
const editing = { ...this.externalEditing };
const snapshots = { ...this.externalEditSnapshots };
delete editing[key];
delete snapshots[key];
this.externalEditing = editing;
this.externalEditSnapshots = snapshots;
},
async finishExternalAdjust(row) {
row.feeItemsJson = JSON.stringify(row.feeItems || {});
const key = this.externalRowKey(row);
const editing = { ...this.externalEditing };
const snapshots = { ...this.externalEditSnapshots };
delete editing[key];
delete snapshots[key];
this.externalEditing = editing;
this.externalEditSnapshots = snapshots;
this.refreshLocalStats();
this.$message.success('调整已暂存');
},
normalizeExternalDateTime(value) {
if (!value) return null;
const text = String(value);
return text.length === 10 ? `${text}T00:00:00` : text;
},
refreshLocalStats() {
const internalQuantity = this.internalDetails.reduce(
(total, item) => total + Number(item.transportQuantity || 0),
0
);
const internalAmount = this.internalDetails.reduce(
(total, item) => total + Number(item.settlementAmount || 0),
0
);
const externalQuantity = this.externalDetails.reduce(
(total, item) => total + Number(item.transportQuantity || 0),
0
);
const externalAmount = this.externalDetails.reduce(
(total, item) => total + Number(item.settlementAmount || 0),
0
);
const matchedCount = this.internalDetails.filter(item => item.matchResult === 'matched').length;
const internalUnmatched = this.internalDetails.filter(item => item.matchResult !== 'matched').length;
const externalUnmatched = this.externalDetails.filter(item => item.matchStatus !== 'matched').length;
this.form = {
...this.form,
internalBillCount: this.internalDetails.length,
externalBillCount: this.externalDetails.length,
internalQuantity,
internalAmount,
externalQuantity,
externalAmount,
differenceCount:
Math.abs(this.internalDetails.length - this.externalDetails.length) +
Math.min(internalUnmatched, externalUnmatched),
differenceQuantity: Math.abs(internalQuantity - externalQuantity),
differenceAmount: Math.abs(internalAmount - externalAmount),
matchedCount,
unmatchedCount: internalUnmatched + externalUnmatched,
matchStatus:
this.internalDetails.length > 0 &&
this.internalDetails.length === this.externalDetails.length &&
matchedCount === this.internalDetails.length
? 'matched'
: matchedCount > 0
? 'partial'
: 'unmatched',
};
},
refreshExternalStats() {
this.refreshLocalStats();
},
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: '手工调整',
partial_updated: '部分更新',
not_updated: '未更新',
}[value] ||
value ||
'未更新'
);
},
updateTagType(value) {
return value === 'updated' || value === 'manually_adjusted'
? 'success'
: value === 'skipped_multi_cargo' || value === 'partial_updated'
? 'warning'
: 'info';
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
formatMatchedExternalLineNo(value) {
return Number(value) < 0 ? '' : this.displayValue(value);
},
formatTransportQuantity(value) {
return value === null || value === undefined || value === ''
? '-'
: Number(value || 0).toFixed(2);
},
formatMileage(value) {
return Number(value) === -1 ? '' : this.displayValue(value);
},
formatDate(value) {
if (value === null || value === undefined || value === '') return '-';
const date = this.$dayjs(value);
return date.isValid() ? date.format('YYYY-MM-DD') : this.displayValue(value);
},
transportTypeLabel(value) {
if (value === null || value === undefined || value === '') return '-';
return (
this.transportTypeOptions.find(item => String(item.value) === String(value))?.label || 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__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__filter-item {
display: flex;
align-items: center;
min-width: 0;
}
.reconciliation-editor__filter-label {
flex: 0 0 auto;
margin-right: 8px;
color: #606266;
white-space: nowrap;
}
.reconciliation-editor__filter-item :deep(.el-input) {
min-width: 0;
}
.reconciliation-editor__pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.reconciliation-editor-shell--page {
display: block;
}
.reconciliation-editor--page {
padding-bottom: 12px;
}
.reconciliation-editor__page-actions {
position: fixed;
z-index: 20;
right: 0;
bottom: 0;
left: 230px;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 64px;
box-sizing: border-box;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.reconciliation-editor__page-actions .el-button + .el-button {
margin-left: 12px;
}
.reconciliation-editor__help {
color: #606266;
line-height: 1.8;
}
.dialog-form {
margin-top: 16px;
}
.completion-dialog__content {
padding: 4px 8px 0;
}
.completion-dialog__section {
padding: 20px 24px;
border: 1px solid #eff1f7;
background: #fff;
}
.completion-dialog__section + .completion-dialog__section {
margin-top: 16px;
}
.completion-dialog__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px 32px;
}
.completion-dialog__field {
display: flex;
align-items: center;
min-width: 0;
}
.completion-dialog__field--remark {
grid-column: 1 / -1;
}
.completion-dialog__label {
flex: 0 0 auto;
min-width: 88px;
margin-right: 12px;
color: #303133;
font-weight: 600;
text-align: right;
white-space: nowrap;
}
.completion-dialog__value {
min-width: 0;
flex: 1;
box-sizing: border-box;
min-height: 36px;
padding: 8px 12px;
overflow: hidden;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #f5f7fa;
color: #606266;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.completion-dialog__field--remark .completion-dialog__value {
white-space: normal;
}
.completion-dialog__section--fees {
position: relative;
padding-top: 48px;
}
.completion-dialog__section-title {
position: absolute;
top: 20px;
left: 24px;
display: flex;
align-items: center;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.completion-dialog__section-title::before {
width: 4px;
height: 22px;
margin-right: 8px;
background: #409eff;
content: '';
}
.completion-dialog__fee-actions {
position: absolute;
top: 14px;
right: 24px;
display: flex;
gap: 12px;
}
.completion-dialog__fee-actions .el-button + .el-button {
margin-left: 0;
}
.completion-dialog__negative {
color: #f56c6c;
}
.completion-dialog__footer {
display: flex;
justify-content: center;
gap: 16px;
}
.completion-dialog__footer .el-button + .el-button {
margin-left: 0;
}
.hidden-file {
display: none;
}
.reconciliation-editor :deep(.el-form-item) {
margin-bottom: 14px;
}
.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;
}
.reconciliation-editor :deep(.el-table__body tr.reconciliation-editor__row--unmatched > td.el-table__cell) {
color: #f56c6c;
background: #fff1f0 !important;
}
.reconciliation-editor
:deep(.el-table__body tr.reconciliation-editor__row--unmatched > td.el-table__cell .cell) {
color: #f56c6c;
}
.reconciliation-editor
:deep(.el-table__body tr.reconciliation-editor__row--unmatched .status-text) {
color: #f56c6c;
background: #fde2e2;
border-color: #fbc4c4;
}
@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));
}
.completion-dialog__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 680px) {
.completion-dialog__grid {
grid-template-columns: minmax(0, 1fr);
}
.completion-dialog__field--remark {
grid-column: auto;
}
.completion-dialog__section {
padding-right: 12px;
padding-left: 12px;
}
.completion-dialog__section-title {
left: 12px;
}
.completion-dialog__fee-actions {
right: 12px;
}
}
</style>