OrderInvoiceService.java 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849
  1. package com.ruoyi.app.order;
  2. import cn.hutool.core.util.StrUtil;
  3. import com.alibaba.fastjson.JSONArray;
  4. import com.alibaba.fastjson2.JSONObject;
  5. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  6. import com.ruoyi.app.utils.ezPay.EzPay;
  7. import com.ruoyi.app.utils.ezPay.EzPayConfig;
  8. import com.ruoyi.common.exception.ServiceException;
  9. import com.ruoyi.common.utils.MessageUtils;
  10. import com.ruoyi.common.utils.SecurityUtils;
  11. import com.ruoyi.system.domain.PosLoveOrg;
  12. import com.ruoyi.system.domain.PosOrder;
  13. import com.ruoyi.system.domain.PosOrderInvoice;
  14. import com.ruoyi.system.domain.PosStore;
  15. import com.ruoyi.system.domain.PosStoreEzpay;
  16. import com.ruoyi.system.domain.InfoUser;
  17. import com.ruoyi.system.domain.dto.ApplyInvoiceDto;
  18. import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
  19. import com.ruoyi.system.mapper.PosLoveOrgMapper;
  20. import com.ruoyi.system.mapper.PosOrderInvoiceMapper;
  21. import com.ruoyi.system.mapper.PosOrderMapper;
  22. import com.ruoyi.system.mapper.PosStoreEzpayMapper;
  23. import com.ruoyi.system.mapper.PosStoreMapper;
  24. import com.ruoyi.system.service.IInfoUserService;
  25. import org.springframework.beans.factory.annotation.Autowired;
  26. import org.springframework.beans.factory.annotation.Value;
  27. import org.springframework.dao.DuplicateKeyException;
  28. import org.springframework.stereotype.Service;
  29. import java.util.Date;
  30. import java.util.LinkedHashMap;
  31. import java.util.List;
  32. import java.util.Map;
  33. import java.util.Objects;
  34. /**
  35. * 订单电子发票 开票 / 作废 / 查询 业务(放 admin,因需调用 {@link EzPay})。
  36. *
  37. * <p>开票链路({@link #applyInvoice}):校验订单归属/状态/支付 → 校验门店可开票(009 凭证 + 免用发票)
  38. * → 金额拆分(不含运费)→ 组装 ezPay issue 参数 + 逐商品明细 → 调 {@link EzPay#issueInvoice}
  39. * → 判读回应 → 落库。作废走 {@link EzPay#doPost} + invoice_invalid。
  40. *
  41. * @author ruoyi
  42. * @date 2026-06-16
  43. */
  44. @Service
  45. public class OrderInvoiceService {
  46. @Autowired
  47. private EzPay ezPay;
  48. @Autowired
  49. private PosOrderMapper posOrderMapper;
  50. @Autowired
  51. private PosStoreMapper posStoreMapper;
  52. @Autowired
  53. private PosStoreEzpayMapper posStoreEzpayMapper;
  54. @Autowired
  55. private PosOrderInvoiceMapper posOrderInvoiceMapper;
  56. @Autowired
  57. private PosLoveOrgMapper posLoveOrgMapper;
  58. @Autowired
  59. private IInfoUserService infoUserService;
  60. /** ezPay 接口根地址(从 application.yml 的 ezpay.base-url 注入,测试/正式在此切换) */
  61. @Value("${ezpay.base-url}")
  62. private String ezpayBaseUrl;
  63. /** 发票状态:0未开/1已开/2失败/3作废 */
  64. private static final int STATUS_NOT_ISSUED = 0;
  65. private static final int STATUS_ISSUED = 1;
  66. private static final int STATUS_FAILED = 2;
  67. private static final int STATUS_INVALID = 3;
  68. /** 订单完成态、已支付 */
  69. private static final long ORDER_STATE_DONE = 3L;
  70. private static final long ORDER_PAID = 1L;
  71. // ==================== US1/US2/US3:客户开票 ====================
  72. /**
  73. * 客户申请开票。校验 → 金额拆分 → 调 ezPay 即时开立 → 落库。
  74. * B2C/B2B/载具由 {@link #buildIssueData} 按 {@code category}/{@code carrierType} 分支处理。
  75. */
  76. public PosOrderInvoiceVo applyInvoice(ApplyInvoiceDto dto, Long currentUserId) {
  77. Long orderId = dto.getOrderId();
  78. PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,orderId.toString()));
  79. if (order == null) {
  80. throw new ServiceException("订单不存在");
  81. }
  82. if (!Objects.equals(order.getUserId(), currentUserId)) {
  83. throw new ServiceException("无权操作该订单");
  84. }
  85. if (order.getState() == null || order.getState() != ORDER_STATE_DONE) {
  86. throw new ServiceException("订单未完成,暂不可开票");
  87. }
  88. if (order.getPayStatus() == null || order.getPayStatus() != ORDER_PAID) {
  89. throw new ServiceException("订单未支付,暂不可开票");
  90. }
  91. Long storeId = order.getMdId();
  92. PosStoreEzpay ez = assertInvoiceable(storeId);
  93. // 场景入参校验(B2B 统编 / B2C 邮箱 / 载具号码)
  94. validateInvoiceInput(dto);
  95. // 防重复开票
  96. PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
  97. new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
  98. if (row != null && Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
  99. throw new ServiceException("该订单已开票,不可重复开票");
  100. }
  101. // 金额拆分(不含运费:invoiceTotal = amount - freight)
  102. int amount = order.getAmount() == null ? 0 : order.getAmount();
  103. int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight());
  104. int invoiceTotal = amount - freight;
  105. if (invoiceTotal <= 0) {
  106. throw new ServiceException("订单可开票金额异常,无法开票");
  107. }
  108. int sales = (int) Math.round(invoiceTotal / 1.05);
  109. int tax = invoiceTotal - sales;
  110. // ezPay 凭证(捐赠机构 checkLoveCode 兜底也用到)
  111. EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
  112. // 捐赠发票:解析机构(local 优先,未命中 checkLoveCode 验真),回填买方名为机构名
  113. String loveOrgName = null;
  114. if ("DONATION".equals(dto.getCategory())) {
  115. loveOrgName = resolveLoveOrg(dto.getLoveCode(), cfg);
  116. dto.setBuyerName(loveOrgName);
  117. }
  118. // 组装 ezPay issue 参数 + 调用
  119. Map<String, Object> inv = buildIssueData(dto, order, invoiceTotal, sales, tax);
  120. int newStatus;
  121. String invoiceNumber = null, randomNum = null, invoiceTransNo = null,
  122. invoiceBarCode = null, invoiceQrcodeL = null, invoiceQrcodeR = null, failReason = null;
  123. try {
  124. JSONObject resp = ezPay.issueInvoice(ezpayBaseUrl, cfg, inv); String status = resp == null ? "" : resp.getString("Status");
  125. JSONObject result = resp == null ? null : resp.getJSONObject("Result");
  126. if ("SUCCESS".equals(status) && result != null && StrUtil.isNotBlank(result.getString("InvoiceNumber"))) {
  127. invoiceNumber = result.getString("InvoiceNumber");
  128. randomNum = result.getString("RandomNum");
  129. invoiceTransNo = result.getString("InvoiceTransNo");
  130. invoiceBarCode = result.getString("BarCode");
  131. invoiceQrcodeL = result.getString("QRcodeL");
  132. invoiceQrcodeR = result.getString("QRcodeR");
  133. newStatus = STATUS_ISSUED;
  134. } else {
  135. failReason = resp == null ? "ezPay 无回应" : (status + ":" + resp.getString("Message"));
  136. newStatus = STATUS_FAILED;
  137. }
  138. } catch (Exception e) {
  139. failReason = "ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
  140. newStatus = STATUS_FAILED;
  141. }
  142. // 落库(uk_order_id 兜底防并发重复)
  143. Date now = new Date();
  144. PosOrderInvoice saveRow = row == null ? new PosOrderInvoice() : row;
  145. saveRow.setOrderId(orderId);
  146. saveRow.setOrderNo(order.getDdId());
  147. saveRow.setStoreId(storeId);
  148. saveRow.setInvoiceCategory(dto.getCategory());
  149. saveRow.setBuyerName(dto.getBuyerName());
  150. saveRow.setBuyerUbn(dto.getBuyerUbn());
  151. saveRow.setBuyerEmail(dto.getBuyerEmail());
  152. saveRow.setCarrierType(dto.getCarrierType());
  153. saveRow.setCarrierNum(dto.getCarrierNum());
  154. saveRow.setLoveCode(dto.getLoveCode());
  155. saveRow.setLoveOrgName(loveOrgName);
  156. saveRow.setTotalAmt(invoiceTotal);
  157. saveRow.setSalesAmt(sales);
  158. saveRow.setTaxAmt(tax);
  159. saveRow.setApplyTime(now);
  160. if (newStatus == STATUS_ISSUED) {
  161. saveRow.setInvoiceNumber(invoiceNumber);
  162. saveRow.setRandomNum(randomNum);
  163. saveRow.setInvoiceTransNo(invoiceTransNo);
  164. saveRow.setInvoiceBarCode(invoiceBarCode);
  165. saveRow.setInvoiceQrcodeL(invoiceQrcodeL);
  166. saveRow.setInvoiceQrcodeR(invoiceQrcodeR);
  167. saveRow.setIssueTime(now);
  168. saveRow.setFailReason(null);
  169. } else {
  170. saveRow.setFailReason(StrUtil.sub(failReason, 0, 480));
  171. }
  172. saveRow.setInvoiceStatus(newStatus);
  173. try {
  174. saveOrUpdate(saveRow);
  175. } catch (DuplicateKeyException dup) {
  176. throw new ServiceException("请勿重复提交开票申请");
  177. }
  178. return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
  179. }
  180. /**
  181. * 下单捕获发票意图(010 Phase 11):把下单选的发票类型/载具/统编/捐赠码落成 pos_order_invoice
  182. * 意图行(issueTriggered=0 未触发开票、invoiceStatus=0 未开);出餐 {@link #autoIssue} 在该行
  183. * 上更新开票结果并置 issueTriggered=1。仅在门店可开票时由 createOrder 调用。
  184. */
  185. public void captureInvoiceIntent(PosOrderInvoice intent) {
  186. intent.setInvoiceStatus(STATUS_NOT_ISSUED);
  187. intent.setIssueTriggered(0);
  188. if (intent.getApplyTime() == null) {
  189. intent.setApplyTime(new Date());
  190. }
  191. saveOrUpdate(intent);
  192. }
  193. /**
  194. * 商家出餐自动开票(010 Phase 11):读 pos_order_invoice 发票意图行 → 组装 dto(个人 BuyerName=手机号、
  195. * 捐赠=机构名)→ 复用开票核心 {@link #reIssueAndSave}。系统触发,不校验客户归属;货到付款未付款也开
  196. * (送达失败/取消时作废兜底)。门店不可开票 / 订单无发票意图 / 已开 → 静默跳过,不影响出餐。
  197. *
  198. * @param orderId PosOrder.id(PK,与 invalid/retry/selectInvoiceDetail 一致)
  199. * @return 1=已开 0=跳过或失败
  200. */
  201. public int autoIssue(Long orderId) {
  202. PosOrder order = posOrderMapper.selectPosOrderById(orderId);
  203. if (order == null) {
  204. return 0; // 订单不存在
  205. }
  206. // 发票意图自 Phase 11 起落 pos_order_invoice(下单捕获的意图行),不再读 pos_order
  207. PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
  208. new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
  209. if (row == null || StrUtil.isBlank(row.getInvoiceChoice())) {
  210. return 0; // 无发票意图(门店不可开票时 createOrder 未插意图行)
  211. }
  212. Long storeId = order.getMdId();
  213. if (!canInvoice(storeId)) {
  214. return 0; // 出餐时门店已不可开票(被停用等),跳过
  215. }
  216. PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
  217. new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
  218. if (ez == null) {
  219. return 0;
  220. }
  221. // 防重复:已开则跳过
  222. if (Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
  223. return 0;
  224. }
  225. ApplyInvoiceDto dto = buildAutoIssueDto(row);
  226. EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
  227. // 捐赠=机构名;个人=手机号
  228. String loveOrgName = null;
  229. if ("LOVE_CODE".equals(row.getInvoiceChoice())) {
  230. loveOrgName = resolveLoveOrg(dto.getLoveCode(), cfg);
  231. dto.setBuyerName(loveOrgName);
  232. } else if (isPersonalChoice(row.getInvoiceChoice())) {
  233. InfoUser u = infoUserService.getOne(
  234. new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, order.getUserId()));
  235. String phone = u != null && StrUtil.isNotBlank(u.getPhone()) ? u.getPhone() : "會員";
  236. dto.setBuyerName(phone);
  237. if ("MEMBER".equals(row.getInvoiceChoice())) {
  238. // 会员载具:载具号=会员手机号;同步落库保持发票行载具信息完整
  239. dto.setCarrierNum(phone);
  240. row.setCarrierType("2");
  241. row.setCarrierNum(phone);
  242. }
  243. }
  244. // 出餐触发开票:在意图行上落结果,并标记已触发(与 invoiceStatus 独立)
  245. row.setInvoiceCategory(dto.getCategory());
  246. row.setBuyerName(dto.getBuyerName());
  247. row.setLoveOrgName(loveOrgName);
  248. row.setIssueTriggered(1);
  249. return reIssueAndSave(order, row, dto, ez);
  250. }
  251. /** 出餐自动开票:pos_order_invoice 发票意图行 → ApplyInvoiceDto(捐赠走 DONATION 复用既有分支)。 */
  252. private ApplyInvoiceDto buildAutoIssueDto(PosOrderInvoice intent) {
  253. ApplyInvoiceDto dto = new ApplyInvoiceDto();
  254. dto.setOrderId(intent.getOrderId());
  255. String choice = intent.getInvoiceChoice();
  256. switch (choice) {
  257. case "PAPER":
  258. case "PHONE_BARCODE":
  259. case "CITIZEN":
  260. dto.setCategory("B2C");
  261. dto.setCarrierType(intent.getCarrierType()); // PAPER 时为 null → 个人纸本分支
  262. dto.setCarrierNum(intent.getCarrierNum());
  263. break;
  264. case "MEMBER":
  265. // 会员载具 ezPay 类型 2;载具号(会员手机号)与买方名由 autoIssue 取 InfoUser.phone 回填
  266. dto.setCategory("B2C");
  267. dto.setCarrierType("2");
  268. break;
  269. case "LOVE_CODE":
  270. dto.setCategory("DONATION");
  271. dto.setLoveCode(intent.getLoveCode());
  272. break;
  273. case "COMPANY":
  274. dto.setCategory("B2B");
  275. dto.setBuyerUbn(intent.getBuyerUbn());
  276. dto.setBuyerName(intent.getBuyerName());
  277. dto.setBuyerEmail(intent.getBuyerEmail());
  278. break;
  279. default:
  280. break;
  281. }
  282. return dto;
  283. }
  284. private boolean isPersonalChoice(String choice) {
  285. return "PAPER".equals(choice) || "PHONE_BARCODE".equals(choice) || "CITIZEN".equals(choice)
  286. || "MEMBER".equals(choice);
  287. }
  288. /** 客户查询订单发票;门店不可开票时 invoiceStatus=null(客户端据此隐藏入口)。 */
  289. public PosOrderInvoiceVo getInvoice(Long orderId, Long currentUserId) {
  290. PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,String.valueOf(orderId)));
  291. if (order == null || !Objects.equals(order.getUserId(), currentUserId)) {
  292. throw new ServiceException("订单不存在");
  293. }
  294. boolean can = canInvoice(order.getMdId());
  295. PosOrderInvoiceVo vo = posOrderInvoiceMapper.selectInvoiceDetail(order.getId());
  296. if (vo == null) {
  297. vo = new PosOrderInvoiceVo();
  298. vo.setOrderId(order.getId());
  299. vo.setOrderNo(order.getDdId());
  300. vo.setInvoiceStatus(can ? STATUS_NOT_ISSUED : null);
  301. }
  302. return vo;
  303. }
  304. /** 商家端查询订单发票(校验商家为该订单门店归属人)。 */
  305. public PosOrderInvoiceVo getInvoiceForMerchant(Long orderId, Long merchantUserId) {
  306. requireOwnedOrder(orderId, merchantUserId);
  307. return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
  308. }
  309. /** 商家端作废发票(校验归属后复用作废逻辑)。 */
  310. public int invalidForMerchant(Long orderId, String reason, Long merchantUserId) {
  311. requireOwnedOrder(orderId, merchantUserId);
  312. return invalid(orderId, reason);
  313. }
  314. /** 校验订单存在且属于该商家门店,返回订单;否则抛异常。 */
  315. private PosOrder requireOwnedOrder(Long orderId, Long merchantUserId) {
  316. PosOrder order = posOrderMapper.selectPosOrderById(orderId);
  317. if (order == null) {
  318. throw new ServiceException("订单不存在");
  319. }
  320. InfoUser user = infoUserService.getOne(
  321. new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, merchantUserId));
  322. if (user == null) {
  323. throw new ServiceException("用户不存在");
  324. }
  325. String ut = user.getUserType();
  326. boolean own;
  327. if ("1".equals(ut) || "3".equals(ut)) {
  328. // 普通/夜市商家:订单 shId 即商家 userId
  329. own = merchantUserId.equals(order.getShId());
  330. } else {
  331. // 摊位商家:订单门店 mdId 即商家 storeId
  332. own = user.getStoreId() != null && user.getStoreId().equals(order.getMdId());
  333. }
  334. if (!own) {
  335. throw new ServiceException("无权操作该订单发票");
  336. }
  337. return order;
  338. }
  339. // ==================== US4/US5:管理端 ====================
  340. /** 管理端:订单发票列表(配合 PageHelper 分页)。 */
  341. public List<PosOrderInvoiceVo> list(PosOrderInvoiceVo query) {
  342. if (query != null && query.getDateRange() != null && query.getDateRange().length == 2) {
  343. query.setCretimStart(query.getDateRange()[0]);
  344. query.setCretimEnd(query.getDateRange()[1]);
  345. }
  346. return posOrderInvoiceMapper.selectInvoiceList(query);
  347. }
  348. /** 会员发票夹(010 Phase 11):按 userId 列已开发票(不含码图)。配合 PageHelper 分页。 */
  349. public List<PosOrderInvoiceVo> listMyInvoices(Long userId) {
  350. return posOrderInvoiceMapper.selectMyInvoices(userId);
  351. }
  352. /** 管理端:订单发票详情。 */
  353. public PosOrderInvoiceVo detail(Long orderId) {
  354. return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
  355. }
  356. /** 管理端:重新开票(仅 失败/作废 单;运营身份不校验客户归属)。 */
  357. public int retry(Long orderId) {
  358. PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
  359. new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
  360. if (row == null) {
  361. throw new ServiceException("发票记录不存在");
  362. }
  363. int st = row.getInvoiceStatus() == null ? STATUS_NOT_ISSUED : row.getInvoiceStatus();
  364. if (st != STATUS_FAILED && st != STATUS_INVALID) {
  365. throw new ServiceException("仅失败或作废的发票可重新开票");
  366. }
  367. // 重置为未开,复用客户开票参数重开(运营身份 currentUserId 传 null 跳过归属校验)
  368. // orderId 实为 pos_order.id(主键,发票行 order_id 已证实),按主键查最稳,不依赖 order_no 是否回填
  369. PosOrder order = posOrderMapper.selectPosOrderById(orderId);
  370. if (order == null) {
  371. throw new ServiceException("订单不存在");
  372. }
  373. PosStoreEzpay ez = assertInvoiceable(order.getMdId());
  374. ApplyInvoiceDto dto = new ApplyInvoiceDto();
  375. dto.setOrderId(orderId);
  376. dto.setCategory(row.getInvoiceCategory());
  377. dto.setBuyerName(row.getBuyerName());
  378. dto.setBuyerUbn(row.getBuyerUbn());
  379. dto.setBuyerEmail(row.getBuyerEmail());
  380. dto.setCarrierType(row.getCarrierType());
  381. dto.setCarrierNum(row.getCarrierNum());
  382. dto.setLoveCode(row.getLoveCode());
  383. // 复用核心开票(不含归属校验):直接走 issue + 落库
  384. return reIssueAndSave(order, row, dto, ez);
  385. }
  386. /** 管理端:作废发票(仅 已开 单,调 ezPay invoice_invalid)。 */
  387. public int invalid(Long orderId, String reason) {
  388. PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
  389. new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
  390. if (row == null || !Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
  391. throw new ServiceException("仅已开发票可作废");
  392. }
  393. PosOrder order = posOrderMapper.selectPosOrderById(orderId);
  394. PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
  395. new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, order.getMdId()));
  396. if (ez == null) {
  397. throw new ServiceException("门店 ezPay 凭证缺失,无法作废");
  398. }
  399. Map<String, Object> postData = new LinkedHashMap<>();
  400. postData.put("RespondType", "JSON");
  401. postData.put("Version", "1.0");
  402. postData.put("InvoiceNumber", row.getInvoiceNumber());
  403. postData.put("InvalidReason", StrUtil.isBlank(reason) ? "商家作废" : reason);
  404. try {
  405. EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
  406. JSONObject resp = ezPay.doPost(ezpayBaseUrl + EzPay.URL_INVALID, cfg, postData); String status = resp == null ? "" : resp.getString("Status");
  407. if ("SUCCESS".equals(status)) {
  408. row.setInvoiceStatus(STATUS_INVALID);
  409. row.setInvalidTime(new Date());
  410. saveOrUpdate(row);
  411. return 1;
  412. }
  413. // ezPay 回非 SUCCESS 且消息含「已作廢」:该票在 ezPay 端已是作废态,本地对齐成作废,
  414. // 避免与 ezPay 不同步时(如作废后 retry 复活死票)陷入「再作废也改不回 status」的死锁
  415. String msg = resp == null ? "" : resp.getString("Message");
  416. if (msg != null && msg.contains("已作廢")) {
  417. row.setInvoiceStatus(STATUS_INVALID);
  418. saveOrUpdate(row);
  419. return 1;
  420. }
  421. throw new ServiceException("作废失败:" + (resp == null ? "ezPay 无回应" : msg));
  422. } catch (ServiceException se) {
  423. throw se;
  424. } catch (Exception e) {
  425. throw new ServiceException("ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
  426. }
  427. }
  428. // ==================== 内部辅助 ====================
  429. /** 复用开票核心(运营重开,无归属校验)。 */
  430. private int reIssueAndSave(PosOrder order, PosOrderInvoice row, ApplyInvoiceDto dto, PosStoreEzpay ez) {
  431. int amount = order.getAmount() == null ? 0 : order.getAmount();
  432. int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight());
  433. int invoiceTotal = amount - freight;
  434. int sales = (int) Math.round(invoiceTotal / 1.05);
  435. int tax = invoiceTotal - sales;
  436. Map<String, Object> inv = buildIssueData(dto, order, invoiceTotal, sales, tax);
  437. EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
  438. int newStatus;
  439. String invoiceNumber = null, randomNum = null, invoiceTransNo = null,
  440. invoiceBarCode = null, invoiceQrcodeL = null, invoiceQrcodeR = null, failReason = null;
  441. try {
  442. JSONObject resp = ezPay.issueInvoice(ezpayBaseUrl, cfg, inv);
  443. String status = resp == null ? "" : resp.getString("Status");
  444. JSONObject result = resp == null ? null : resp.getJSONObject("Result");
  445. if ("SUCCESS".equals(status) && result != null && StrUtil.isNotBlank(result.getString("InvoiceNumber"))) {
  446. invoiceNumber = result.getString("InvoiceNumber");
  447. randomNum = result.getString("RandomNum");
  448. invoiceTransNo = result.getString("InvoiceTransNo");
  449. invoiceBarCode = result.getString("BarCode");
  450. invoiceQrcodeL = result.getString("QRcodeL");
  451. invoiceQrcodeR = result.getString("QRcodeR");
  452. newStatus = STATUS_ISSUED;
  453. } else {
  454. failReason = resp == null ? "ezPay 无回应" : (status + ":" + resp.getString("Message"));
  455. newStatus = STATUS_FAILED;
  456. }
  457. } catch (Exception e) {
  458. failReason = "ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
  459. newStatus = STATUS_FAILED;
  460. }
  461. Date now = new Date();
  462. if (newStatus == STATUS_ISSUED) {
  463. row.setInvoiceNumber(invoiceNumber);
  464. row.setRandomNum(randomNum);
  465. row.setInvoiceTransNo(invoiceTransNo);
  466. row.setInvoiceBarCode(invoiceBarCode);
  467. row.setInvoiceQrcodeL(invoiceQrcodeL);
  468. row.setInvoiceQrcodeR(invoiceQrcodeR);
  469. row.setIssueTime(now);
  470. row.setFailReason(null);
  471. } else {
  472. row.setFailReason(StrUtil.sub(failReason, 0, 480));
  473. }
  474. row.setTotalAmt(invoiceTotal);
  475. row.setSalesAmt(sales);
  476. row.setTaxAmt(tax);
  477. row.setInvoiceStatus(newStatus);
  478. saveOrUpdate(row);
  479. return newStatus == STATUS_ISSUED ? 1 : 0;
  480. }
  481. /** 校验门店可开票并返回凭证;不可开票抛异常。 */
  482. private PosStoreEzpay assertInvoiceable(Long storeId) {
  483. if (storeId == null) {
  484. throw new ServiceException("订单未关联门店,暂不可开票");
  485. }
  486. PosStore store = posStoreMapper.selectPosStoreById(storeId);
  487. if (store != null && Integer.valueOf(1).equals(store.getInvoiceExempt())) {
  488. throw new ServiceException("该门店免用发票,无需开票");
  489. }
  490. PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
  491. new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
  492. if (ez == null || !Integer.valueOf(2).equals(ez.getEzpayStatus())
  493. || !Integer.valueOf(1).equals(ez.getIsEnabled())) {
  494. throw new ServiceException("该门店暂不支持电子发票");
  495. }
  496. return ez;
  497. }
  498. /** 门店是否可开票(不抛异常):免用发票或 ezPay 未开通启用均返回 false。 */
  499. public boolean canInvoice(Long storeId) {
  500. if (storeId == null) {
  501. return false;
  502. }
  503. PosStore store = posStoreMapper.selectPosStoreById(storeId);
  504. if (store != null && Integer.valueOf(1).equals(store.getInvoiceExempt())) {
  505. return false;
  506. }
  507. PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
  508. new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
  509. return ez != null && Integer.valueOf(2).equals(ez.getEzpayStatus())
  510. && Integer.valueOf(1).equals(ez.getIsEnabled());
  511. }
  512. /** 取门店 ezPay 凭证配置;未开通/未启用返回 null(不抛异常,供校验场景容忍使用)。 */
  513. private EzPayConfig getEzpayConfig(Long storeId) {
  514. if (storeId == null) {
  515. return null;
  516. }
  517. PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
  518. new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
  519. if (ez == null || !Integer.valueOf(2).equals(ez.getEzpayStatus())
  520. || !Integer.valueOf(1).equals(ez.getIsEnabled())) {
  521. return null;
  522. }
  523. return new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
  524. }
  525. /**
  526. * 结账时校验发票意图(010 Phase 11):载具/捐赠联网验真、统编/凭证格式校验。
  527. * PAPER(默认纸本)不校验。联网校验(手机条码/捐赠码)用 storeId 对应门店的 ezPay 凭证调 BDV。
  528. *
  529. * @param storeId 一个可开票门店(调用方负责选 canInvoice 的门店,用于取 ezPay 凭证)
  530. */
  531. public void validateCheckoutInvoiceIntent(String invoiceChoice, String carrierNum, String loveCode,
  532. String buyerUbn, String buyerEmail, Long storeId) {
  533. if (StrUtil.isBlank(invoiceChoice) || "PAPER".equals(invoiceChoice)) {
  534. return;
  535. }
  536. switch (invoiceChoice) {
  537. case "PHONE_BARCODE":
  538. if (StrUtil.isBlank(carrierNum) || !carrierNum.matches("^/[0-9A-Z+\\-.]{7}$")) {
  539. throw new ServiceException(MessageUtils.message("no.invoice.barcode.format"));
  540. }
  541. EzPayConfig barCfg = getEzpayConfig(storeId);
  542. if (barCfg == null) {
  543. throw new ServiceException(MessageUtils.message("no.invoice.store.notsupport.barcode"));
  544. }
  545. boolean barExist;
  546. try {
  547. barExist = ezPay.checkBarCode(ezpayBaseUrl, barCfg, carrierNum);
  548. } catch (Exception e) {
  549. throw new ServiceException(MessageUtils.message("no.invoice.barcode.service.unavailable"));
  550. }
  551. if (!barExist) {
  552. throw new ServiceException(MessageUtils.message("no.invoice.barcode.notexist"));
  553. }
  554. break;
  555. case "CITIZEN":
  556. if (StrUtil.isBlank(carrierNum) || !carrierNum.matches("^[A-Z]{2}\\d{14}$")) {
  557. throw new ServiceException(MessageUtils.message("no.invoice.citizen.format"));
  558. }
  559. break;
  560. case "MEMBER":
  561. // 会员载具:载具号=会员手机号(出餐 autoIssue 时取 InfoUser.phone),免联网验真、免格式校验
  562. break;
  563. case "LOVE_CODE":
  564. if (StrUtil.isBlank(loveCode) || !loveCode.matches("^\\d{3,7}$")) {
  565. throw new ServiceException(MessageUtils.message("no.invoice.lovecode.format"));
  566. }
  567. EzPayConfig loveCfg = getEzpayConfig(storeId);
  568. if (loveCfg == null) {
  569. throw new ServiceException(MessageUtils.message("no.invoice.store.notsupport.lovecode"));
  570. }
  571. resolveLoveOrg(loveCode, loveCfg); // 抛异常=捐赠码无效
  572. break;
  573. case "COMPANY":
  574. if (StrUtil.isBlank(buyerUbn) || !buyerUbn.matches("^\\d{8}$")) {
  575. throw new ServiceException(MessageUtils.message("no.invoice.ubn.format"));
  576. }
  577. if (StrUtil.isBlank(buyerEmail) || !buyerEmail.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
  578. throw new ServiceException(MessageUtils.message("no.invoice.company.email"));
  579. }
  580. break;
  581. default:
  582. throw new ServiceException(MessageUtils.message("no.invoice.choice.invalid", invoiceChoice));
  583. }
  584. }
  585. /**
  586. * 捐赠机构列表(客户端选择器,仅 enabled=1,配合 PageHelper 分页)。
  587. */
  588. public List<PosLoveOrg> listLoveOrg(String keyword) {
  589. return posLoveOrgMapper.selectListByKeyword(keyword);
  590. }
  591. /**
  592. * 解析捐赠机构并验真:local 命中返回简称/全名;local 未命中调 ezPay checkLoveCode
  593. * (IsExist=N 抛「捐赠码无效」,调用异常抛「验证失败」)。返回用于展示 / BuyerName 的机构名。
  594. */
  595. private String resolveLoveOrg(String loveCode, EzPayConfig cfg) {
  596. PosLoveOrg org = posLoveOrgMapper.selectOne(new LambdaQueryWrapper<PosLoveOrg>()
  597. .eq(PosLoveOrg::getLoveCode, loveCode)
  598. .eq(PosLoveOrg::getEnabled, 1));
  599. if (org != null) {
  600. return StrUtil.isNotBlank(org.getOrgShortName()) ? org.getOrgShortName() : org.getOrgName();
  601. }
  602. try {
  603. if (!ezPay.checkLoveCode(ezpayBaseUrl, cfg, loveCode)) {
  604. throw new ServiceException(MessageUtils.message("no.invoice.lovecode.invalid"));
  605. }
  606. } catch (ServiceException se) {
  607. throw se;
  608. } catch (Exception e) {
  609. throw new ServiceException(MessageUtils.message("no.invoice.lovecode.verify.fail"));
  610. }
  611. return "未知机构";
  612. }
  613. /**
  614. * 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。对齐 ezPay 开票模型:
  615. * B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填;会员载具(2)载具号=会员手机号、不收邮箱。
  616. */
  617. private void validateInvoiceInput(ApplyInvoiceDto dto) {
  618. if ("B2B".equals(dto.getCategory())) {
  619. if (StrUtil.isBlank(dto.getBuyerName())) {
  620. throw new ServiceException(MessageUtils.message("no.invoice.b2b.buyername.blank"));
  621. }
  622. if (StrUtil.isBlank(dto.getBuyerUbn()) || !dto.getBuyerUbn().matches("\\d{8}")) {
  623. throw new ServiceException(MessageUtils.message("no.invoice.ubn.format"));
  624. }
  625. if (StrUtil.isBlank(dto.getBuyerEmail())) {
  626. throw new ServiceException(MessageUtils.message("no.invoice.b2b.buyeremail.blank"));
  627. }
  628. if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) {
  629. throw new ServiceException(MessageUtils.message("no.invoice.b2b.nocarrier"));
  630. }
  631. } else if ("DONATION".equals(dto.getCategory())) {
  632. // 捐赠:loveCode 3-7 位必填,与载具/统编互斥
  633. if (StrUtil.isBlank(dto.getLoveCode()) || !dto.getLoveCode().matches("\\d{3,7}")) {
  634. throw new ServiceException(MessageUtils.message("no.invoice.lovecode.format"));
  635. }
  636. if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) {
  637. throw new ServiceException(MessageUtils.message("no.invoice.donation.nocarrier"));
  638. }
  639. if (StrUtil.isNotBlank(dto.getBuyerUbn())) {
  640. throw new ServiceException(MessageUtils.message("no.invoice.donation.noubn"));
  641. }
  642. } else if ("B2C".equals(dto.getCategory())) {
  643. // 载具分支
  644. if (StrUtil.isBlank(dto.getBuyerName())) {
  645. throw new ServiceException(MessageUtils.message("no.invoice.buyername.blank"));
  646. }
  647. String ct = dto.getCarrierType();
  648. if (StrUtil.isBlank(ct)) {
  649. // 无载具 → 个人纸本(PrintFlag=Y,零信息兜底),无需载具校验
  650. return;
  651. }
  652. if (!"0".equals(ct) && !"1".equals(ct) && !"2".equals(ct)) {
  653. throw new ServiceException(MessageUtils.message("no.invoice.carriertype.invalid"));
  654. }
  655. if (StrUtil.isBlank(dto.getCarrierNum())) {
  656. throw new ServiceException(MessageUtils.message("no.invoice.carriernum.blank"));
  657. }
  658. // ezPay doc:载具号码前后不得含空白
  659. dto.setCarrierNum(dto.getCarrierNum().trim());
  660. // ezPay doc:手机条码(0)= / +7码[0-9A-Z+\-.];自然人凭证(1)=2大写英+14数字;ezPay会员(2)=卖方自订代号,不校验
  661. switch (ct) {
  662. case "0":
  663. if (!dto.getCarrierNum().matches("^/[0-9A-Z+\\-.]{7}$")) {
  664. throw new ServiceException(MessageUtils.message("no.invoice.barcode.format"));
  665. }
  666. break;
  667. case "1":
  668. if (!dto.getCarrierNum().matches("^[A-Z]{2}\\d{14}$")) {
  669. throw new ServiceException(MessageUtils.message("no.invoice.citizen.format"));
  670. }
  671. break;
  672. case "2":
  673. default:
  674. break;
  675. }
  676. }
  677. }
  678. /** 组装 ezPay invoice_issue 业务参数(Category/PrintFlag/Buyer/Amt/明细)。 */
  679. private Map<String, Object> buildIssueData(ApplyInvoiceDto dto, PosOrder order,
  680. int invoiceTotal, int sales, int tax) {
  681. Map<String, Object> inv = new LinkedHashMap<>();
  682. inv.put("MerchantOrderNo", order.getDdId());
  683. // ezPay Category 只有 B2C/B2B;DONATION 是我方语义,对应 ezPay B2C + LoveCode
  684. inv.put("Category", "DONATION".equals(dto.getCategory()) ? "B2C" : dto.getCategory());
  685. inv.put("BuyerName", dto.getBuyerName());
  686. if ("B2B".equals(dto.getCategory())) {
  687. inv.put("BuyerUBN", dto.getBuyerUbn());
  688. inv.put("BuyerEmail", dto.getBuyerEmail()); // B2B 邮箱必填:ezPay 发开立通知给买方,凭此查看
  689. inv.put("PrintFlag", "Y");
  690. } else if ("DONATION".equals(dto.getCategory())) {
  691. // 捐赠:LoveCode + PrintFlag=N,无载具/邮箱/统编(买方名=机构名,已在 applyInvoice 回填)
  692. inv.put("LoveCode", dto.getLoveCode());
  693. inv.put("PrintFlag", "N");
  694. } else {
  695. // B2C:有载具 → PrintFlag=N 存载具;无载具 → PrintFlag=Y 个人纸本(零信息兜底,010 Phase 11)
  696. if (StrUtil.isNotBlank(dto.getCarrierType())) {
  697. inv.put("CarrierType", dto.getCarrierType());
  698. inv.put("CarrierNum", dto.getCarrierNum());
  699. inv.put("PrintFlag", "N");
  700. // ezPay 手册:CarrierType=2(ezPay会员载具)时 BuyerEmail 必填(INV10013);CarrierNum=卖方自订代号(手机/邮箱/会员编号)
  701. if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) {
  702. inv.put("BuyerEmail", dto.getBuyerEmail());
  703. }
  704. } else {
  705. // 个人纸本:无载具无捐赠,法定 PrintFlag=Y;不传载具/邮箱
  706. inv.put("PrintFlag", "Y");
  707. }
  708. }
  709. inv.put("TaxType", "1");
  710. inv.put("TaxRate", "5");
  711. inv.put("Amt", String.valueOf(sales));
  712. inv.put("TaxAmt", String.valueOf(tax));
  713. inv.put("TotalAmt", String.valueOf(invoiceTotal));
  714. appendItems(inv, order.getFood(), invoiceTotal);
  715. return inv;
  716. }
  717. /**
  718. * 逐商品明细(research D2/D3):从 food JSON 解析,优惠按比例分摊到单价(方案 B)。
  719. * 每行满足 ItemCount × ItemPrice = ItemAmt(单价决定金额,ezPay 逐商品校验通过)。
  720. */
  721. private void appendItems(Map<String, Object> inv, String foodJson, int invoiceTotal) {
  722. JSONArray arr = StrUtil.isBlank(foodJson) ? null : com.alibaba.fastjson.JSON.parseArray(foodJson);
  723. if (arr == null || arr.isEmpty()) {
  724. inv.put("ItemName", "餐点");
  725. inv.put("ItemCount", "1");
  726. inv.put("ItemUnit", "份");
  727. inv.put("ItemPrice", String.valueOf(invoiceTotal));
  728. inv.put("ItemAmt", String.valueOf(invoiceTotal));
  729. return;
  730. }
  731. int foodTotal = foodOriginalTotal(arr);
  732. double ratio = foodTotal > 0 ? (double) invoiceTotal / foodTotal : 1.0;
  733. StringBuilder name = new StringBuilder(), count = new StringBuilder(),
  734. unit = new StringBuilder(), price = new StringBuilder(), amt = new StringBuilder();
  735. for (int i = 0; i < arr.size(); i++) {
  736. com.alibaba.fastjson.JSONObject it = arr.getJSONObject(i);
  737. int p = it.getIntValue("price") + it.getIntValue("otherPrice");
  738. int c = it.getIntValue("number");
  739. if (c <= 0) {
  740. c = 1;
  741. }
  742. int itemPrice = (int) Math.round(p * ratio);
  743. int itemAmt = itemPrice * c;
  744. if (i > 0) {
  745. name.append("|"); count.append("|"); unit.append("|"); price.append("|"); amt.append("|");
  746. }
  747. String nm = it.getString("name");
  748. name.append(StrUtil.isBlank(nm) ? "餐点" : nm);
  749. count.append(c);
  750. unit.append("個");
  751. price.append(itemPrice);
  752. amt.append(itemAmt);
  753. }
  754. inv.put("ItemName", name.toString());
  755. inv.put("ItemCount", count.toString());
  756. inv.put("ItemUnit", unit.toString());
  757. inv.put("ItemPrice", price.toString());
  758. inv.put("ItemAmt", amt.toString());
  759. }
  760. /** food 商品原价总额(含税)= Σ(price + otherPrice) × number。 */
  761. private int foodOriginalTotal(JSONArray arr) {
  762. int sum = 0;
  763. for (int i = 0; i < arr.size(); i++) {
  764. com.alibaba.fastjson.JSONObject it = arr.getJSONObject(i);
  765. int p = it.getIntValue("price") + it.getIntValue("otherPrice");
  766. int c = it.getIntValue("number");
  767. sum += p * (c <= 0 ? 1 : c);
  768. }
  769. return sum;
  770. }
  771. /** 新增或更新(显式设置时间与人)。 */
  772. private void saveOrUpdate(PosOrderInvoice row) {
  773. Date now = new Date();
  774. String user = currentUser();
  775. if (row.getId() == null) {
  776. row.setCreateTime(now);
  777. row.setUpdateTime(now);
  778. row.setCreateBy(user);
  779. row.setUpdateBy(user);
  780. posOrderInvoiceMapper.insert(row);
  781. } else {
  782. row.setUpdateTime(now);
  783. row.setUpdateBy(user);
  784. posOrderInvoiceMapper.updateById(row);
  785. }
  786. }
  787. private String currentUser() {
  788. try {
  789. return SecurityUtils.getUsername();
  790. } catch (Exception e) {
  791. return "system";
  792. }
  793. }
  794. }