|
|
@@ -74,9 +74,29 @@
|
|
|
</el-form>
|
|
|
|
|
|
<el-divider content-position="left">收款明细(1-1000 笔,单笔金额 ≥ 1 元)</el-divider>
|
|
|
+ <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px">
|
|
|
+ <el-button type="primary" plain icon="Download" @click="downloadTemplate">
|
|
|
+ 下载明细模板
|
|
|
+ </el-button>
|
|
|
+ <el-upload
|
|
|
+ action=""
|
|
|
+ :auto-upload="false"
|
|
|
+ :on-change="handleImportFileChange"
|
|
|
+ accept=".xlsx,.xls"
|
|
|
+ :file-list="importFileList"
|
|
|
+ :limit="1"
|
|
|
+ >
|
|
|
+ <el-button type="primary" plain icon="Upload">导入 Excel 明细</el-button>
|
|
|
+ <template #tip>
|
|
|
+ <div class="el-upload__tip">
|
|
|
+ 模板列:收款方账号 / 账号类型 / 收款方姓名 / 金额(元) / 备注;解析失败不影响已有明细
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-upload>
|
|
|
+ </div>
|
|
|
<el-table :data="detailRows" border stripe>
|
|
|
<template #empty>
|
|
|
- <el-empty description="暂无明细,请添加明细行" />
|
|
|
+ <el-empty description="暂无明细,请添加明细行或导入 Excel" />
|
|
|
</template>
|
|
|
<el-table-column label="明细单号" min-width="200">
|
|
|
<template #default="{ row }">
|
|
|
@@ -161,6 +181,7 @@ import BatchPayAPI from "@/api/module_payment/batch";
|
|
|
import { useEnterpriseStore } from "@/store";
|
|
|
import { ElMessage } from "element-plus";
|
|
|
import type { FormInstance, FormRules } from "element-plus";
|
|
|
+import * as ExcelJS from "exceljs";
|
|
|
|
|
|
const emit = defineEmits<{ created: [] }>();
|
|
|
|
|
|
@@ -207,6 +228,8 @@ const IDENTITY_TYPE_OPTIONS = [
|
|
|
{ value: "ALIPAY_OPEN_ID", label: "支付宝账户(OpenID)" },
|
|
|
];
|
|
|
|
|
|
+const IDENTITY_TYPE_CODES = IDENTITY_TYPE_OPTIONS.map((o) => o.value);
|
|
|
+
|
|
|
interface DetailRow {
|
|
|
out_biz_no: string;
|
|
|
payee_identity: string;
|
|
|
@@ -218,12 +241,17 @@ interface DetailRow {
|
|
|
|
|
|
const detailRows = ref<DetailRow[]>([]);
|
|
|
const reportInfos = ref<Array<{ info_type: string; info_content: string }>>([]);
|
|
|
+const importFileList = ref<any[]>([]);
|
|
|
|
|
|
let detailSeq = 0;
|
|
|
-function addDetailRow() {
|
|
|
+function genOutBizNo() {
|
|
|
detailSeq += 1;
|
|
|
+ return `D${Date.now()}${String(detailSeq).padStart(2, "0")}`;
|
|
|
+}
|
|
|
+
|
|
|
+function addDetailRow() {
|
|
|
detailRows.value.push({
|
|
|
- out_biz_no: `D${Date.now()}${String(detailSeq).padStart(2, "0")}`,
|
|
|
+ out_biz_no: genOutBizNo(),
|
|
|
payee_identity: "",
|
|
|
payee_identity_type: "ALIPAY_LOGON_ID",
|
|
|
payee_name: "",
|
|
|
@@ -232,6 +260,136 @@ function addDetailRow() {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+/** 下载 Excel 模板(类型列带下拉校验 + 示例行) */
|
|
|
+async function downloadTemplate() {
|
|
|
+ const workbook = new ExcelJS.Workbook();
|
|
|
+ const worksheet = workbook.addWorksheet("批量付款明细模板");
|
|
|
+ worksheet.columns = [
|
|
|
+ { header: "收款方账号(必填)", key: "payee_identity", width: 28 },
|
|
|
+ {
|
|
|
+ header: "账号类型(ALIPAY_LOGON_ID / ALIPAY_USER_ID / ALIPAY_OPEN_ID)",
|
|
|
+ key: "payee_identity_type",
|
|
|
+ width: 32,
|
|
|
+ },
|
|
|
+ { header: "收款方姓名(LOGON_ID 必填,其余选填)", key: "payee_name", width: 22 },
|
|
|
+ { header: "金额(元)(必填,≥1)", key: "amount", width: 16 },
|
|
|
+ { header: "备注(选填)", key: "remark", width: 24 },
|
|
|
+ ];
|
|
|
+ worksheet.addRow({
|
|
|
+ payee_identity: "13800000000",
|
|
|
+ payee_identity_type: "ALIPAY_LOGON_ID",
|
|
|
+ payee_name: "张三",
|
|
|
+ amount: 100,
|
|
|
+ remark: "8月佣金",
|
|
|
+ });
|
|
|
+ // 类型列下拉数据校验(2-1001 行,覆盖 1000 条明细上限)
|
|
|
+ for (let row = 2; row <= 1001; row++) {
|
|
|
+ worksheet.getCell(`B${row}`).dataValidation = {
|
|
|
+ type: "list",
|
|
|
+ formulae: [`"${IDENTITY_TYPE_CODES.join(",")}"`],
|
|
|
+ allowBlank: true,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ const buffer = await workbook.xlsx.writeBuffer();
|
|
|
+ const blob = new Blob([buffer], {
|
|
|
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
+ });
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ const a = document.createElement("a");
|
|
|
+ a.href = url;
|
|
|
+ a.download = "批量付款明细模板.xlsx";
|
|
|
+ a.click();
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+}
|
|
|
+
|
|
|
+/** 单元格取值(兼容 rich text 对象) */
|
|
|
+function cellText(cell: ExcelJS.Cell): string {
|
|
|
+ const v = cell.value;
|
|
|
+ if (v == null) return "";
|
|
|
+ if (typeof v === "object" && "text" in v) return String((v as { text: string }).text).trim();
|
|
|
+ return String(v).trim();
|
|
|
+}
|
|
|
+
|
|
|
+/** 解析 Excel 并追加到明细行;任何错误都不破坏现有手填行 */
|
|
|
+async function parseImportFile(file: File) {
|
|
|
+ let workbook: ExcelJS.Workbook;
|
|
|
+ try {
|
|
|
+ workbook = new ExcelJS.Workbook();
|
|
|
+ await workbook.xlsx.load(await file.arrayBuffer());
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e);
|
|
|
+ ElMessage.error("文件解析失败,请使用模板 .xlsx 格式");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const worksheet = workbook.worksheets[0];
|
|
|
+ const parsed: DetailRow[] = [];
|
|
|
+ const errors: string[] = [];
|
|
|
+
|
|
|
+ worksheet.eachRow((row, rowNumber) => {
|
|
|
+ if (rowNumber === 1) return; // 表头
|
|
|
+ const payeeIdentity = cellText(row.getCell(1));
|
|
|
+ const identityTypeRaw = cellText(row.getCell(2));
|
|
|
+ const payeeName = cellText(row.getCell(3));
|
|
|
+ const amountRaw = cellText(row.getCell(4));
|
|
|
+ const remark = cellText(row.getCell(5));
|
|
|
+
|
|
|
+ // 整行空白跳过
|
|
|
+ if (!payeeIdentity && !identityTypeRaw && !payeeName && !amountRaw && !remark) return;
|
|
|
+
|
|
|
+ const rowErrors: string[] = [];
|
|
|
+ const identityType = identityTypeRaw
|
|
|
+ ? IDENTITY_TYPE_CODES.find((c) => c === identityTypeRaw) || ""
|
|
|
+ : "ALIPAY_LOGON_ID";
|
|
|
+ if (!payeeIdentity) rowErrors.push("收款方账号为空");
|
|
|
+ if (identityTypeRaw && !identityType) rowErrors.push(`账号类型非法: ${identityTypeRaw}`);
|
|
|
+ if (identityType === "ALIPAY_LOGON_ID" && !payeeName) rowErrors.push("收款方姓名为空");
|
|
|
+ const amount = Number(amountRaw);
|
|
|
+ if (!amountRaw || Number.isNaN(amount)) rowErrors.push("金额非法");
|
|
|
+ else if (amount <= 0) rowErrors.push("金额必须大于 0");
|
|
|
+
|
|
|
+ if (rowErrors.length) {
|
|
|
+ errors.push(`第 ${rowNumber} 行: ${rowErrors.join("; ")}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ parsed.push({
|
|
|
+ out_biz_no: "",
|
|
|
+ payee_identity: payeeIdentity,
|
|
|
+ payee_identity_type: identityType,
|
|
|
+ payee_name: payeeName,
|
|
|
+ amount,
|
|
|
+ remark,
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ if (errors.length) {
|
|
|
+ ElMessage.error(
|
|
|
+ `Excel 校验失败 ${errors.length} 处(最多显示前 3 处): ${errors.slice(0, 3).join(" | ")}`
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (parsed.length === 0) {
|
|
|
+ ElMessage.warning("Excel 中未解析到有效明细行");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (detailRows.value.length + parsed.length > 1000) {
|
|
|
+ ElMessage.warning(
|
|
|
+ `导入后将超过 1000 笔上限(现有 ${detailRows.value.length} 笔 + 导入 ${parsed.length} 笔),请分批导入`
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ parsed.forEach((r) => {
|
|
|
+ r.out_biz_no = genOutBizNo();
|
|
|
+ detailRows.value.push(r);
|
|
|
+ });
|
|
|
+ ElMessage.success(`已导入 ${parsed.length} 条明细`);
|
|
|
+}
|
|
|
+
|
|
|
+async function handleImportFileChange(file: any) {
|
|
|
+ importFileList.value = [file.raw];
|
|
|
+ await parseImportFile(file.raw);
|
|
|
+ importFileList.value = [];
|
|
|
+}
|
|
|
+
|
|
|
async function handleSubmit() {
|
|
|
if (!enterpriseId.value) {
|
|
|
ElMessage.warning("请先选择企业");
|
|
|
@@ -247,13 +405,23 @@ async function handleSubmit() {
|
|
|
ElMessage.warning("每批明细最多 1000 笔");
|
|
|
return;
|
|
|
}
|
|
|
+ // 后端规则:仅 LOGON_ID 时姓名必填(校验姓名一致),UID/OPEN_ID 选填
|
|
|
const invalidRow = detailRows.value.find(
|
|
|
- (r) => !r.out_biz_no || !r.payee_identity || !r.payee_name || r.amount == null || r.amount <= 0
|
|
|
+ (r) =>
|
|
|
+ !r.out_biz_no ||
|
|
|
+ !r.payee_identity ||
|
|
|
+ (r.payee_identity_type === "ALIPAY_LOGON_ID" && !r.payee_name) ||
|
|
|
+ r.amount == null ||
|
|
|
+ r.amount <= 0
|
|
|
);
|
|
|
if (invalidRow) {
|
|
|
- ElMessage.warning("收款明细存在未填写完整的行(明细单号/账号/姓名/金额必填)");
|
|
|
+ ElMessage.warning("收款明细存在未填写完整的行(明细单号/账号/金额必填,LOGON_ID 需姓名)");
|
|
|
return;
|
|
|
}
|
|
|
+ // 过滤 info_type 与 info_content 均为空的行
|
|
|
+ const sceneReportInfos = reportInfos.value
|
|
|
+ .filter((r) => r.info_type.trim() !== "" || r.info_content.trim() !== "")
|
|
|
+ .map((r) => ({ info_type: r.info_type.trim(), info_content: r.info_content.trim() }));
|
|
|
submitting.value = true;
|
|
|
try {
|
|
|
const res = await BatchPayAPI.batchCreate({
|
|
|
@@ -262,9 +430,7 @@ async function handleSubmit() {
|
|
|
payer_uid: form.payer_uid,
|
|
|
agreement_no: form.agreement_no || undefined,
|
|
|
transfer_scene_name: form.transfer_scene_name || undefined,
|
|
|
- transfer_scene_report_infos: reportInfos.value.length
|
|
|
- ? reportInfos.value.map((r) => ({ info_type: r.info_type, info_content: r.info_content }))
|
|
|
- : undefined,
|
|
|
+ transfer_scene_report_infos: sceneReportInfos.length ? sceneReportInfos : undefined,
|
|
|
time_expire: form.time_expire || undefined,
|
|
|
remark: form.remark || undefined,
|
|
|
details: detailRows.value.map((r) => ({
|
|
|
@@ -278,19 +444,27 @@ async function handleSubmit() {
|
|
|
});
|
|
|
createdResult.value = res.data.data;
|
|
|
emit("created");
|
|
|
+ // 成功后清空表单,保留成功提示
|
|
|
+ clearForm();
|
|
|
} finally {
|
|
|
submitting.value = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function handleReset() {
|
|
|
+function clearForm() {
|
|
|
formRef.value?.resetFields();
|
|
|
detailRows.value = [];
|
|
|
reportInfos.value = [];
|
|
|
- createdResult.value = null;
|
|
|
+ importFileList.value = [];
|
|
|
+ detailSeq = 0;
|
|
|
addDetailRow();
|
|
|
}
|
|
|
|
|
|
+function handleReset() {
|
|
|
+ clearForm();
|
|
|
+ createdResult.value = null;
|
|
|
+}
|
|
|
+
|
|
|
onMounted(addDetailRow);
|
|
|
</script>
|
|
|
|