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.MessageUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.domain.PosLoveOrg;
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.PosLoveOrgMapper;
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 PosLoveOrgMapper posLoveOrgMapper;
@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 凭证(捐赠机构 checkLoveCode 兜底也用到)
EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
// 捐赠发票:解析机构(local 优先,未命中 checkLoveCode 验真),回填买方名为机构名
String loveOrgName = null;
if ("DONATION".equals(dto.getCategory())) {
loveOrgName = resolveLoveOrg(dto.getLoveCode(), cfg);
dto.setBuyerName(loveOrgName);
}
// 组装 ezPay issue 参数 + 调用
Map inv = buildIssueData(dto, order, invoiceTotal, sales, tax);
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.setLoveCode(dto.getLoveCode());
saveRow.setLoveOrgName(loveOrgName);
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);
}
/**
* 下单捕获发票意图(010 Phase 11):把下单选的发票类型/载具/统编/捐赠码落成 pos_order_invoice
* 意图行(issueTriggered=0 未触发开票、invoiceStatus=0 未开);出餐 {@link #autoIssue} 在该行
* 上更新开票结果并置 issueTriggered=1。仅在门店可开票时由 createOrder 调用。
*/
public void captureInvoiceIntent(PosOrderInvoice intent) {
intent.setInvoiceStatus(STATUS_NOT_ISSUED);
intent.setIssueTriggered(0);
if (intent.getApplyTime() == null) {
intent.setApplyTime(new Date());
}
saveOrUpdate(intent);
}
/**
* 商家出餐自动开票(010 Phase 11):读 pos_order_invoice 发票意图行 → 组装 dto(个人 BuyerName=手机号、
* 捐赠=机构名)→ 复用开票核心 {@link #reIssueAndSave}。系统触发,不校验客户归属;货到付款未付款也开
* (送达失败/取消时作废兜底)。门店不可开票 / 订单无发票意图 / 已开 → 静默跳过,不影响出餐。
*
* @param orderId PosOrder.id(PK,与 invalid/retry/selectInvoiceDetail 一致)
* @return 1=已开 0=跳过或失败
*/
public int autoIssue(Long orderId) {
PosOrder order = posOrderMapper.selectPosOrderById(orderId);
if (order == null) {
return 0; // 订单不存在
}
// 发票意图自 Phase 11 起落 pos_order_invoice(下单捕获的意图行),不再读 pos_order
PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
new LambdaQueryWrapper().eq(PosOrderInvoice::getOrderId, orderId));
if (row == null || StrUtil.isBlank(row.getInvoiceChoice())) {
return 0; // 无发票意图(门店不可开票时 createOrder 未插意图行)
}
Long storeId = order.getMdId();
if (!canInvoice(storeId)) {
return 0; // 出餐时门店已不可开票(被停用等),跳过
}
PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
new LambdaQueryWrapper().eq(PosStoreEzpay::getStoreId, storeId));
if (ez == null) {
return 0;
}
// 防重复:已开则跳过
if (Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
return 0;
}
ApplyInvoiceDto dto = buildAutoIssueDto(row);
EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
// 捐赠=机构名;个人=手机号
String loveOrgName = null;
if ("LOVE_CODE".equals(row.getInvoiceChoice())) {
loveOrgName = resolveLoveOrg(dto.getLoveCode(), cfg);
dto.setBuyerName(loveOrgName);
} else if (isPersonalChoice(row.getInvoiceChoice())) {
InfoUser u = infoUserService.getOne(
new LambdaQueryWrapper().eq(InfoUser::getUserId, order.getUserId()));
String phone = u != null && StrUtil.isNotBlank(u.getPhone()) ? u.getPhone() : "會員";
dto.setBuyerName(phone);
if ("MEMBER".equals(row.getInvoiceChoice())) {
// 会员载具:载具号=会员手机号;同步落库保持发票行载具信息完整
dto.setCarrierNum(phone);
row.setCarrierType("2");
row.setCarrierNum(phone);
}
}
// 出餐触发开票:在意图行上落结果,并标记已触发(与 invoiceStatus 独立)
row.setInvoiceCategory(dto.getCategory());
row.setBuyerName(dto.getBuyerName());
row.setLoveOrgName(loveOrgName);
row.setIssueTriggered(1);
return reIssueAndSave(order, row, dto, ez);
}
/** 出餐自动开票:pos_order_invoice 发票意图行 → ApplyInvoiceDto(捐赠走 DONATION 复用既有分支)。 */
private ApplyInvoiceDto buildAutoIssueDto(PosOrderInvoice intent) {
ApplyInvoiceDto dto = new ApplyInvoiceDto();
dto.setOrderId(intent.getOrderId());
String choice = intent.getInvoiceChoice();
switch (choice) {
case "PAPER":
case "PHONE_BARCODE":
case "CITIZEN":
dto.setCategory("B2C");
dto.setCarrierType(intent.getCarrierType()); // PAPER 时为 null → 个人纸本分支
dto.setCarrierNum(intent.getCarrierNum());
break;
case "MEMBER":
// 会员载具 ezPay 类型 2;载具号(会员手机号)与买方名由 autoIssue 取 InfoUser.phone 回填
dto.setCategory("B2C");
dto.setCarrierType("2");
break;
case "LOVE_CODE":
dto.setCategory("DONATION");
dto.setLoveCode(intent.getLoveCode());
break;
case "COMPANY":
dto.setCategory("B2B");
dto.setBuyerUbn(intent.getBuyerUbn());
dto.setBuyerName(intent.getBuyerName());
dto.setBuyerEmail(intent.getBuyerEmail());
break;
default:
break;
}
return dto;
}
private boolean isPersonalChoice(String choice) {
return "PAPER".equals(choice) || "PHONE_BARCODE".equals(choice) || "CITIZEN".equals(choice)
|| "MEMBER".equals(choice);
}
/** 客户查询订单发票;门店不可开票时 invoiceStatus=null(客户端据此隐藏入口)。 */
public PosOrderInvoiceVo getInvoice(Long orderId, Long currentUserId) {
PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper().eq(PosOrder::getDdId,String.valueOf(orderId)));
if (order == null || !Objects.equals(order.getUserId(), currentUserId)) {
throw new ServiceException("订单不存在");
}
boolean can = canInvoice(order.getMdId());
PosOrderInvoiceVo vo = posOrderInvoiceMapper.selectInvoiceDetail(order.getId());
if (vo == null) {
vo = new PosOrderInvoiceVo();
vo.setOrderId(order.getId());
vo.setOrderNo(order.getDdId());
vo.setInvoiceStatus(can ? STATUS_NOT_ISSUED : 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);
}
/** 会员发票夹(010 Phase 11):按 userId 列已开发票(不含码图)。配合 PageHelper 分页。 */
public List listMyInvoices(Long userId) {
return posOrderInvoiceMapper.selectMyInvoices(userId);
}
/** 管理端:订单发票详情。 */
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 跳过归属校验)
// orderId 实为 pos_order.id(主键,发票行 order_id 已证实),按主键查最稳,不依赖 order_no 是否回填
PosOrder order = posOrderMapper.selectPosOrderById(orderId);
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());
dto.setLoveCode(row.getLoveCode());
// 复用核心开票(不含归属校验):直接走 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;
}
// ezPay 回非 SUCCESS 且消息含「已作廢」:该票在 ezPay 端已是作废态,本地对齐成作废,
// 避免与 ezPay 不同步时(如作废后 retry 复活死票)陷入「再作废也改不回 status」的死锁
String msg = resp == null ? "" : resp.getString("Message");
if (msg != null && msg.contains("已作廢")) {
row.setInvoiceStatus(STATUS_INVALID);
saveOrUpdate(row);
return 1;
}
throw new ServiceException("作废失败:" + (resp == null ? "ezPay 无回应" : msg));
} 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.setTotalAmt(invoiceTotal);
row.setSalesAmt(sales);
row.setTaxAmt(tax);
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;
}
/** 门店是否可开票(不抛异常):免用发票或 ezPay 未开通启用均返回 false。 */
public 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());
}
/** 取门店 ezPay 凭证配置;未开通/未启用返回 null(不抛异常,供校验场景容忍使用)。 */
private EzPayConfig getEzpayConfig(Long storeId) {
if (storeId == null) {
return null;
}
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())) {
return null;
}
return new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
}
/**
* 结账时校验发票意图(010 Phase 11):载具/捐赠联网验真、统编/凭证格式校验。
* PAPER(默认纸本)不校验。联网校验(手机条码/捐赠码)用 storeId 对应门店的 ezPay 凭证调 BDV。
*
* @param storeId 一个可开票门店(调用方负责选 canInvoice 的门店,用于取 ezPay 凭证)
*/
public void validateCheckoutInvoiceIntent(String invoiceChoice, String carrierNum, String loveCode,
String buyerUbn, String buyerEmail, Long storeId) {
if (StrUtil.isBlank(invoiceChoice) || "PAPER".equals(invoiceChoice)) {
return;
}
switch (invoiceChoice) {
case "PHONE_BARCODE":
if (StrUtil.isBlank(carrierNum) || !carrierNum.matches("^/[0-9A-Z+\\-.]{7}$")) {
throw new ServiceException(MessageUtils.message("no.invoice.barcode.format"));
}
EzPayConfig barCfg = getEzpayConfig(storeId);
if (barCfg == null) {
throw new ServiceException(MessageUtils.message("no.invoice.store.notsupport.barcode"));
}
boolean barExist;
try {
barExist = ezPay.checkBarCode(ezpayBaseUrl, barCfg, carrierNum);
} catch (Exception e) {
throw new ServiceException(MessageUtils.message("no.invoice.barcode.service.unavailable"));
}
if (!barExist) {
throw new ServiceException(MessageUtils.message("no.invoice.barcode.notexist"));
}
break;
case "CITIZEN":
if (StrUtil.isBlank(carrierNum) || !carrierNum.matches("^[A-Z]{2}\\d{14}$")) {
throw new ServiceException(MessageUtils.message("no.invoice.citizen.format"));
}
break;
case "MEMBER":
// 会员载具:载具号=会员手机号(出餐 autoIssue 时取 InfoUser.phone),免联网验真、免格式校验
break;
case "LOVE_CODE":
if (StrUtil.isBlank(loveCode) || !loveCode.matches("^\\d{3,7}$")) {
throw new ServiceException(MessageUtils.message("no.invoice.lovecode.format"));
}
EzPayConfig loveCfg = getEzpayConfig(storeId);
if (loveCfg == null) {
throw new ServiceException(MessageUtils.message("no.invoice.store.notsupport.lovecode"));
}
resolveLoveOrg(loveCode, loveCfg); // 抛异常=捐赠码无效
break;
case "COMPANY":
if (StrUtil.isBlank(buyerUbn) || !buyerUbn.matches("^\\d{8}$")) {
throw new ServiceException(MessageUtils.message("no.invoice.ubn.format"));
}
if (StrUtil.isBlank(buyerEmail) || !buyerEmail.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
throw new ServiceException(MessageUtils.message("no.invoice.company.email"));
}
break;
default:
throw new ServiceException(MessageUtils.message("no.invoice.choice.invalid", invoiceChoice));
}
}
/**
* 捐赠机构列表(客户端选择器,仅 enabled=1,配合 PageHelper 分页)。
*/
public List listLoveOrg(String keyword) {
return posLoveOrgMapper.selectListByKeyword(keyword);
}
/**
* 解析捐赠机构并验真:local 命中返回简称/全名;local 未命中调 ezPay checkLoveCode
* (IsExist=N 抛「捐赠码无效」,调用异常抛「验证失败」)。返回用于展示 / BuyerName 的机构名。
*/
private String resolveLoveOrg(String loveCode, EzPayConfig cfg) {
PosLoveOrg org = posLoveOrgMapper.selectOne(new LambdaQueryWrapper()
.eq(PosLoveOrg::getLoveCode, loveCode)
.eq(PosLoveOrg::getEnabled, 1));
if (org != null) {
return StrUtil.isNotBlank(org.getOrgShortName()) ? org.getOrgShortName() : org.getOrgName();
}
try {
if (!ezPay.checkLoveCode(ezpayBaseUrl, cfg, loveCode)) {
throw new ServiceException(MessageUtils.message("no.invoice.lovecode.invalid"));
}
} catch (ServiceException se) {
throw se;
} catch (Exception e) {
throw new ServiceException(MessageUtils.message("no.invoice.lovecode.verify.fail"));
}
return "未知机构";
}
/**
* 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。对齐 ezPay 开票模型:
* B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填;会员载具(2)载具号=会员手机号、不收邮箱。
*/
private void validateInvoiceInput(ApplyInvoiceDto dto) {
if ("B2B".equals(dto.getCategory())) {
if (StrUtil.isBlank(dto.getBuyerName())) {
throw new ServiceException(MessageUtils.message("no.invoice.b2b.buyername.blank"));
}
if (StrUtil.isBlank(dto.getBuyerUbn()) || !dto.getBuyerUbn().matches("\\d{8}")) {
throw new ServiceException(MessageUtils.message("no.invoice.ubn.format"));
}
if (StrUtil.isBlank(dto.getBuyerEmail())) {
throw new ServiceException(MessageUtils.message("no.invoice.b2b.buyeremail.blank"));
}
if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) {
throw new ServiceException(MessageUtils.message("no.invoice.b2b.nocarrier"));
}
} else if ("DONATION".equals(dto.getCategory())) {
// 捐赠:loveCode 3-7 位必填,与载具/统编互斥
if (StrUtil.isBlank(dto.getLoveCode()) || !dto.getLoveCode().matches("\\d{3,7}")) {
throw new ServiceException(MessageUtils.message("no.invoice.lovecode.format"));
}
if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) {
throw new ServiceException(MessageUtils.message("no.invoice.donation.nocarrier"));
}
if (StrUtil.isNotBlank(dto.getBuyerUbn())) {
throw new ServiceException(MessageUtils.message("no.invoice.donation.noubn"));
}
} else if ("B2C".equals(dto.getCategory())) {
// 载具分支
if (StrUtil.isBlank(dto.getBuyerName())) {
throw new ServiceException(MessageUtils.message("no.invoice.buyername.blank"));
}
String ct = dto.getCarrierType();
if (StrUtil.isBlank(ct)) {
// 无载具 → 个人纸本(PrintFlag=Y,零信息兜底),无需载具校验
return;
}
if (!"0".equals(ct) && !"1".equals(ct) && !"2".equals(ct)) {
throw new ServiceException(MessageUtils.message("no.invoice.carriertype.invalid"));
}
if (StrUtil.isBlank(dto.getCarrierNum())) {
throw new ServiceException(MessageUtils.message("no.invoice.carriernum.blank"));
}
// ezPay doc:载具号码前后不得含空白
dto.setCarrierNum(dto.getCarrierNum().trim());
// ezPay doc:手机条码(0)= / +7码[0-9A-Z+\-.];自然人凭证(1)=2大写英+14数字;ezPay会员(2)=卖方自订代号,不校验
switch (ct) {
case "0":
if (!dto.getCarrierNum().matches("^/[0-9A-Z+\\-.]{7}$")) {
throw new ServiceException(MessageUtils.message("no.invoice.barcode.format"));
}
break;
case "1":
if (!dto.getCarrierNum().matches("^[A-Z]{2}\\d{14}$")) {
throw new ServiceException(MessageUtils.message("no.invoice.citizen.format"));
}
break;
case "2":
default:
break;
}
}
}
/** 组装 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());
// ezPay Category 只有 B2C/B2B;DONATION 是我方语义,对应 ezPay B2C + LoveCode
inv.put("Category", "DONATION".equals(dto.getCategory()) ? "B2C" : 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 if ("DONATION".equals(dto.getCategory())) {
// 捐赠:LoveCode + PrintFlag=N,无载具/邮箱/统编(买方名=机构名,已在 applyInvoice 回填)
inv.put("LoveCode", dto.getLoveCode());
inv.put("PrintFlag", "N");
} else {
// B2C:有载具 → PrintFlag=N 存载具;无载具 → PrintFlag=Y 个人纸本(零信息兜底,010 Phase 11)
if (StrUtil.isNotBlank(dto.getCarrierType())) {
inv.put("CarrierType", dto.getCarrierType());
inv.put("CarrierNum", dto.getCarrierNum());
inv.put("PrintFlag", "N");
// ezPay 手册:CarrierType=2(ezPay会员载具)时 BuyerEmail 必填(INV10013);CarrierNum=卖方自订代号(手机/邮箱/会员编号)
if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) {
inv.put("BuyerEmail", dto.getBuyerEmail());
}
} else {
// 个人纸本:无载具无捐赠,法定 PrintFlag=Y;不传载具/邮箱
inv.put("PrintFlag", "Y");
}
}
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";
}
}
}