1、调整凭证

2、调整运单
This commit is contained in:
2026-09-08 17:50:34 +08:00
parent a7315b4491
commit cba2ad3b3b
43 changed files with 5730 additions and 1119 deletions
+438 -106
View File
@@ -22,13 +22,13 @@
><el-input v-model="query.waybillBatchNo" clearable placeholder="请输入"
/></el-form-item>
<template v-if="searchExpanded">
<el-form-item label="上传来源"
<el-form-item v-if="!isCarrier" label="上传来源"
><el-select v-model="query.uploadSource" clearable placeholder="全部"
><el-option label="内部" value="内部" /><el-option
label="承运商"
value="承运商" /></el-select
></el-form-item>
<el-form-item label="承运商"
<el-form-item v-if="!isCarrier" label="承运商"
><el-input v-model="query.carrierName" clearable placeholder="请输入"
/></el-form-item>
<el-form-item label="创建日期"
@@ -77,7 +77,8 @@
min-width="200"
show-overflow-tooltip
/>
<el-table-column prop="uploadSource" label="上传来源" width="100" /><el-table-column
<el-table-column v-if="!isCarrier" prop="uploadSource" label="上传来源" width="100" /><el-table-column
v-if="!isCarrier"
prop="carrierName"
label="承运商名称"
min-width="160"
@@ -99,13 +100,19 @@
width="120"
fixed="right"
align="center"
/><el-table-column label="操作" width="160" fixed="right" align="left"
/><el-table-column label="操作" width="200" fixed="right" align="left"
><template #default="{ row }"
><div class="voucher-manage-page__actions">
<el-link
v-if="row.processStatus === '处理完成' && row.auditStatus === '待审核'"
v-if="!isCarrier && row.processStatus === '处理完成' && row.auditStatus === '待审核' && hasPermission('vouchermanage_audit')"
type="primary"
>流程</el-link
@click="handleAuditPass(row)"
>审核通过</el-link
><el-link
v-if="!isCarrier && row.processStatus === '处理完成' && row.auditStatus === '待审核' && hasPermission('vouchermanage_audit')"
type="warning"
@click="handleAuditReject(row)"
>审核驳回</el-link
><el-link v-if="row.processStatus === '处理完成'" type="primary" @click="view(row)"
>查看</el-link
><el-link
@@ -142,63 +149,60 @@
</section>
</basic-container>
<el-dialog v-model="detailVisible" width="960px" destroy-on-close>
<template #header>
<div class="voucher-manage-page__dialog-title">凭证详情</div>
</template>
<el-descriptions v-loading="detailLoading" :column="2" border class="voucher-manage-page__detail-desc">
<el-descriptions-item label="项目名称">
{{ detailValue(detailRow.projectName) }}
</el-descriptions-item>
<el-descriptions-item label="凭证批次号">
{{ detailValue(detailRow.voucherBatchNo) }}
</el-descriptions-item>
<el-descriptions-item label="运单批次号" :span="2">
{{ detailValue(detailRow.waybillBatchNo) }}
</el-descriptions-item>
<el-descriptions-item label="文件名称" :span="2">
<el-link v-if="detailRow.fileUrl" type="primary" @click="download(detailRow)">
{{ detailValue(detailRow.fileName) }}
</el-link>
<template v-else>{{ detailValue(detailRow.fileName) }}</template>
</el-descriptions-item>
<el-descriptions-item label="上传来源">
{{ detailValue(detailRow.uploadSource) }}
</el-descriptions-item>
<el-descriptions-item label="承运商名称">
{{ detailValue(detailRow.carrierName) }}
</el-descriptions-item>
<el-descriptions-item label="处理状态">
{{ detailValue(detailRow.processStatus) }}
</el-descriptions-item>
<el-descriptions-item label="审核状态">
{{ detailValue(detailRow.auditStatus) }}
</el-descriptions-item>
<el-descriptions-item label="凭证数量">
{{ detailValue(detailRow.voucherCount) }}
</el-descriptions-item>
<el-descriptions-item label="已关联运单">
{{ detailValue(detailRow.relatedWaybillCount) }}
</el-descriptions-item>
<el-descriptions-item label="未关联运单">
{{ detailValue(detailRow.unRelatedWaybillCount) }}
</el-descriptions-item>
<el-descriptions-item label="创建人">
{{ detailValue(detailRow.createUserName) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ detailValue(detailRow.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="更新人">
{{ detailValue(detailRow.updateUserName) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ detailValue(detailRow.updateTime) }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
</template>
<el-dialog
v-model="detailVisible"
title="执行凭证详情"
width="92%"
top="5vh"
class="voucher-detail-dialog"
destroy-on-close
>
<div v-loading="detailLoading" class="voucher-manage-page__detail">
<div class="voucher-manage-page__detail-summary">
<div class="voucher-manage-page__detail-field">
<span class="voucher-manage-page__detail-label">执行凭证号</span>
<span class="voucher-manage-page__detail-value">{{ detailVoucherNo }}</span>
</div>
<div class="voucher-manage-page__detail-field">
<span class="voucher-manage-page__detail-label">文件夹名称</span>
<span class="voucher-manage-page__detail-value">{{ detailFolderName }}</span>
</div>
<div class="voucher-manage-page__detail-field">
<span class="voucher-manage-page__detail-label">是否关联运单</span>
<span class="voucher-manage-page__detail-value">{{ detailRelatedWaybill }}</span>
</div>
<div class="voucher-manage-page__detail-field">
<span class="voucher-manage-page__detail-label">配载单号</span>
<el-link
v-if="detailLoadingNo !== '-'"
class="voucher-manage-page__detail-value voucher-manage-page__detail-link"
type="primary"
>
{{ detailLoadingNo }}
</el-link>
<span v-else class="voucher-manage-page__detail-value">-</span>
</div>
</div>
<div v-if="detailImages.length" class="voucher-manage-page__detail-images">
<el-image
v-for="(image, index) in detailImages"
:key="image.url || index"
class="voucher-manage-page__detail-image"
:src="image.url"
:preview-src-list="detailImageUrls"
:initial-index="index"
fit="cover"
preview-teleported
/>
</div>
<div v-if="detailOtherFiles.length" class="voucher-manage-page__detail-files">
<div v-for="file in detailOtherFiles" :key="file.id || file.url || file.name" class="voucher-manage-page__detail-file">
<span class="voucher-manage-page__detail-file-name">{{ file.name }}</span>
<el-link v-if="file.url" type="primary" @click="openDetailFile(file)">查看</el-link>
</div>
</div>
<el-empty v-if="!detailImages.length && !detailOtherFiles.length" description="暂无凭证文件" :image-size="80" />
</div>
</el-dialog>
<el-dialog
@@ -217,7 +221,7 @@
label-width="auto"
class="voucher-manage-page__upload-form"
>
<el-form-item label="项目名称" prop="projectId"
<el-form-item v-if="!isCarrier" label="项目名称" prop="projectId"
><el-select
v-model="editing.projectId"
class="voucher-manage-page__project-select"
@@ -231,7 +235,7 @@
:label="item.projectName"
:value="item.id" /></el-select
></el-form-item>
<el-form-item label="执行凭证" prop="fileUrl"
<el-form-item label="执行凭证" required
><el-upload
action="#"
accept=".zip,.7z"
@@ -246,7 +250,7 @@
>上传</el-button
><template #tip
><span class="voucher-manage-page__upload-tip"
>支持 zip7z 格式采用分片上传上传中断后可在上传进度继续上传</span
>请上传车辆的执行凭证压缩包支持zip7z格式 上传附件大于50M时为超大附件确认提交后可在批量补录->上传任务中查看上传进度</span
></template
></el-upload
><el-progress
@@ -325,8 +329,9 @@
ref="batchTableRef"
:data="batchRows"
border
@selection-change="batchSelection = $event"
><el-table-column type="selection" width="56" /><el-table-column
highlight-current-row
@current-change="batchSelection = $event ? [$event] : []"
><el-table-column
type="index"
label="序号"
width="80" /><el-table-column
@@ -337,7 +342,13 @@
label="创建时间"
min-width="180" /><el-table-column label="运单数" width="120"
><template #default="{ row }">{{ row.waybillCount ?? '-' }}</template></el-table-column
><el-table-column prop="createUserName" label="创建人" min-width="140"
><el-table-column prop="createUserName" label="创建人" min-width="140" /><el-table-column
label="操作"
width="100"
align="center"
><template #default="{ row }"
><el-link type="primary" @click="selectBatch(row)">选择</el-link></template
></el-table-column
/></el-table>
<div class="voucher-manage-page__pagination">
<el-pagination
@@ -458,13 +469,39 @@
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { computed, reactive, ref, watch, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import md5 from 'js-md5';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getList as getProjectList } from '@/api/business/project-apply';
import * as api from '@/api/business/voucher-manage';
import SectionCard from '@/components/section-card/main.vue';
const router = useRouter();
const store = useStore();
// 权限判断
const permission = computed(() => store.getters.permission);
const userInfo = computed(() => store.getters.userInfo);
const isAdmin = computed(() => userInfo.value?.authority?.includes('admin'));
const hasPermission = code => {
if (isAdmin.value) return true;
return permission.value?.[code] === true;
};
// 判断当前用户是否为承运商(顶级组织是否为"外部组织")
const isCarrier = ref(false);
const checkIfCarrier = () => {
const userInfoData = userInfo.value || {};
const topDeptName = userInfoData.top_dept_name || userInfoData.topDeptName || '';
isCarrier.value = topDeptName === '外部组织';
};
onMounted(() => {
checkIfCarrier();
});
const loading = ref(false),
rows = ref([]),
createRange = ref([]),
@@ -834,6 +871,16 @@ const resetBatchQuery = () => {
batchPage.current = 1;
loadBatches();
};
const selectBatch = row => {
const batches = new Map();
selectedBatches.value.forEach(item => {
if (!batches.has(item.batchNo)) batches.set(item.batchNo, item);
});
if (!batches.has(row.batchNo)) batches.set(row.batchNo, row);
selectedBatches.value = [...batches.values()];
editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
batchVisible.value = false;
};
const confirmBatches = () => {
const batches = new Map();
batchSelection.value.forEach(item => {
@@ -935,24 +982,218 @@ const removeRow = row =>
});
const detailValue = value =>
value === undefined || value === null || value === '' ? '-' : value;
const view = async row => {
detailVisible.value = true;
detailLoading.value = true;
detailRow.value = { ...row };
try {
const res = await api.getDetail(row.id);
detailRow.value = res.data?.data || row;
} catch (error) {
ElMessage.error(error.message || '获取凭证详情失败');
} finally {
detailLoading.value = false;
const firstDetailValue = (row, keys) => {
for (const key of keys) {
const value = row?.[key];
if (value !== undefined && value !== null && value !== '') return value;
}
return undefined;
};
const detailVoucherNo = computed(() =>
detailValue(
firstDetailValue(detailRow.value, ['voucherNo', 'executeVoucherNo', 'voucherBatchNo'])
)
);
const detailFolderName = computed(() =>
detailValue(
firstDetailValue(detailRow.value, [
'folderName',
'directoryName',
'sourceFolderName',
'voucherFolderName',
'folder',
'fileName',
])
)
);
const detailRelatedWaybill = computed(() => {
const value = firstDetailValue(detailRow.value, [
'isRelatedWaybill',
'isRelateWaybill',
'isRelationWaybill',
'relatedWaybill',
'relatedWaybillFlag',
'hasRelatedWaybill',
]);
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'number') return value > 0 ? '是' : '否';
if (typeof value === 'string' && value.trim()) {
if (['1', 'true', 'yes', '是'].includes(value.trim().toLowerCase())) return '是';
if (['0', 'false', 'no', '否'].includes(value.trim().toLowerCase())) return '否';
return value;
}
const count = firstDetailValue(detailRow.value, ['relatedWaybillCount', 'waybillCount']);
if (count !== undefined && count !== null && count !== '') {
return Number(count) > 0 ? '是' : '否';
}
return firstDetailValue(detailRow.value, ['waybillBatchNo', 'waybillNo']) ? '是' : '-';
});
const detailLoadingNo = computed(() =>
detailValue(
firstDetailValue(detailRow.value, [
'loadingNo',
'loadingCode',
'loadBatchNo',
'loadingBatchNo',
'stowageNo',
'stowageCode',
'stowageBatchNo',
'shipmentNo',
'waybillBatchNo',
'waybillNo',
])
)
);
const imageFieldKeys = [
'voucherImages',
'voucherImageList',
'executeVoucherImages',
'executeVoucherImageList',
'voucherImgList',
'voucherImageUrls',
'imageList',
'imgList',
'imageUrls',
'images',
'pictureList',
'pictures',
'photoList',
'attachments',
'attachmentList',
'fileList',
];
const fileFieldKeys = ['voucherFiles', 'files', 'fileDetails', 'fileList'];
const toDetailImageItems = value => {
if (Array.isArray(value)) return value.flatMap(item => toDetailImageItems(item));
if (typeof value === 'string') {
const text = value.trim();
if (!text) return [];
try {
return toDetailImageItems(JSON.parse(text));
} catch {
return text.split(/[,\n;]/).map(item => item.trim()).filter(Boolean);
}
}
if (value && typeof value === 'object') {
const nested = value.records || value.list || value.items || value.data || value.urls;
if (nested !== undefined) return toDetailImageItems(nested);
}
return value && typeof value === 'object' ? [value] : [];
};
const normalizeDetailImage = item => {
if (typeof item === 'string') return { url: item, name: item.split('/').pop() };
const url =
item?.url ||
item?.link ||
item?.fileUrl ||
item?.downloadUrl ||
item?.domain ||
item?.src ||
item?.imageUrl ||
item?.path;
return url ? { url: String(url), name: item.name || item.fileName || '' } : null;
};
const normalizeDetailFile = item => {
if (typeof item === 'string') {
return { name: item.split('/').pop() || item, url: item, fileType: '' };
}
const url = item?.url || item?.link || item?.fileUrl || item?.downloadUrl || item?.domain || item?.src;
const name = item?.fileName || item?.name || item?.entryName || item?.imageName || '-';
return {
...item,
id: item?.id,
name,
url: url ? String(url) : '',
fileType: item?.fileType || '',
contentType: item?.contentType || '',
};
};
const isDetailImage = file =>
file.fileType === 'image' ||
String(file.contentType || '').startsWith('image/') ||
/\.(jpe?g|png|bmp|webp|gif)$/i.test(file.name || '');
const detailFiles = computed(() => {
for (const key of fileFieldKeys) {
const value = detailRow.value?.[key];
if (value === undefined || value === null || value === '') continue;
const files = toDetailImageItems(value).map(normalizeDetailFile).filter(Boolean);
if (files.length) return files;
}
return [];
});
const detailImages = computed(() => {
if (detailFiles.value.length) return detailFiles.value.filter(file => isDetailImage(file));
for (const key of imageFieldKeys) {
const value = detailRow.value?.[key];
if (value === undefined || value === null || value === '') continue;
const images = toDetailImageItems(value).map(normalizeDetailImage).filter(Boolean);
if (images.length) return images;
}
const singleImage = normalizeDetailImage(detailRow.value?.voucherImage || detailRow.value?.image);
return singleImage ? [singleImage] : [];
});
const detailOtherFiles = computed(() =>
detailFiles.value.filter(file => !isDetailImage(file))
);
const detailImageUrls = computed(() => detailImages.value.map(image => image.url));
const openDetailFile = file => {
if (file?.url) window.open(file.url, '_blank');
};
const view = async row => {
router.push({
path: '/business/voucher-manage/detail',
query: { id: row.id, batchNo: row.voucherBatchNo },
});
};
const download = row => window.open(row.fileUrl, '_blank');
const handleAuditPass = row =>
ElMessageBox.confirm(`确认审核通过凭证批次"${row.voucherBatchNo}"吗?`, '提示', {
type: 'warning',
}).then(async () => {
await api.auditPass(row.id);
ElMessage.success('审核通过');
load();
});
const handleAuditReject = row =>
ElMessageBox.prompt('请输入驳回原因(可选)', '审核驳回', {
confirmButtonText: '确认',
cancelButtonText: '取消',
inputPattern: /.*/,
}).then(async ({ value }) => {
await api.auditReject(row.id, value || '');
ElMessage.success('审核驳回');
load();
});
load();
</script>
<style lang="scss" scoped>
:global(.voucher-detail-dialog.el-dialog) {
max-width: 996px;
border: 1px solid #dcdfe6;
border-radius: 0;
overflow: hidden;
}
:global(.voucher-detail-dialog .el-dialog__header) {
padding: 20px 24px;
margin-right: 0;
//background: #f5f5f5;
border-bottom: 1px solid #dcdfe6;
}
:global(.voucher-detail-dialog .el-dialog__headerbtn) {
top: 20px;
right: 24px;
}
:global(.voucher-detail-dialog .el-dialog__body) {
max-height: calc(100vh - 130px);
padding: 0;
overflow-y: auto;
background: #fff;
}
.voucher-manage-page {
&__search {
padding: 12px 12px 4px;
@@ -986,21 +1227,6 @@ load();
padding: 0;
min-height: auto;
}
&__dialog-title {
display: flex;
align-items: center;
gap: 8px;
color: #303133;
font-size: 18px;
font-weight: 600;
&::before {
width: 4px;
height: 18px;
background: #409eff;
content: '';
}
}
&__actions {
display: flex;
flex-wrap: wrap;
@@ -1025,6 +1251,20 @@ load();
margin-left: 16px;
color: #606266;
}
&__upload-progress {
margin-top: 12px;
:deep(.el-progress__text) {
font-size: 14px;
font-weight: 500;
}
:deep(.el-progress-bar__outer) {
background: #eff1f7;
border-radius: 6px;
}
:deep(.el-progress-bar__inner) {
border-radius: 6px;
}
}
&__progress-cell {
display: flex;
align-items: center;
@@ -1076,16 +1316,108 @@ load();
:deep(.el-table__body tr:nth-child(even) > td) {
background: #fafafa;
}
&__detail-desc {
:deep(.el-descriptions__table) {
table-layout: fixed;
th, td {
width: 50%;
}
.el-descriptions__label,
.el-descriptions__content {
width: 50%;
}
&__detail {
min-height: 420px;
padding: 40px 46px 68px;
box-sizing: border-box;
}
&__detail-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: 56px;
row-gap: 16px;
margin-bottom: 32px;
}
&__detail-field {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
column-gap: 16px;
align-items: center;
min-height: 24px;
font-size: 14px;
line-height: 24px;
}
&__detail-label,
&__detail-value {
color: #303133;
white-space: nowrap;
}
&__detail-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
&__detail-link {
justify-self: start;
font-size: inherit;
line-height: inherit;
}
&__detail-images {
display: grid;
grid-template-columns: repeat(5, 140px);
gap: 32px;
justify-content: start;
}
&__detail-image {
width: 140px;
height: 100px;
box-sizing: border-box;
overflow: hidden;
cursor: zoom-in;
background: #fff;
border: 6px solid #cfcfcf;
:deep(.el-image__inner) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__detail-files {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #eff1f7;
}
&__detail-file {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
}
&__detail-file-name {
min-width: 0;
overflow: hidden;
color: #303133;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (max-width: 900px) {
.voucher-manage-page {
&__detail-images {
grid-template-columns: repeat(4, 140px);
}
}
}
@media (max-width: 680px) {
.voucher-manage-page {
&__detail {
padding: 28px 24px 40px;
}
&__detail-summary {
grid-template-columns: 1fr;
row-gap: 12px;
}
&__detail-images {
grid-template-columns: repeat(2, minmax(0, 140px));
gap: 20px;
}
}
}