Merge remote-tracking branch 'origin/feature/kingdee-attachment-sync' into feature/bfi-api-rate

# Conflicts:
#	src/api/payment/invoiceApplication.js
#	src/api/payment/invoiceReceipt.js
#	src/api/payment/paymentApplication.js
#	src/api/settlement/formalSettlement.js
#	src/views/payment/invoice-application.vue
#	src/views/payment/invoice-receipt.vue
#	src/views/payment/payment-application-form.vue
#	src/views/payment/payment-application.vue
#	src/views/settlement/formal-settlement.vue
This commit is contained in:
刘泉佳
2026-09-28 10:07:19 +08:00
15 changed files with 314 additions and 15 deletions
+9
View File
@@ -7,6 +7,15 @@ export const getList = (current, size, params) =>
export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get', params: { id } });
export const queryInvoicePool = params =>
request({ url: `${baseUrl}/invoice-pool`, method: 'get', params });
export const downloadInvoicePoolAttachment = invoiceNo =>
// 金蝶票池发票文件下载(后端代理 A801-2 流式回源),耗时较普通接口长,放宽到 120s
request({
url: `${baseUrl}/invoice-pool/attachment/download`,
method: 'get',
params: { invoiceNo },
responseType: 'blob',
timeout: 120000,
});
export const getSettlementCandidates = (keyword, receiptId) =>
request({
url: `${baseUrl}/settlement-candidates`,
+3
View File
@@ -16,10 +16,13 @@ export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post'
export const approve = data => request({ url: `${baseUrl}/approve`, method: 'post', data });
export const returnBill = data => request({ url: `${baseUrl}/return`, method: 'post', data });
export const voidBill = data => request({ url: `${baseUrl}/void`, method: 'post', data });
// 同步金蝶涉及多次金蝶远程接口调用(A103-2 预检 + A103-1/A103-3 推送),单笔可达 20s+,覆盖全局 10s 超时
export const syncKingdee = id =>
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', timeout: 30000, params: { id } });
// 批量同步逐单单据级事务串行推送,耗时随单量线性增长
export const syncKingdeeBatch = ids =>
request({ url: `${baseUrl}/sync-kingdee-batch`, method: 'post', timeout: 30000, data: ids });
// 回写任务按 paymentNo 分批远程查询 A103-2/A103-4,同样可能超过全局超时
export const syncKingdeeResult = () =>
request({ url: `${baseUrl}/sync-kingdee-result`, method: 'post', timeout: 30000 });
+1
View File
@@ -30,6 +30,7 @@ export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post'
export const approve = data => request({ url: `${baseUrl}/approve`, method: 'post', data });
export const returnBill = data => request({ url: `${baseUrl}/return`, method: 'post', data });
export const voidBill = data => request({ url: `${baseUrl}/void`, method: 'post', data });
// 同步金蝶涉及多次金蝶远程接口调用(A102-4 预检 + A102-2 保存),可达 20s+,覆盖全局 10s 超时
export const syncKingdee = id =>
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', timeout: 30000, params: { id } });
export const getDetailFees = detailId =>
@@ -104,6 +104,24 @@
<el-tag v-else type="info" size="small">未登记</el-tag>
</template>
</el-table-column>
<el-table-column label="附件" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-link
type="primary"
:underline="false"
:disabled="attachmentBusy === row.invoiceNo"
@click.stop="viewAttachment(row)"
>查看</el-link
>
<el-link
type="primary"
:underline="false"
:disabled="attachmentBusy === row.invoiceNo"
@click.stop="downloadAttachment(row)"
>下载</el-link
>
</template>
</el-table-column>
<el-table-column v-if="!multiple" label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-link type="primary" @click.stop="confirmRows([row])">选择</el-link>
@@ -126,6 +144,12 @@
</template>
<script>
import {
downloadKingdeeInvoiceFile,
isKingdeePathAttachment,
viewKingdeeInvoiceFile,
} from '@/utils/kingdee-attachment';
export default {
name: 'KingdeeInvoicePoolDialog',
props: {
@@ -156,6 +180,8 @@ export default {
pendingCursor: null,
// 已浏览页缓存:上一页直接回显不再请求(0 远程调用)
pageCache: {},
// 正在查看/下载附件的发票号(行级 loading 守卫)
attachmentBusy: null,
query: this.emptyQuery(),
};
},
@@ -184,6 +210,50 @@ export default {
rowKey(row) {
return row.invoiceNo || row.id;
},
// 票池行附件字段为复数 attachmentsJson,行内首项即发票文件({source,path,name})
rowAttachment(row = {}) {
const value = row.attachmentsJson;
if (!value) return null;
let parsed = value;
if (typeof value === 'string') {
try {
parsed = JSON.parse(value);
} catch {
return null;
}
}
return Array.isArray(parsed) ? parsed[0] || null : parsed || null;
},
attachmentName(row) {
const attachment = this.rowAttachment(row);
return attachment?.name || `${row.invoiceNo || '发票'}.pdf`;
},
async viewAttachment(row) {
const attachment = this.rowAttachment(row);
if (!isKingdeePathAttachment(attachment)) {
this.$message.warning('当前发票暂无可查看附件');
return;
}
this.attachmentBusy = row.invoiceNo;
try {
await viewKingdeeInvoiceFile(row.invoiceNo, this.attachmentName(row));
} finally {
this.attachmentBusy = null;
}
},
async downloadAttachment(row) {
const attachment = this.rowAttachment(row);
if (!isKingdeePathAttachment(attachment)) {
this.$message.warning('当前发票暂无可下载附件');
return;
}
this.attachmentBusy = row.invoiceNo;
try {
await downloadKingdeeInvoiceFile(row.invoiceNo, this.attachmentName(row));
} finally {
this.attachmentBusy = null;
}
},
handleOpen() {
this.query = this.emptyQuery();
this.query.invoiceNo = this.initialInvoiceNo || '';
+1
View File
@@ -13,6 +13,7 @@ export const invoiceApplicationTableColumns = [
{ prop: 'currentProcessor', label: '当前处理人', minWidth: 130 },
{ prop: 'kingdeeBillNo', label: '金蝶单据号', minWidth: 150 },
{ prop: 'kingdeeStatusName', label: '金蝶单据状态', minWidth: 140 },
{ prop: 'attachmentSyncStatusName', label: '附件同步', minWidth: 100 },
];
export const invoiceApplicationDetailColumns = [
+1
View File
@@ -14,4 +14,5 @@ export const invoiceReceiptTableColumns = [
{ prop: 'kingdeeReceivedInvoiceNo', label: '金蝶单据号', minWidth: 150 },
{ prop: 'kingdeePushStatusName', label: '金蝶状态', minWidth: 130 },
{ prop: 'kingdeePushMsg', label: '失败原因', minWidth: 160 },
{ prop: 'attachmentSyncStatusName', label: '附件同步', minWidth: 100 },
];
+1
View File
@@ -17,4 +17,5 @@ export const paymentApplicationTableColumns = [
{ prop: 'currentProcessor', label: '当前处理人', minWidth: 120 },
{ prop: 'kingdeeBillNo', label: '金蝶单据号', minWidth: 130 },
{ prop: 'kingdeeStatusName', label: '金蝶单据状态', minWidth: 130 },
{ prop: 'attachmentSyncStatusName', label: '附件同步', minWidth: 100 },
];
@@ -21,6 +21,7 @@ export const formalSettlementTableColumns = [
{ label: '当前处理人', prop: 'currentProcessor', minWidth: 130 },
{ label: '金蝶单据号', prop: 'kingdeeBillNo', minWidth: 150 },
{ label: '金蝶单据状态', prop: 'kingdeeSyncStatus', minWidth: 135 },
{ label: '附件同步', prop: 'attachmentSyncStatusName', minWidth: 100 },
{ label: '创建人', prop: 'createUserName', minWidth: 110 },
{ label: '创建时间', prop: 'createTime', minWidth: 170 },
];
+90
View File
@@ -0,0 +1,90 @@
/**
* 金蝶侧发票附件查看/下载公共工具
*
* 票池发票附件为 {source:'piaozone', path, name}:path 是金蝶侧文件 id(非 http 直链),
* 须由后端代理 A801-2 流式回源;OSS 直链附件(url/link/fileUrl)仍由前端直连下载/预览。
* 三处入口共用:票池弹窗、正式结算认领发票行、付款申请发票附件行。
*/
import { ElMessage } from 'element-plus';
import { downloadFile } from '@/utils/util';
import { downloadInvoicePoolAttachment } from '@/api/payment/invoiceReceipt';
/**
* 金蝶侧附件判定:source=piaozone,或有 path 且非 http(s) 直链
* @param {Object|Null} attachment 附件对象(attachmentsJson/attachmentJson 行内首项)
* @returns {Boolean}
*/
export const isKingdeePathAttachment = attachment => {
if (!attachment) return false;
if (attachment.source === 'piaozone') return true;
return Boolean(attachment.path) && !/^https?:\/\//i.test(attachment.path);
};
/**
* 请求金蝶文件流:成功返回 Blob;业务失败为 HTTP 200 + R.fail JSON(响应通道未写流时后端即失败),
* 按 Content-Type 识别并抛出带 msg 的错误;网络级失败拦截器已提示,标记 silent 避免重复提示
* @param {String} invoiceNo 发票号码
* @returns {Promise<Blob>}
*/
const requestKingdeeFile = async invoiceNo => {
let res;
try {
res = await downloadInvoicePoolAttachment(invoiceNo);
} catch (error) {
throw Object.assign(new Error(error?.message || '附件下载失败'), { silent: true });
}
const blob = res?.data;
if (!(blob instanceof Blob)) {
throw new Error('响应不是文件流');
}
if (blob.type && blob.type.includes('application/json')) {
let message = '附件下载失败';
try {
const parsed = JSON.parse(await blob.text());
message = parsed.msg || parsed.message || message;
} catch {
// 错误体非 JSON 时按默认提示
}
throw new Error(message);
}
return blob;
};
/**
* 下载金蝶侧发票附件(经 A801-2 后端代理),失败统一 ElMessage 提示
* @param {String} invoiceNo 发票号码
* @param {String} fileName 下载文件名(缺省按 发票号.pdf)
*/
export const downloadKingdeeInvoiceFile = async (invoiceNo, fileName) => {
if (!invoiceNo) {
ElMessage.warning('当前发票缺少发票号码,无法下载金蝶侧附件');
return;
}
try {
const blob = await requestKingdeeFile(invoiceNo);
downloadFile(blob, fileName || `${invoiceNo}.pdf`);
} catch (error) {
if (!error?.silent) ElMessage.error(error?.message || '金蝶附件下载失败');
}
};
/**
* 新窗口预览金蝶侧发票附件:拉取 Blob 转 objectURL(PDF 可预览;OFD 等浏览器不可预览的格式由浏览器触发下载)
* @param {String} invoiceNo 发票号码
* @param {String} fileName 预览文件名(缺省按 发票号.pdf)
*/
export const viewKingdeeInvoiceFile = async (invoiceNo, fileName) => {
if (!invoiceNo) {
ElMessage.warning('当前发票缺少发票号码,无法预览金蝶侧附件');
return;
}
try {
const blob = await requestKingdeeFile(invoiceNo);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// 延迟释放 objectURL:立即回收可能导致新窗口尚未加载完成即失效
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (error) {
if (!error?.silent) ElMessage.error(error?.message || '金蝶附件预览失败');
}
};
+12 -1
View File
@@ -411,7 +411,18 @@ export default {
try {
await Promise.all(rows.map(row => api.syncKingdee(row.id)));
this.$message.success(`已同步${rows.length}条开票申请`);
this.loadTable();
await this.loadTable();
// 附件联带同步提示:同步成功但附件未全部上传时追加 warning,重推即自动补传
const idSet = new Set(rows.map(row => String(row.id)));
const pendingCount = (this.rows || []).filter(row => idSet.has(String(row.id))
&& ['pending', 'partial'].includes(row.attachmentSyncStatus)).length;
if (pendingCount) {
this.$message({
type: 'warning',
message: `其中${pendingCount}单附件未全部上传,可重推补传`,
duration: 8000,
});
}
} finally {
this.batchSyncing = false;
}
+7 -2
View File
@@ -378,8 +378,13 @@ export default {
this.pushLoading = true;
try {
const summary = this.unwrapData(await api.pushKingdee(rows.map(row => row.id)));
this.$alert(summary || '推送完成', '推送结果', { confirmButtonText: '知道了' });
this.loadTable();
await this.loadTable();
// 附件联带同步提示:推送成功但附件未全部上传时在结果弹窗追加说明,重推即自动补传
const idSet = new Set(rows.map(row => String(row.id)));
const pendingCount = (this.rows || []).filter(row => idSet.has(String(row.id))
&& ['pending', 'partial'].includes(row.attachmentSyncStatus)).length;
const attachmentTip = pendingCount ? `\n其中${pendingCount}单附件未全部上传,可重推补传` : '';
this.$alert((summary || '推送完成') + attachmentTip, '推送结果', { confirmButtonText: '知道了' });
} finally {
this.pushLoading = false;
}
+45 -5
View File
@@ -281,8 +281,18 @@
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row, $index }">
<div class="payment-form-page__invoice-actions">
<el-link type="primary" @click="viewInvoiceAttachment(row)">查看</el-link>
<el-link type="primary" @click="downloadInvoiceAttachment(row)">下载</el-link>
<el-link
type="primary"
:disabled="kingdeeAttachmentBusy"
@click="viewInvoiceAttachment(row)"
>查看</el-link
>
<el-link
type="primary"
:disabled="kingdeeAttachmentBusy"
@click="downloadInvoiceAttachment(row)"
>下载</el-link
>
<el-link
v-if="!readonly"
type="danger"
@@ -663,6 +673,11 @@ import {
import { readSettlementTransfer, removeSettlementTransfer } from '@/utils/settlement-transfer';
import { downloadFileByUrl } from '@/utils/util';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import {
downloadKingdeeInvoiceFile,
isKingdeePathAttachment,
viewKingdeeInvoiceFile,
} from '@/utils/kingdee-attachment';
import PdfPreview from '@/components/pdf-preview/main.vue';
const attachmentViewerPlugins = [
@@ -743,6 +758,8 @@ export default {
form: emptyForm(),
amountSyncing: false,
transferPayload: null,
// 金蝶侧发票附件查看/下载进行中(行组级 loading 守卫)
kingdeeAttachmentBusy: false,
referenceRows: [],
paymentRecords: [],
referenceVisible: false,
@@ -1584,8 +1601,19 @@ export default {
this.attachmentFileName(this.invoiceAttachment(row)) || `${row.invoiceNo || '发票'}.pdf`
);
},
viewInvoiceAttachment(row) {
async viewInvoiceAttachment(row) {
const attachment = this.invoiceAttachment(row);
// 金蝶侧附件(path 型)经后端代理 A801-2 预览,OSS 直链维持本地预览组件
if (isKingdeePathAttachment(attachment)) {
if (this.kingdeeAttachmentBusy) return;
this.kingdeeAttachmentBusy = true;
try {
await viewKingdeeInvoiceFile(row.invoiceNo, this.invoiceAttachmentName(row));
} finally {
this.kingdeeAttachmentBusy = false;
}
return;
}
if (!this.attachmentFileUrl(attachment)) {
this.$message.warning('当前发票暂无可查看附件');
return;
@@ -1595,8 +1623,20 @@ export default {
[attachment]
);
},
downloadInvoiceAttachment(row) {
const url = this.invoiceAttachmentUrl(row);
async downloadInvoiceAttachment(row) {
const attachment = this.invoiceAttachment(row);
// 金蝶侧附件(path 型)经后端代理 A801-2 下载,OSS 直链维持前端直连下载
if (isKingdeePathAttachment(attachment)) {
if (this.kingdeeAttachmentBusy) return;
this.kingdeeAttachmentBusy = true;
try {
await downloadKingdeeInvoiceFile(row.invoiceNo, this.invoiceAttachmentName(row));
} finally {
this.kingdeeAttachmentBusy = false;
}
return;
}
const url = this.attachmentFileUrl(attachment);
if (!url) {
this.$message.warning('当前发票暂无可下载附件');
return;
+17 -2
View File
@@ -406,7 +406,8 @@ export default {
async handleSyncRow(row) {
const data = this.unwrapData(await api.syncKingdee(row.id));
this.$message.success(`已生成金蝶付款单:${data}`);
this.loadTable();
await this.loadTable();
this.warnAttachmentNotUploaded([row.id]);
},
async handleSync() {
if (this.syncLoading) return;
@@ -428,11 +429,25 @@ export default {
} else {
this.$message.success(`已同步${data.length}条付款申请`);
}
this.loadTable();
await this.loadTable();
this.warnAttachmentNotUploaded(rows.map(row => row.id));
} finally {
this.syncLoading = false;
}
},
// 附件联带同步提示:同步成功但附件未全部上传时追加 warning,重推即自动补传
warnAttachmentNotUploaded(ids) {
const idSet = new Set(ids.map(String));
const pendingCount = (this.rows || []).filter(row => idSet.has(String(row.id))
&& ['pending', 'partial'].includes(row.attachmentSyncStatus)).length;
if (pendingCount) {
this.$message({
type: 'warning',
message: `其中${pendingCount}单附件未全部上传,可重推补传`,
duration: 8000,
});
}
},
async handleSyncResult() {
if (this.resultSyncing) return;
this.resultSyncing = true;
@@ -420,8 +420,18 @@
<el-table-column label="操作" width="220" align="center">
<template #default="{ row, $index }">
<div class="formal-editor__links">
<el-link type="primary" @click="viewInvoiceAttachment(row)">查看</el-link>
<el-link type="primary" @click="downloadInvoiceAttachment(row)">下载</el-link>
<el-link
type="primary"
:disabled="kingdeeAttachmentBusy"
@click="viewInvoiceAttachment(row)"
>查看</el-link
>
<el-link
type="primary"
:disabled="kingdeeAttachmentBusy"
@click="downloadInvoiceAttachment(row)"
>下载</el-link
>
<el-link v-if="editable" type="danger" @click="removeInvoice($index)">删除</el-link>
</div>
</template>
@@ -933,6 +943,11 @@ import { h } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import { downloadFileByUrl } from '@/utils/util';
import {
downloadKingdeeInvoiceFile,
isKingdeePathAttachment,
viewKingdeeInvoiceFile,
} from '@/utils/kingdee-attachment';
import KingdeeInvoicePoolDialog from '@/components/kingdee-invoice-pool-dialog/main.vue';
import * as XLSX from 'xlsx';
@@ -972,6 +987,8 @@ export default {
invoices: [],
invoiceClaimNo: '',
invoiceClaimLoading: false,
// 金蝶侧发票附件查看/下载进行中(行组级 loading 守卫)
kingdeeAttachmentBusy: false,
invoiceClaimDialog: {
visible: false,
selected: [],
@@ -2355,12 +2372,36 @@ export default {
const attachment = this.invoiceAttachment(row);
return attachment.originalName || attachment.name || `${row.invoiceNo || '发票'}.pdf`;
},
viewInvoiceAttachment(row) {
async viewInvoiceAttachment(row) {
const attachment = this.invoiceAttachment(row);
// 金蝶侧附件(path 型)经后端代理 A801-2 预览,OSS 直链维持 window.open
if (isKingdeePathAttachment(attachment)) {
if (this.kingdeeAttachmentBusy) return;
this.kingdeeAttachmentBusy = true;
try {
await viewKingdeeInvoiceFile(row.invoiceNo, this.invoiceAttachmentName(row));
} finally {
this.kingdeeAttachmentBusy = false;
}
return;
}
const url = this.invoiceAttachmentUrl(row);
if (!url) return this.$message.warning('当前发票暂无可查看附件');
window.open(url, '_blank');
},
downloadInvoiceAttachment(row) {
async downloadInvoiceAttachment(row) {
const attachment = this.invoiceAttachment(row);
// 金蝶侧附件(path 型)经后端代理 A801-2 下载,OSS 直链维持前端直连下载
if (isKingdeePathAttachment(attachment)) {
if (this.kingdeeAttachmentBusy) return;
this.kingdeeAttachmentBusy = true;
try {
await downloadKingdeeInvoiceFile(row.invoiceNo, this.invoiceAttachmentName(row));
} finally {
this.kingdeeAttachmentBusy = false;
}
return;
}
const url = this.invoiceAttachmentUrl(row);
if (!url) return this.$message.warning('当前发票暂无可下载附件');
downloadFileByUrl(url, this.invoiceAttachmentName(row));
+11 -1
View File
@@ -387,12 +387,22 @@ export default {
if (this.syncLoading) return;
const row = this.selectedOne('同步金蝶');
if (!row) return;
this.syncLoading = true;
try {
const response = await api.syncKingdee(row.id);
const data = this.unwrapData(response);
this.$message.success(`同步成功,金蝶单据号:${data}`);
this.loadTable();
await this.loadTable();
// 附件联带同步提示:同步成功但附件未全部上传时追加 warning,重推即自动补传
const freshRow = (this.rows || []).find(item => String(item.id) === String(row.id));
if (freshRow && ['pending', 'partial'].includes(freshRow.attachmentSyncStatus)) {
this.$message({
type: 'warning',
message: '该单附件未全部上传,可重推补传',
duration: 8000,
});
}
} finally {
this.syncLoading = false;
}