// 商户中心 —— 发票管理路由:/api/invoice/*(写操作,部分为"透传中台"语义但 mock 实际维护 // 本地状态以保证 demo 可跑通)+ /api/invoice-management/*(本地查询)。 import express from 'express' import { invoices, repayments } from '../db/seedInvoice.js' import { nextId } from '../utils/id.js' import { paginate } from '../utils/pagination.js' import { sendOk, sendFail } from '../utils/response.js' import { requireAuth } from '../middleware/requireAuth.js' const router = express.Router() router.use(requireAuth) function nowStr() { return new Date().toISOString().slice(0, 19).replace('T', ' ') } function recalcInvoiceStatus(invoice) { const matched = Number(invoice.matchedAmount || 0) const total = Number(invoice.invoiceAmount || 0) if (matched <= 0) invoice.matchStatus = 'UNMATCHED' else if (matched >= total) invoice.matchStatus = 'MATCHED' else invoice.matchStatus = 'PARTIAL_MATCH' } function recalcRepaymentStatus(repayment) { const matched = Number(repayment.matchedAmount || 0) const total = Number(repayment.repaymentAmount || 0) if (repayment.status === 'SETTLED') return repayment.status = matched >= total ? 'MATCHED' : 'PENDING' } // ---------------- /api/invoice/* (写操作) ---------------- router.post('/invoice/create', (req, res) => { const body = req.body || {} if (!body.merchantId) return sendFail(res, '所属商户不能为空', 400) if (!body.invoiceCode) return sendFail(res, '发票代码不能为空', 400) if (!body.invoiceNo) return sendFail(res, '发票号码不能为空', 400) if (!body.invoiceType) return sendFail(res, '发票类型不能为空', 400) if (!body.invoiceAmount) return sendFail(res, '发票金额不能为空', 400) if (invoices.some((inv) => inv.invoiceCode === body.invoiceCode && inv.invoiceNo === body.invoiceNo)) { return sendFail(res, '该发票已登记,请勿重复上传', 400) } const now = nowStr() const entity = { ...body, id: nextId('invoice'), matchStatus: 'UNMATCHED', matchedAmount: '0.00', settleStatus: 'UNSETTLED', settledAmount: '0.00', matchRecordList: [], createdAt: now, updatedAt: now } invoices.push(entity) sendOk(res, entity, '发票登记成功') }) router.post('/invoice/batch-create', (req, res) => { const list = Array.isArray(req.body) ? req.body : [] let successCount = 0 const failureDetails = [] const now = nowStr() list.forEach((body, index) => { if (!body.invoiceCode || !body.invoiceNo || !body.invoiceType || !body.invoiceAmount) { failureDetails.push(`第${index + 1}行:必填字段缺失`) return } if (invoices.some((inv) => inv.invoiceCode === body.invoiceCode && inv.invoiceNo === body.invoiceNo)) { failureDetails.push(`第${index + 1}行:发票${body.invoiceNo}已登记,请勿重复上传`) return } invoices.push({ ...body, id: nextId('invoice'), matchStatus: 'UNMATCHED', matchedAmount: '0.00', settleStatus: 'UNSETTLED', settledAmount: '0.00', matchRecordList: [], createdAt: now, updatedAt: now }) successCount += 1 }) sendOk( res, { totalCount: list.length, successCount, failCount: list.length - successCount, failureDetails: failureDetails.join(';') }, '批量登记完成' ) }) router.post('/invoice/match', (req, res) => { const { channelNo, invoiceNo, data } = req.body || {} if (!channelNo) return sendFail(res, '所属渠道不能为空', 400) const invoice = invoices.find((inv) => inv.invoiceNo === invoiceNo) if (!invoice) return sendFail(res, '发票不存在', 404) if (invoice.invoiceStatus !== 'ACTIVE') return sendFail(res, '该发票已作废,不可匹配', 400) if (!Array.isArray(data) || !data.length) return sendFail(res, '匹配数据不能为空', 400) const now = nowStr() data.forEach((item) => { const repayment = repayments.find((r) => r.repaymentSerialNo === item.depositSerialNo) const matchAmount = Number(item.matchAmount || 0) invoice.matchRecordList = invoice.matchRecordList || [] invoice.matchRecordList.push({ id: nextId('invoiceMatchRecord'), repaymentSerialNo: item.depositSerialNo, repaymentAmount: repayment ? repayment.repaymentAmount : '0.00', matchAmount: matchAmount.toFixed(2), matchType: 'manual', matchTime: now, remark: '' }) invoice.matchedAmount = (Number(invoice.matchedAmount || 0) + matchAmount).toFixed(2) if (repayment) { repayment.matchedAmount = (Number(repayment.matchedAmount || 0) + matchAmount).toFixed(2) recalcRepaymentStatus(repayment) } }) recalcInvoiceStatus(invoice) invoice.updatedAt = now sendOk(res, true, '发票匹配成功') }) router.post('/invoice/settle', (req, res) => { const { channelNo, invoiceNo } = req.body || {} if (!channelNo) return sendFail(res, '所属渠道不能为空', 400) const invoice = invoices.find((inv) => inv.invoiceNo === invoiceNo) if (!invoice) return sendFail(res, '发票不存在', 404) if (invoice.invoiceStatus !== 'ACTIVE') return sendFail(res, '该发票已作废,不可清算', 400) if (invoice.settleStatus === 'SETTLED') return sendFail(res, '该发票已结算,请勿重复清算', 400) invoice.settleStatus = Number(invoice.matchedAmount || 0) >= Number(invoice.invoiceAmount || 0) ? 'SETTLED' : 'PARTIAL_SETTLED' invoice.settledAmount = invoice.matchedAmount invoice.updatedAt = nowStr() ;(repayments || []).forEach((r) => { if (invoice.matchRecordList?.some((m) => m.repaymentSerialNo === r.repaymentSerialNo)) { r.status = 'SETTLED' } }) sendOk(res, true, '发票清算成功') }) router.post('/invoice/delete', (req, res) => { const { channelNo, invoiceNo } = req.body || {} if (!channelNo) return sendFail(res, '所属渠道不能为空', 400) const invoice = invoices.find((inv) => inv.invoiceNo === invoiceNo) if (!invoice) return sendFail(res, '发票不存在', 404) if (invoice.matchStatus !== 'UNMATCHED') return sendFail(res, '该发票已匹配,不可作废', 400) invoice.invoiceStatus = 'CANCELLED' invoice.updatedAt = nowStr() sendOk(res, true, '发票作废成功') }) router.post('/invoice/unmatch', (req, res) => { const { matchRecordId } = req.body || {} const invoice = invoices.find((inv) => (inv.matchRecordList || []).some((m) => m.id === matchRecordId)) if (!invoice) return sendFail(res, '匹配记录不存在', 404) if (invoice.settleStatus !== 'UNSETTLED') return sendFail(res, '已进入清算流程的匹配记录不可取消', 400) const record = invoice.matchRecordList.find((m) => m.id === matchRecordId) invoice.matchRecordList = invoice.matchRecordList.filter((m) => m.id !== matchRecordId) invoice.matchedAmount = Math.max(0, Number(invoice.matchedAmount || 0) - Number(record.matchAmount || 0)).toFixed(2) recalcInvoiceStatus(invoice) invoice.updatedAt = nowStr() const repayment = repayments.find((r) => r.repaymentSerialNo === record.repaymentSerialNo) if (repayment) { repayment.matchedAmount = Math.max(0, Number(repayment.matchedAmount || 0) - Number(record.matchAmount || 0)).toFixed(2) recalcRepaymentStatus(repayment) } sendOk(res, true, '已取消匹配') }) // ---------------- /api/invoice-management/* (本地查询) ---------------- router.post('/invoice-management/invoice/list', (req, res) => { const { merchantIdIs, invoiceNoLike, registerDateStart, registerDateEnd, invoiceStatusList, matchStatusList, settleStatusList, accountNoLike, oppAccountNoLike, amountMin, amountMax, page, pageSize } = req.body || {} let list = invoices if (merchantIdIs) list = list.filter((inv) => inv.merchantId === merchantIdIs) if (invoiceNoLike) list = list.filter((inv) => inv.invoiceNo.includes(invoiceNoLike)) if (registerDateStart) list = list.filter((inv) => inv.registerDate >= registerDateStart) if (registerDateEnd) list = list.filter((inv) => inv.registerDate <= registerDateEnd) if (invoiceStatusList?.length) list = list.filter((inv) => invoiceStatusList.includes(inv.invoiceStatus)) if (matchStatusList?.length) list = list.filter((inv) => matchStatusList.includes(inv.matchStatus)) if (settleStatusList?.length) list = list.filter((inv) => settleStatusList.includes(inv.settleStatus)) if (accountNoLike) list = list.filter((inv) => inv.accountNo && inv.accountNo.includes(accountNoLike)) if (oppAccountNoLike) list = list.filter((inv) => inv.oppAccountNo && inv.oppAccountNo.includes(oppAccountNoLike)) if (amountMin !== undefined && amountMin !== null && amountMin !== '') list = list.filter((inv) => Number(inv.invoiceAmount) >= Number(amountMin)) if (amountMax !== undefined && amountMax !== null && amountMax !== '') list = list.filter((inv) => Number(inv.invoiceAmount) <= Number(amountMax)) list = [...list].sort((a, b) => (a.registerDate < b.registerDate ? 1 : -1)) sendOk(res, paginate(list, page, pageSize)) }) router.post('/invoice-management/invoice/detail', (req, res) => { const { id } = req.body || {} const invoice = invoices.find((inv) => inv.id === id) if (!invoice) return sendFail(res, '发票不存在', 404) sendOk(res, invoice) }) router.post('/invoice-management/repayment/list', (req, res) => { const { merchantIdIs, accountNoLike, oppAccountNoLike, timeStart, timeEnd, page, pageSize } = req.body || {} let list = repayments if (merchantIdIs) list = list.filter((r) => r.merchantId === merchantIdIs) if (accountNoLike) list = list.filter((r) => r.accountNo && r.accountNo.includes(accountNoLike)) if (oppAccountNoLike) list = list.filter((r) => r.oppAccountNo && r.oppAccountNo.includes(oppAccountNoLike)) if (timeStart) list = list.filter((r) => r.repaymentTime >= timeStart) if (timeEnd) list = list.filter((r) => r.repaymentTime <= timeEnd) sendOk(res, paginate(list, page, pageSize)) }) export default router