Files
tms-erp-web/src/views/settlement/components/settlement-adjustment-editor.vue
T

666 lines
24 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>
<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-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 fields" :key="field.prop" :span="6"
><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
: field.prop === 'formalSettlementId'
? form.formalSettlementNo || form.formalSettlementId
: field.prop === 'createTime'
? formatCreateDate(form[field.prop])
: 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-card>
<section-card title="调整费用">
<template #extra>
<el-button
v-if="editable && form.formalSettlementId"
type="primary"
plain
@click="addFeeRow"
>添加费用</el-button
>
</template>
<el-table :data="details" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="feeType" label="费用类型" min-width="180" align="center">
<template #default="{ row }">
<el-select
v-if="editable && row.manualFlag === 1"
v-model="row.feeType"
filterable
allow-create
default-first-option
clearable
placeholder="请选择或输入"
>
<el-option
v-for="item in feeOptions"
:key="item.feeType"
:label="item.feeTypeName || item.feeType"
:value="item.feeType"
/>
</el-select>
<span v-else>{{ displayValue(feeTypeName(row.feeType)) }}</span>
</template>
</el-table-column>
<el-table-column prop="feeItem" label="费用项目" min-width="200" align="center">
<template #default="{ row }">
<el-select
v-if="editable && row.manualFlag === 1"
v-model="row.feeItem"
filterable
allow-create
default-first-option
clearable
placeholder="请选择或输入"
>
<el-option
v-for="item in feeItemOptions(row.feeType)"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<span v-else>{{ displayValue(row.feeItem) }}</span>
</template>
</el-table-column>
<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"
:class="{ negative: Number(row.adjustmentAmountTax) < 0 }"
:precision="2"
:step="0.01"
:controls="false"
@change="recalculate"
/><span v-else :class="{ negative: Number(row.adjustmentAmountTax) < 0 }">{{
formatMoney(row.adjustmentAmountTax)
}}</span></template
></el-table-column
>
<el-table-column
v-if="!editable"
label="调整金额(不含税)"
min-width="170"
align="center"
><template #default="{ row }"
><span>{{ 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-card>
<section-card title="附件材料">
<div class="settlement-adjustment-editor__attachment-actions">
<el-button
type="primary"
:disabled="!attachments.length"
@click="batchDownloadAttachments"
>
批量下载
</el-button>
</div>
<el-table :data="attachments" border @selection-change="selectedAttachments = $event">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="240">
<template #default="{ row }">
<el-input
v-if="editable"
v-model="row.description"
maxlength="200"
placeholder="请输入附件描述"
/>
<span v-else>{{ displayValue(row.description) }}</span>
</template>
</el-table-column>
<el-table-column label="文件大小" width="120" align="center">
<template #default="{ row }">{{ formatFileSize(row) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" align="center" />
<el-table-column prop="uploadTime" label="上传时间" width="170" align="center" />
<el-table-column label="操作" :width="editable ? 130 : 70" fixed="right" align="center">
<template #default="{ row, $index }">
<div class="settlement-adjustment-editor__links">
<el-link type="primary" @click="downloadAttachment(row)">下载</el-link>
<el-link v-if="editable" type="danger" @click="removeAttachment($index)">
删除
</el-link>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="暂无附件材料" /></template>
</el-table>
<div v-show="editable" class="settlement-adjustment-editor__attachment-upload">
<vehicle-attachment-upload
ref="attachmentUploadRef"
v-model="attachments"
:readonly="!editable"
:multiple="true"
:limit="20"
:max-size="500"
:file-types="attachmentFileTypes"
:show-file-list="false"
button-text="上传附件"
@change="handleAttachmentChange"
/>
</div>
</section-card>
</div>
<template #footer
><el-button @click="visible = false">取消</el-button
><el-button
v-if="editable"
type="primary"
plain
:loading="syncing"
:disabled="!form.formalSettlementId"
@click="handleSync"
>同步</el-button
><el-button v-if="editable" type="primary" :loading="saving" @click="handleSave"
>保存</el-button
></template
>
</el-dialog>
</template>
<script>
import * as api from '@/api/settlement/settlementAdjustment';
import {
createSettlementAdjustmentForm,
settlementAdjustmentFormFields,
} from '@/option/settlement/settlementAdjustmentForm';
import { downloadFileByUrl } from '@/utils/util';
export default {
name: 'SettlementAdjustmentEditor',
props: { modelValue: Boolean, recordId: [String, Number], readonly: Boolean },
emits: ['update:modelValue', 'success'],
data: () => ({
loading: false,
saving: false,
syncing: false,
form: createSettlementAdjustmentForm(),
fields: settlementAdjustmentFormFields,
details: [],
attachments: [],
selectedAttachments: [],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
],
candidates: [],
feeOptions: [],
rules: {
formalSettlementId: [{ required: true, message: '请选择关联正式结算单', trigger: 'change' }],
},
}),
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();
const userInfo = this.$store.getters.userInfo || {};
this.form.createTime = this.$dayjs().format('YYYY-MM-DD');
this.form.createUserName = userInfo.realName || userInfo.userName || userInfo.account || '';
this.details = [];
this.attachments = [];
this.selectedAttachments = [];
this.candidates = [];
this.feeOptions = [];
if (!this.recordId) {
await Promise.all([this.loadFormalSettlements(''), this.loadFeeOptions()]);
return;
}
this.loading = true;
try {
const [response] = await Promise.all([api.getDetail(this.recordId), this.loadFeeOptions()]);
const data = response?.data?.data || response?.data || response || {};
this.form = { ...createSettlementAdjustmentForm(), ...data };
this.details = (data.details || []).map(item => ({
...item,
manualFlag:
item.formalSettlementDetailId == null && item.formalSettlementDetailFeeId == null
? 1
: 0,
adjustmentAmountTax: Number(item.adjustmentAmountTax || 0),
adjustmentAmountNoTax:
item.adjustmentAmountNoTax == null ? null : Number(item.adjustmentAmountNoTax),
}));
this.attachments = this.parseAttachments(data.attachmentsJson);
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 loadFeeOptions() {
const { data } = await api.getFeeOptions();
this.feeOptions = data?.data || data || [];
},
async handleFormalChange(id) {
if (!id) {
Object.assign(this.form, {
formalSettlementId: null,
formalSettlementNo: '',
settlementType: '',
projectName: '',
deptName: '',
customerName: '',
contractNo: '',
contractName: '',
originalSettlementAmount: 0,
});
this.details = [];
this.recalculate();
return;
}
const item = this.candidates.find(row => String(row.id) === String(id));
if (!item) return;
const { id: formalSettlementId, ...formalSettlement } = item;
Object.assign(this.form, formalSettlement, {
formalSettlementId,
formalSettlementNo: item.formalSettlementNo,
originalSettlementAmount: Number(item.settlementAmount || 0),
settlementTypeName: item.settlementTypeName,
});
this.details = [];
this.recalculate();
this.loading = true;
try {
const { data } = await api.getFormalDetails(formalSettlementId);
if (String(this.form.formalSettlementId) !== String(formalSettlementId)) return;
this.details = (data?.data || data || []).map(fee => ({
...fee,
manualFlag: 0,
originalAmountTax: Number(fee.originalAmountTax || 0),
adjustmentAmountTax: 0,
adjustmentAmountNoTax: null,
remark: '',
}));
this.recalculate();
} finally {
this.loading = false;
}
},
addFeeRow() {
this.details.push({
formalSettlementDetailId: null,
formalSettlementDetailFeeId: null,
feeType: '',
feeItem: '',
originalAmountTax: 0,
adjustmentAmountTax: 0,
adjustmentAmountNoTax: null,
remark: '',
manualFlag: 1,
});
},
feeTypeName(value) {
return (
this.feeOptions.find(item => String(item.feeType) === String(value))?.feeTypeName || value
);
},
feeItemOptions(feeType) {
return (
this.feeOptions.find(item => String(item.feeType) === String(feeType))?.feeItems || []
);
},
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 handleSync() {
const formalSettlementId = this.form.formalSettlementId;
if (!formalSettlementId) return this.$message.warning('请先选择正式结算单');
this.syncing = true;
try {
const [candidateResponse, detailResponse] = await Promise.all([
api.getFormalSettlements(this.form.formalSettlementNo || ''),
api.getFormalDetails(formalSettlementId),
]);
if (String(this.form.formalSettlementId) !== String(formalSettlementId)) return;
const candidateData = candidateResponse?.data?.data || candidateResponse?.data || [];
const formalSettlements = Array.isArray(candidateData) ? candidateData : [];
const formalSettlement = formalSettlements.find(
item => String(item.id) === String(formalSettlementId)
);
if (formalSettlement) {
const { id, ...formalInformation } = formalSettlement;
Object.assign(this.form, formalInformation, {
formalSettlementId: id,
formalSettlementNo: formalSettlement.formalSettlementNo,
originalSettlementAmount: Number(formalSettlement.settlementAmount || 0),
settlementTypeName: formalSettlement.settlementTypeName,
});
}
const existingDetailMap = new Map(
this.details
.filter(item => item.formalSettlementDetailFeeId != null)
.map(item => [String(item.formalSettlementDetailFeeId), item])
);
const manualDetails = this.details.filter(item => Number(item.manualFlag) === 1);
const detailData = detailResponse?.data?.data || detailResponse?.data || [];
const formalDetails = Array.isArray(detailData) ? detailData : [];
this.details = formalDetails
.map(fee => {
const existing = existingDetailMap.get(String(fee.formalSettlementDetailFeeId));
return {
...fee,
manualFlag: 0,
originalAmountTax: Number(fee.originalAmountTax || 0),
adjustmentAmountTax: Number(existing?.adjustmentAmountTax || 0),
adjustmentAmountNoTax:
existing?.adjustmentAmountNoTax == null
? null
: Number(existing.adjustmentAmountNoTax),
remark: existing?.remark || '',
};
})
.concat(manualDetails);
this.recalculate();
this.$message.success('同步成功');
} finally {
this.syncing = false;
}
},
async handleSave() {
this.saving = true;
try {
const { data } = await api.save({
id: this.form.id || undefined,
formalSettlementId: this.form.formalSettlementId,
attachmentsJson: JSON.stringify(this.attachments || []),
remark: this.form.remark,
details: this.details.map(item => ({
formalSettlementDetailId: item.formalSettlementDetailId,
formalSettlementDetailFeeId: item.formalSettlementDetailFeeId,
feeType: item.feeType,
feeItem: item.feeItem,
originalAmountTax: item.originalAmountTax,
adjustmentAmountTax: item.adjustmentAmountTax,
adjustmentAmountNoTax: item.adjustmentAmountNoTax,
remark: item.remark,
})),
});
this.form.id = data?.data || data || this.form.id;
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);
},
formatCreateDate(value) {
if (!value) return '';
const date = this.$dayjs(value);
return date.isValid() ? date.format('YYYY-MM-DD') : value;
},
handleAttachmentChange(files) {
const userInfo = this.$store.getters.userInfo || {};
const uploadUserName = userInfo.realName || userInfo.userName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
this.attachments = (files || []).map(file => ({
...file,
description: file.description || '',
uploadUserName: file.uploadUserName || uploadUserName,
uploadTime: file.uploadTime || uploadTime,
}));
this.selectedAttachments = [];
},
parseAttachments(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
},
attachmentName(file = {}) {
return file.originalName || file.name || file.fileName || '附件';
},
attachmentUrl(file = {}) {
return file.url || file.link || file.fileUrl || file.domain || '';
},
formatFileSize(file = {}) {
const bytes = Number(file.size || file.fileSize || file.attachSize || 0);
if (!bytes) return '-';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
},
previewAttachment(file) {
this.$refs.attachmentUploadRef?.handlePreview(file);
},
downloadAttachment(file) {
const url = this.attachmentUrl(file);
if (!url) {
this.$message.warning('附件地址为空');
return;
}
downloadFileByUrl(url, this.attachmentName(file));
},
batchDownloadAttachments() {
const files = this.selectedAttachments.length ? this.selectedAttachments : this.attachments;
const downloadableFiles = files.filter(file => this.attachmentUrl(file));
if (!downloadableFiles.length) {
this.$message.warning(
this.selectedAttachments.length ? '所选附件暂无可下载地址' : '暂无可下载附件'
);
return;
}
downloadableFiles.forEach((file, index) => {
window.setTimeout(() => this.downloadAttachment(file), index * 200);
});
},
removeAttachment(index) {
const [removed] = this.attachments.splice(index, 1);
this.selectedAttachments = this.selectedAttachments.filter(file => file !== removed);
},
},
};
</script>
<style scoped lang="scss">
.settlement-adjustment-editor {
max-height: 72vh;
overflow-y: auto;
padding-right: 4px;
}
.settlement-adjustment-editor__summary {
padding: 12px 0;
text-align: right;
}
.settlement-adjustment-editor__attachment-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.settlement-adjustment-editor__attachment-upload {
padding-top: 12px;
}
.settlement-adjustment-editor__links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.settlement-adjustment-editor :deep(.el-form-item) {
margin-bottom: 24px;
}
.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;
}
.settlement-adjustment-editor :deep(.el-input-number.negative .el-input__inner) {
color: #f56c6c;
}
</style>