package com.ruoyi.app.order; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.ruoyi.app.utils.ezPay.EzPay; import com.ruoyi.app.utils.ezPay.EzPayConfig; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.system.domain.PosOrder; import com.ruoyi.system.domain.PosOrderInvoice; import com.ruoyi.system.domain.PosStore; import com.ruoyi.system.domain.PosStoreEzpay; import com.ruoyi.system.domain.InfoUser; import com.ruoyi.system.domain.dto.ApplyInvoiceDto; import com.ruoyi.system.domain.vo.PosOrderInvoiceVo; import com.ruoyi.system.mapper.PosOrderInvoiceMapper; import com.ruoyi.system.mapper.PosOrderMapper; import com.ruoyi.system.mapper.PosStoreEzpayMapper; import com.ruoyi.system.mapper.PosStoreMapper; import com.ruoyi.system.service.IInfoUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** * 订单电子发票 开票 / 作废 / 查询 业务(放 admin,因需调用 {@link EzPay})。 * *

开票链路({@link #applyInvoice}):校验订单归属/状态/支付 → 校验门店可开票(009 凭证 + 免用发票) * → 金额拆分(不含运费)→ 组装 ezPay issue 参数 + 逐商品明细 → 调 {@link EzPay#issueInvoice} * → 判读回应 → 落库。作废走 {@link EzPay#doPost} + invoice_invalid。 * * @author ruoyi * @date 2026-06-16 */ @Service public class OrderInvoiceService { @Autowired private EzPay ezPay; @Autowired private PosOrderMapper posOrderMapper; @Autowired private PosStoreMapper posStoreMapper; @Autowired private PosStoreEzpayMapper posStoreEzpayMapper; @Autowired private PosOrderInvoiceMapper posOrderInvoiceMapper; @Autowired private IInfoUserService infoUserService; /** ezPay 接口根地址(从 application.yml 的 ezpay.base-url 注入,测试/正式在此切换) */ @Value("${ezpay.base-url}") private String ezpayBaseUrl; /** 发票状态:0未开/1已开/2失败/3作废 */ private static final int STATUS_NOT_ISSUED = 0; private static final int STATUS_ISSUED = 1; private static final int STATUS_FAILED = 2; private static final int STATUS_INVALID = 3; /** 订单完成态、已支付 */ private static final long ORDER_STATE_DONE = 3L; private static final long ORDER_PAID = 1L; // ==================== US1/US2/US3:客户开票 ==================== /** * 客户申请开票。校验 → 金额拆分 → 调 ezPay 即时开立 → 落库。 * B2C/B2B/载具由 {@link #buildIssueData} 按 {@code category}/{@code carrierType} 分支处理。 */ public PosOrderInvoiceVo applyInvoice(ApplyInvoiceDto dto, Long currentUserId) { Long orderId = dto.getOrderId(); PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper().eq(PosOrder::getDdId,orderId.toString())); if (order == null) { throw new ServiceException("订单不存在"); } if (!Objects.equals(order.getUserId(), currentUserId)) { throw new ServiceException("无权操作该订单"); } if (order.getState() == null || order.getState() != ORDER_STATE_DONE) { throw new ServiceException("订单未完成,暂不可开票"); } if (order.getPayStatus() == null || order.getPayStatus() != ORDER_PAID) { throw new ServiceException("订单未支付,暂不可开票"); } Long storeId = order.getMdId(); PosStoreEzpay ez = assertInvoiceable(storeId); // 场景入参校验(B2B 统编 / B2C 邮箱 / 载具号码) validateInvoiceInput(dto); // 防重复开票 PosOrderInvoice row = posOrderInvoiceMapper.selectOne( new LambdaQueryWrapper().eq(PosOrderInvoice::getOrderId, orderId)); if (row != null && Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) { throw new ServiceException("该订单已开票,不可重复开票"); } // 金额拆分(不含运费:invoiceTotal = amount - freight) int amount = order.getAmount() == null ? 0 : order.getAmount(); int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight()); int invoiceTotal = amount - freight; if (invoiceTotal <= 0) { throw new ServiceException("订单可开票金额异常,无法开票"); } int sales = (int) Math.round(invoiceTotal / 1.05); int tax = invoiceTotal - sales; // 组装 ezPay issue 参数 + 调用 Map inv = buildIssueData(dto, order, invoiceTotal, sales, tax); EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv()); int newStatus; String invoiceNumber = null, randomNum = null, invoiceTransNo = null, invoiceBarCode = null, invoiceQrcodeL = null, invoiceQrcodeR = null, failReason = null; try { JSONObject resp = ezPay.issueInvoice(ezpayBaseUrl, cfg, inv); String status = resp == null ? "" : resp.getString("Status"); JSONObject result = resp == null ? null : resp.getJSONObject("Result"); if ("SUCCESS".equals(status) && result != null && StrUtil.isNotBlank(result.getString("InvoiceNumber"))) { invoiceNumber = result.getString("InvoiceNumber"); randomNum = result.getString("RandomNum"); invoiceTransNo = result.getString("InvoiceTransNo"); invoiceBarCode = result.getString("BarCode"); invoiceQrcodeL = result.getString("QRcodeL"); invoiceQrcodeR = result.getString("QRcodeR"); newStatus = STATUS_ISSUED; } else { failReason = resp == null ? "ezPay 无回应" : (status + ":" + resp.getString("Message")); newStatus = STATUS_FAILED; } } catch (Exception e) { failReason = "ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); newStatus = STATUS_FAILED; } // 落库(uk_order_id 兜底防并发重复) Date now = new Date(); PosOrderInvoice saveRow = row == null ? new PosOrderInvoice() : row; saveRow.setOrderId(orderId); saveRow.setOrderNo(order.getDdId()); saveRow.setStoreId(storeId); saveRow.setInvoiceCategory(dto.getCategory()); saveRow.setBuyerName(dto.getBuyerName()); saveRow.setBuyerUbn(dto.getBuyerUbn()); saveRow.setBuyerEmail(dto.getBuyerEmail()); saveRow.setCarrierType(dto.getCarrierType()); saveRow.setCarrierNum(dto.getCarrierNum()); saveRow.setTotalAmt(invoiceTotal); saveRow.setSalesAmt(sales); saveRow.setTaxAmt(tax); saveRow.setApplyTime(now); if (newStatus == STATUS_ISSUED) { saveRow.setInvoiceNumber(invoiceNumber); saveRow.setRandomNum(randomNum); saveRow.setInvoiceTransNo(invoiceTransNo); saveRow.setInvoiceBarCode(invoiceBarCode); saveRow.setInvoiceQrcodeL(invoiceQrcodeL); saveRow.setInvoiceQrcodeR(invoiceQrcodeR); saveRow.setIssueTime(now); saveRow.setFailReason(null); } else { saveRow.setFailReason(StrUtil.sub(failReason, 0, 480)); } saveRow.setInvoiceStatus(newStatus); try { saveOrUpdate(saveRow); } catch (DuplicateKeyException dup) { throw new ServiceException("请勿重复提交开票申请"); } return posOrderInvoiceMapper.selectInvoiceDetail(orderId); } /** 客户查询订单发票;门店不可开票时 invoiceStatus=null(客户端据此隐藏入口)。 */ public PosOrderInvoiceVo getInvoice(Long orderId, Long currentUserId) { PosOrder order = posOrderMapper.selectPosOrderById(orderId); if (order == null || !Objects.equals(order.getUserId(), currentUserId)) { throw new ServiceException("订单不存在"); } boolean can = canInvoice(order.getMdId()); PosOrderInvoiceVo vo = posOrderInvoiceMapper.selectInvoiceDetail(orderId); if (vo == null) { vo = new PosOrderInvoiceVo(); vo.setOrderId(orderId); vo.setOrderNo(order.getDdId()); vo.setInvoiceStatus(can ? STATUS_NOT_ISSUED : null); } else if (!can) { vo.setInvoiceStatus(null); } return vo; } /** 商家端查询订单发票(校验商家为该订单门店归属人)。 */ public PosOrderInvoiceVo getInvoiceForMerchant(Long orderId, Long merchantUserId) { requireOwnedOrder(orderId, merchantUserId); return posOrderInvoiceMapper.selectInvoiceDetail(orderId); } /** 商家端作废发票(校验归属后复用作废逻辑)。 */ public int invalidForMerchant(Long orderId, String reason, Long merchantUserId) { requireOwnedOrder(orderId, merchantUserId); return invalid(orderId, reason); } /** 校验订单存在且属于该商家门店,返回订单;否则抛异常。 */ private PosOrder requireOwnedOrder(Long orderId, Long merchantUserId) { PosOrder order = posOrderMapper.selectPosOrderById(orderId); if (order == null) { throw new ServiceException("订单不存在"); } InfoUser user = infoUserService.getOne( new LambdaQueryWrapper().eq(InfoUser::getUserId, merchantUserId)); if (user == null) { throw new ServiceException("用户不存在"); } String ut = user.getUserType(); boolean own; if ("1".equals(ut) || "3".equals(ut)) { // 普通/夜市商家:订单 shId 即商家 userId own = merchantUserId.equals(order.getShId()); } else { // 摊位商家:订单门店 mdId 即商家 storeId own = user.getStoreId() != null && user.getStoreId().equals(order.getMdId()); } if (!own) { throw new ServiceException("无权操作该订单发票"); } return order; } // ==================== US4/US5:管理端 ==================== /** 管理端:订单发票列表(配合 PageHelper 分页)。 */ public List list(PosOrderInvoiceVo query) { if (query != null && query.getDateRange() != null && query.getDateRange().length == 2) { query.setCretimStart(query.getDateRange()[0]); query.setCretimEnd(query.getDateRange()[1]); } return posOrderInvoiceMapper.selectInvoiceList(query); } /** 管理端:订单发票详情。 */ public PosOrderInvoiceVo detail(Long orderId) { return posOrderInvoiceMapper.selectInvoiceDetail(orderId); } /** 管理端:重新开票(仅 失败/作废 单;运营身份不校验客户归属)。 */ public int retry(Long orderId) { PosOrderInvoice row = posOrderInvoiceMapper.selectOne( new LambdaQueryWrapper().eq(PosOrderInvoice::getOrderId, orderId)); if (row == null) { throw new ServiceException("发票记录不存在"); } int st = row.getInvoiceStatus() == null ? STATUS_NOT_ISSUED : row.getInvoiceStatus(); if (st != STATUS_FAILED && st != STATUS_INVALID) { throw new ServiceException("仅失败或作废的发票可重新开票"); } // 重置为未开,复用客户开票参数重开(运营身份 currentUserId 传 null 跳过归属校验) PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper().eq(PosOrder::getDdId,orderId.toString())); if (order == null) { throw new ServiceException("订单不存在"); } PosStoreEzpay ez = assertInvoiceable(order.getMdId()); ApplyInvoiceDto dto = new ApplyInvoiceDto(); dto.setOrderId(orderId); dto.setCategory(row.getInvoiceCategory()); dto.setBuyerName(row.getBuyerName()); dto.setBuyerUbn(row.getBuyerUbn()); dto.setBuyerEmail(row.getBuyerEmail()); dto.setCarrierType(row.getCarrierType()); dto.setCarrierNum(row.getCarrierNum()); // 复用核心开票(不含归属校验):直接走 issue + 落库 return reIssueAndSave(order, row, dto, ez); } /** 管理端:作废发票(仅 已开 单,调 ezPay invoice_invalid)。 */ public int invalid(Long orderId, String reason) { PosOrderInvoice row = posOrderInvoiceMapper.selectOne( new LambdaQueryWrapper().eq(PosOrderInvoice::getOrderId, orderId)); if (row == null || !Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) { throw new ServiceException("仅已开发票可作废"); } PosOrder order = posOrderMapper.selectPosOrderById(orderId); PosStoreEzpay ez = posStoreEzpayMapper.selectOne( new LambdaQueryWrapper().eq(PosStoreEzpay::getStoreId, order.getMdId())); if (ez == null) { throw new ServiceException("门店 ezPay 凭证缺失,无法作废"); } Map postData = new LinkedHashMap<>(); postData.put("RespondType", "JSON"); postData.put("Version", "1.0"); postData.put("InvoiceNumber", row.getInvoiceNumber()); postData.put("InvalidReason", StrUtil.isBlank(reason) ? "商家作废" : reason); try { EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv()); JSONObject resp = ezPay.doPost(ezpayBaseUrl + EzPay.URL_INVALID, cfg, postData); String status = resp == null ? "" : resp.getString("Status"); if ("SUCCESS".equals(status)) { row.setInvoiceStatus(STATUS_INVALID); row.setInvalidTime(new Date()); saveOrUpdate(row); return 1; } throw new ServiceException("作废失败:" + (resp == null ? "ezPay 无回应" : resp.getString("Message"))); } catch (ServiceException se) { throw se; } catch (Exception e) { throw new ServiceException("ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); } } // ==================== 内部辅助 ==================== /** 复用开票核心(运营重开,无归属校验)。 */ private int reIssueAndSave(PosOrder order, PosOrderInvoice row, ApplyInvoiceDto dto, PosStoreEzpay ez) { int amount = order.getAmount() == null ? 0 : order.getAmount(); int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight()); int invoiceTotal = amount - freight; int sales = (int) Math.round(invoiceTotal / 1.05); int tax = invoiceTotal - sales; Map inv = buildIssueData(dto, order, invoiceTotal, sales, tax); EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv()); int newStatus; String invoiceNumber = null, randomNum = null, invoiceTransNo = null, invoiceBarCode = null, invoiceQrcodeL = null, invoiceQrcodeR = null, failReason = null; try { JSONObject resp = ezPay.issueInvoice(ezpayBaseUrl, cfg, inv); String status = resp == null ? "" : resp.getString("Status"); JSONObject result = resp == null ? null : resp.getJSONObject("Result"); if ("SUCCESS".equals(status) && result != null && StrUtil.isNotBlank(result.getString("InvoiceNumber"))) { invoiceNumber = result.getString("InvoiceNumber"); randomNum = result.getString("RandomNum"); invoiceTransNo = result.getString("InvoiceTransNo"); invoiceBarCode = result.getString("BarCode"); invoiceQrcodeL = result.getString("QRcodeL"); invoiceQrcodeR = result.getString("QRcodeR"); newStatus = STATUS_ISSUED; } else { failReason = resp == null ? "ezPay 无回应" : (status + ":" + resp.getString("Message")); newStatus = STATUS_FAILED; } } catch (Exception e) { failReason = "ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); newStatus = STATUS_FAILED; } Date now = new Date(); if (newStatus == STATUS_ISSUED) { row.setInvoiceNumber(invoiceNumber); row.setRandomNum(randomNum); row.setInvoiceTransNo(invoiceTransNo); row.setInvoiceBarCode(invoiceBarCode); row.setInvoiceQrcodeL(invoiceQrcodeL); row.setInvoiceQrcodeR(invoiceQrcodeR); row.setIssueTime(now); row.setFailReason(null); } else { row.setFailReason(StrUtil.sub(failReason, 0, 480)); } row.setInvoiceStatus(newStatus); saveOrUpdate(row); return newStatus == STATUS_ISSUED ? 1 : 0; } /** 校验门店可开票并返回凭证;不可开票抛异常。 */ private PosStoreEzpay assertInvoiceable(Long storeId) { if (storeId == null) { throw new ServiceException("订单未关联门店,暂不可开票"); } PosStore store = posStoreMapper.selectPosStoreById(storeId); if (store != null && Integer.valueOf(1).equals(store.getInvoiceExempt())) { throw new ServiceException("该门店免用发票,无需开票"); } PosStoreEzpay ez = posStoreEzpayMapper.selectOne( new LambdaQueryWrapper().eq(PosStoreEzpay::getStoreId, storeId)); if (ez == null || !Integer.valueOf(2).equals(ez.getEzpayStatus()) || !Integer.valueOf(1).equals(ez.getIsEnabled())) { throw new ServiceException("该门店暂不支持电子发票"); } return ez; } /** 门店是否可开票(不抛异常)。 */ private boolean canInvoice(Long storeId) { if (storeId == null) { return false; } PosStore store = posStoreMapper.selectPosStoreById(storeId); if (store != null && Integer.valueOf(1).equals(store.getInvoiceExempt())) { return false; } PosStoreEzpay ez = posStoreEzpayMapper.selectOne( new LambdaQueryWrapper().eq(PosStoreEzpay::getStoreId, storeId)); return ez != null && Integer.valueOf(2).equals(ez.getEzpayStatus()) && Integer.valueOf(1).equals(ez.getIsEnabled()); } /** * 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。对齐 ezPay 开票模型: * B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填,ezPay会员载具(2)时邮箱必填。 */ private void validateInvoiceInput(ApplyInvoiceDto dto) { if ("B2B".equals(dto.getCategory())) { if (StrUtil.isBlank(dto.getBuyerUbn()) || !dto.getBuyerUbn().matches("\\d{8}")) { throw new ServiceException("统编格式不正确,须为 8 位数字"); } if (StrUtil.isBlank(dto.getBuyerEmail())) { throw new ServiceException("B2B 发票须填写买方邮箱"); } if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) { throw new ServiceException("B2B 发票不支持载具"); } } else if ("B2C".equals(dto.getCategory())) { String ct = dto.getCarrierType(); if (!"0".equals(ct) && !"1".equals(ct) && !"2".equals(ct)) { throw new ServiceException("B2C 发票须选择载具类型(0手机条码/1自然人凭证/2ezPay会员)"); } if (StrUtil.isBlank(dto.getCarrierNum())) { throw new ServiceException("载具号码不能为空"); } if ("2".equals(ct) && StrUtil.isBlank(dto.getBuyerEmail())) { throw new ServiceException("ezPay 会员载具须填写买方邮箱"); } } } /** 组装 ezPay invoice_issue 业务参数(Category/PrintFlag/Buyer/Amt/明细)。 */ private Map buildIssueData(ApplyInvoiceDto dto, PosOrder order, int invoiceTotal, int sales, int tax) { Map inv = new LinkedHashMap<>(); inv.put("MerchantOrderNo", order.getDdId()); inv.put("Category", dto.getCategory()); inv.put("BuyerName", dto.getBuyerName()); if ("B2B".equals(dto.getCategory())) { inv.put("BuyerUBN", dto.getBuyerUbn()); inv.put("BuyerEmail", dto.getBuyerEmail()); // B2B 邮箱必填:ezPay 发开立通知给买方,凭此查看 inv.put("PrintFlag", "Y"); } else { // B2C 载具必填(validateInvoiceInput 已校验 carrierType∈0/1/2、号码非空),PrintFlag=N inv.put("CarrierType", dto.getCarrierType()); inv.put("CarrierNum", dto.getCarrierNum()); inv.put("PrintFlag", "N"); // ezPay 会员载具(2)需邮箱;手机条码(0)/自然人凭证(1)票进载具,邮箱留空 if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) { inv.put("BuyerEmail", dto.getBuyerEmail()); } } inv.put("TaxType", "1"); inv.put("TaxRate", "5"); inv.put("Amt", String.valueOf(sales)); inv.put("TaxAmt", String.valueOf(tax)); inv.put("TotalAmt", String.valueOf(invoiceTotal)); appendItems(inv, order.getFood(), invoiceTotal); return inv; } /** * 逐商品明细(research D2/D3):从 food JSON 解析,优惠按比例分摊到单价(方案 B)。 * 每行满足 ItemCount × ItemPrice = ItemAmt(单价决定金额,ezPay 逐商品校验通过)。 */ private void appendItems(Map inv, String foodJson, int invoiceTotal) { JSONArray arr = StrUtil.isBlank(foodJson) ? null : com.alibaba.fastjson.JSON.parseArray(foodJson); if (arr == null || arr.isEmpty()) { inv.put("ItemName", "餐点"); inv.put("ItemCount", "1"); inv.put("ItemUnit", "份"); inv.put("ItemPrice", String.valueOf(invoiceTotal)); inv.put("ItemAmt", String.valueOf(invoiceTotal)); return; } int foodTotal = foodOriginalTotal(arr); double ratio = foodTotal > 0 ? (double) invoiceTotal / foodTotal : 1.0; StringBuilder name = new StringBuilder(), count = new StringBuilder(), unit = new StringBuilder(), price = new StringBuilder(), amt = new StringBuilder(); for (int i = 0; i < arr.size(); i++) { com.alibaba.fastjson.JSONObject it = arr.getJSONObject(i); int p = it.getIntValue("price") + it.getIntValue("otherPrice"); int c = it.getIntValue("number"); if (c <= 0) { c = 1; } int itemPrice = (int) Math.round(p * ratio); int itemAmt = itemPrice * c; if (i > 0) { name.append("|"); count.append("|"); unit.append("|"); price.append("|"); amt.append("|"); } String nm = it.getString("name"); name.append(StrUtil.isBlank(nm) ? "餐点" : nm); count.append(c); unit.append("個"); price.append(itemPrice); amt.append(itemAmt); } inv.put("ItemName", name.toString()); inv.put("ItemCount", count.toString()); inv.put("ItemUnit", unit.toString()); inv.put("ItemPrice", price.toString()); inv.put("ItemAmt", amt.toString()); } /** food 商品原价总额(含税)= Σ(price + otherPrice) × number。 */ private int foodOriginalTotal(JSONArray arr) { int sum = 0; for (int i = 0; i < arr.size(); i++) { com.alibaba.fastjson.JSONObject it = arr.getJSONObject(i); int p = it.getIntValue("price") + it.getIntValue("otherPrice"); int c = it.getIntValue("number"); sum += p * (c <= 0 ? 1 : c); } return sum; } /** 新增或更新(显式设置时间与人)。 */ private void saveOrUpdate(PosOrderInvoice row) { Date now = new Date(); String user = currentUser(); if (row.getId() == null) { row.setCreateTime(now); row.setUpdateTime(now); row.setCreateBy(user); row.setUpdateBy(user); posOrderInvoiceMapper.insert(row); } else { row.setUpdateTime(now); row.setUpdateBy(user); posOrderInvoiceMapper.updateById(row); } } private String currentUser() { try { return SecurityUtils.getUsername(); } catch (Exception e) { return "system"; } } }