✨ 评分减分累计与最高扣分、用户部门标签联动树,并修复对账导入与结算调整等

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 04:28:22 +08:00
parent 8d9fe998c0
commit 2a5eca8393
17 changed files with 691 additions and 307 deletions
@@ -380,7 +380,13 @@ export default {
},
async loadFormalSettlements(keyword = '') {
const { data } = await api.getFormalSettlements(keyword);
this.candidates = data?.data || data || [];
const payload = data?.data ?? data;
// 兼容误返回分页对象的情况,只取正式结算单列表
this.candidates = Array.isArray(payload)
? payload
: Array.isArray(payload?.records)
? payload.records
: [];
},
async loadFeeOptions() {
const { data } = await api.getFeeOptions();
@@ -926,14 +926,34 @@ const stripInternalRowFields = row => {
};
};
/** 合并费用列名与各行 feeItems 键,避免保存/回显时丢动态费用列 */
const collectFeeItemNames = (preferred = [], detailRows = []) => {
const names = [];
const seen = new Set();
const pushName = value => {
const name = String(value || '').trim();
if (!name || seen.has(name)) return;
seen.add(name);
names.push(name);
};
(preferred || []).forEach(pushName);
(detailRows || []).forEach(row => {
const feeItems = row?.feeItems;
if (!feeItems || typeof feeItems !== 'object' || Array.isArray(feeItems)) return;
Object.keys(feeItems).forEach(pushName);
});
return names;
};
const buildPayload = () => {
const { file, ...batch } = form;
const payloadRows = rows.value.map(stripInternalRowFields);
return {
...batch,
settlementBillType: form.settlementType === 'payable' ? form.settlementBillType || 'formal' : 'formal',
waybillMode: form.waybillMode === 'history' ? 'history' : 'new',
feeItemNames: [...feeItemNames.value],
rows: rows.value.map(stripInternalRowFields),
feeItemNames: collectFeeItemNames(feeItemNames.value, payloadRows),
rows: payloadRows,
};
};
@@ -1436,9 +1456,13 @@ const loadDetail = async id => {
if (detail.projectId) {
await loadContractsByProject(detail.projectId, detail.projectName || '');
}
feeItemNames.value = Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [];
parsedHeaders.value = [];
const detailRows = Array.isArray(detail.rows) ? detail.rows : [];
// 先按库中列名与明细 feeItems 合并,再规范化行数据,保证草稿再打开仍展示全部费用列
feeItemNames.value = collectFeeItemNames(
Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [],
detailRows
);
rows.value = detailRows.map((item, index) => normalizeParsedRow(item, index));
refreshDuplicateFlags();
// 查看模式始终只读;编辑时仅草稿可改,已完成/已作废强制只读
@@ -125,15 +125,17 @@
<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>
<el-button
type="primary"
plain
:disabled="!filteredInternalRows.length"
@click="exportInternalDetails"
>导出</el-button
>
<div class="reconciliation-editor__filter-actions">
<el-button @click="resetInternalQuery">重置</el-button>
<el-button type="primary" @click="internalFilterTick++">查询</el-button>
<el-button
type="primary"
plain
:disabled="!filteredInternalRows.length"
@click="exportInternalDetails"
>导出</el-button
>
</div>
</div>
<el-table
:data="filteredInternalRows"
@@ -189,6 +191,9 @@
"
>{{ formatDate(row[column.prop]) }}</span
>
<span v-else-if="column.prop === 'unitPrice'">{{
formatOptionalMoney(row.unitPrice)
}}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -318,6 +323,9 @@
"
>{{ formatDate(row[column.prop]) }}</span
>
<span v-else-if="column.prop === 'unitPrice'">{{
formatOptionalMoney(row.unitPrice)
}}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -476,6 +484,7 @@
<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.prop === 'unitPrice'">{{ formatOptionalMoney(row.unitPrice) }}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -630,7 +639,10 @@ import { getDictionary } from '@/api/system/dictbiz';
import { Search } from '@element-plus/icons-vue';
import { h } from 'vue';
import { downloadXls } from '@/utils/util';
import { isExcelResponse, parseBlobJson } from '@/utils/import-excel';
import {
consumeImportResponse,
resolveImportErrorMessage,
} from '@/utils/import-excel';
import { transportReconciliationFormFields } from '@/option/settlement/transportReconciliationForm';
import {
internalColumns,
@@ -1042,12 +1054,24 @@ export default {
.map(normalized)
.join('|');
},
blankOptionalNumber(value) {
if (value === null || value === undefined || value === '' || Number(value) === -1) {
return null;
}
return value;
},
normalizeInternalRow(row) {
const feeItems =
row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length
? row.feeItems
: this.parseFeeItems(row.feeItemsJson);
return { ...row, feeItems };
return {
...row,
feeItems,
unitPrice: this.blankOptionalNumber(row.unitPrice),
mileage: this.blankOptionalNumber(row.mileage),
freightAmount: this.blankOptionalNumber(row.freightAmount),
};
},
normalizeExternalRow(row, index) {
const feeItems =
@@ -1057,6 +1081,9 @@ export default {
return {
...row,
feeItems,
unitPrice: this.blankOptionalNumber(row.unitPrice),
mileage: this.blankOptionalNumber(row.mileage),
freightAmount: this.blankOptionalNumber(row.freightAmount),
_externalKey: row.id || row.externalLineNo || `external-${index}`,
};
},
@@ -1284,6 +1311,9 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
snapshot.unitPrice = this.blankOptionalNumber(row.unitPrice);
snapshot.mileage = this.blankOptionalNumber(row.mileage);
snapshot.freightAmount = this.blankOptionalNumber(row.freightAmount);
return snapshot;
});
},
@@ -1297,6 +1327,9 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
snapshot.unitPrice = this.blankOptionalNumber(row.unitPrice);
snapshot.mileage = this.blankOptionalNumber(row.mileage);
snapshot.freightAmount = this.blankOptionalNumber(row.freightAmount);
snapshot.suspectedDuplicate = duplicateIds.has(String(row.id));
return snapshot;
});
@@ -1326,7 +1359,7 @@ export default {
}
},
async handleUpdate() {
await this.$confirm('将以外部账单数据更新运单及内部结算明细,是否继续?', '更新账单', {
await this.$confirm('将以外部账单数据更新内部结算明细,是否继续?', '更新账单', {
type: 'warning',
});
this.actionLoading = true;
@@ -1508,22 +1541,23 @@ export default {
? await api.importCargo(this.currentId, file)
: await api.importVehicle(this.currentId, file);
// 成功时 axios 拦截器可能已把 JSON Blob 解析成对象,不能再调 .text()
if (isExcelResponse(response)) {
downloadXls(
response.data,
`运输对账导入失败明细${this.$dayjs().format('YYYY-MM-DD HH-mm-ss')}.xlsx`
);
const consumed = await consumeImportResponse(response, {
businessName: '运输对账',
dayjsFormat: this.$dayjs().format('YYYY-MM-DD HH-mm-ss'),
});
if (consumed.kind === 'excel') {
downloadXls(consumed.blob, consumed.fileName);
this.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const result = await parseBlobJson(response.data);
if (Number(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 || '外部账单导入失败');
if (!error?._notified) {
this.$message.error(await resolveImportErrorMessage(error));
}
}
},
async downloadTemplate(mode) {
@@ -1831,7 +1865,10 @@ export default {
: Number(value || 0).toFixed(2);
},
formatMileage(value) {
return Number(value) === -1 ? '' : this.displayValue(value);
return this.blankOptionalNumber(value) === null ? '' : this.displayValue(value);
},
formatOptionalMoney(value, currency = 'RMB') {
return this.blankOptionalNumber(value) === null ? '-' : this.formatMoney(value, currency);
},
formatDate(value) {
if (value === null || value === undefined || value === '') return '-';
@@ -1952,15 +1989,26 @@ export default {
}
.reconciliation-editor__filter {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto auto;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.reconciliation-editor__filter-item {
display: flex;
align-items: center;
min-width: 0;
}
.reconciliation-editor__filter-actions {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.reconciliation-editor__filter-actions .el-button {
min-width: 64px;
margin-left: 0;
}
.reconciliation-editor__filter-label {
flex: 0 0 auto;
margin-right: 8px;
@@ -6,15 +6,33 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed, getCurrentInstance, nextTick, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import SettlementImportPage from './components/settlement-import-page.vue';
const route = useRoute();
const store = useStore();
const instance = getCurrentInstance();
const pageTitle = computed(() => {
if (route.query.mode === 'view') return '查看导入结算单';
return route.query.id ? '编辑导入结算单' : '新增导入结算单';
});
const syncTagTitle = () => {
nextTick(() => {
store.commit('SET_TAG', {
fullPath: route.fullPath,
name: pageTitle.value,
});
instance?.proxy?.$router?.$avueRouter?.setTitle?.(pageTitle.value);
});
};
onMounted(() => {
syncTagTitle();
});
</script>
<style scoped lang="scss">
+6 -3
View File
@@ -239,19 +239,22 @@ export default {
this.onLoad(this.page);
},
openCreate() {
this.$router.push({ path: '/settlement/settlement-import/form' });
this.$router.push({
path: '/settlement/settlement-import/form',
query: { name: '新增导入结算单' },
});
},
openDetail(row) {
if (!row?.id) return;
this.$router.push({
path: '/settlement/settlement-import/form',
query: { id: row.id, mode: 'view' },
query: { id: row.id, mode: 'view', name: '查看导入结算单' },
});
},
editDraft(row) {
this.$router.push({
path: '/settlement/settlement-import/form',
query: { id: row.id },
query: { id: row.id, name: '编辑导入结算单' },
});
},
async handleDelete(row) {