OrderInvoiceService.java 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  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.SecurityUtils;
  10. import com.ruoyi.system.domain.PosOrder;
  11. import com.ruoyi.system.domain.PosOrderInvoice;
  12. import com.ruoyi.system.domain.PosStore;
  13. import com.ruoyi.system.domain.PosStoreEzpay;
  14. import com.ruoyi.system.domain.InfoUser;
  15. import com.ruoyi.system.domain.dto.ApplyInvoiceDto;
  16. import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
  17. import com.ruoyi.system.mapper.PosOrderInvoiceMapper;
  18. import com.ruoyi.system.mapper.PosOrderMapper;
  19. import com.ruoyi.system.mapper.PosStoreEzpayMapper;
  20. import com.ruoyi.system.mapper.PosStoreMapper;
  21. import com.ruoyi.system.service.IInfoUserService;
  22. import org.springframework.beans.factory.annotation.Autowired;
  23. import org.springframework.beans.factory.annotation.Value;
  24. import org.springframework.dao.DuplicateKeyException;
  25. import org.springframework.stereotype.Service;
  26. import java.util.Date;
  27. import java.util.LinkedHashMap;
  28. import java.util.List;
  29. import java.util.Map;
  30. import java.util.Objects;
  31. /**
  32. * 订单电子发票 开票 / 作废 / 查询 业务(放 admin,因需调用 {@link EzPay})。
  33. *
  34. * <p>开票链路({@link #applyInvoice}):校验订单归属/状态/支付 → 校验门店可开票(009 凭证 + 免用发票)
  35. * → 金额拆分(不含运费)→ 组装 ezPay issue 参数 + 逐商品明细 → 调 {@link EzPay#issueInvoice}
  36. * → 判读回应 → 落库。作废走 {@link EzPay#doPost} + invoice_invalid。
  37. *
  38. * @author ruoyi
  39. * @date 2026-06-16
  40. */
  41. @Service
  42. public class OrderInvoiceService {
  43. @Autowired
  44. private EzPay ezPay;
  45. @Autowired
  46. private PosOrderMapper posOrderMapper;
  47. @Autowired
  48. private PosStoreMapper posStoreMapper;
  49. @Autowired
  50. private PosStoreEzpayMapper posStoreEzpayMapper;
  51. @Autowired
  52. private PosOrderInvoiceMapper posOrderInvoiceMapper;
  53. @Autowired
  54. private IInfoUserService infoUserService;
  55. /** ezPay 接口根地址(从 application.yml 的 ezpay.base-url 注入,测试/正式在此切换) */
  56. @Value("${ezpay.base-url}")
  57. private String ezpayBaseUrl;
  58. /** 发票状态:0未开/1已开/2失败/3作废 */
  59. private static final int STATUS_NOT_ISSUED = 0;
  60. private static final int STATUS_ISSUED = 1;
  61. private static final int STATUS_FAILED = 2;
  62. private static final int STATUS_INVALID = 3;
  63. /** 订单完成态、已支付 */
  64. private static final long ORDER_STATE_DONE = 3L;
  65. private static final long ORDER_PAID = 1L;
  66. // ==================== US1/US2/US3:客户开票 ====================
  67. /**
  68. * 客户申请开票。校验 → 金额拆分 → 调 ezPay 即时开立 → 落库。
  69. * B2C/B2B/载具由 {@link #buildIssueData} 按 {@code category}/{@code carrierType} 分支处理。
  70. */
  71. public PosOrderInvoiceVo applyInvoice(ApplyInvoiceDto dto, Long currentUserId) {
  72. Long orderId = dto.getOrderId();
  73. PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,orderId.toString()));
  74. if (order == null) {
  75. throw new ServiceException("订单不存在");
  76. }
  77. if (!Objects.equals(order.getUserId(), currentUserId)) {
  78. throw new ServiceException("无权操作该订单");
  79. }
  80. if (order.getState() == null || order.getState() != ORDER_STATE_DONE) {
  81. throw new ServiceException("订单未完成,暂不可开票");
  82. }
  83. if (order.getPayStatus() == null || order.getPayStatus() != ORDER_PAID) {
  84. throw new ServiceException("订单未支付,暂不可开票");
  85. }
  86. Long storeId = order.getMdId();
  87. PosStoreEzpay ez = assertInvoiceable(storeId);
  88. // 场景入参校验(B2B 统编 / B2C 邮箱 / 载具号码)
  89. validateInvoiceInput(dto);
  90. // 防重复开票
  91. PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
  92. new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
  93. if (row != null && Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
  94. throw new ServiceException("该订单已开票,不可重复开票");
  95. }
  96. // 金额拆分(不含运费:invoiceTotal = amount - freight)
  97. int amount = order.getAmount() == null ? 0 : order.getAmount();
  98. int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight());
  99. int invoiceTotal = amount - freight;
  100. if (invoiceTotal <= 0) {
  101. throw new ServiceException("订单可开票金额异常,无法开票");
  102. }
  103. int sales = (int) Math.round(invoiceTotal / 1.05);
  104. int tax = invoiceTotal - sales;
  105. // 组装 ezPay issue 参数 + 调用
  106. Map<String, Object> inv = buildIssueData(dto, order, invoiceTotal, sales, tax);
  107. EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
  108. int newStatus;
  109. String invoiceNumber = null, randomNum = null, invoiceTransNo = null,
  110. invoiceBarCode = null, invoiceQrcodeL = null, invoiceQrcodeR = null, failReason = null;
  111. try {
  112. JSONObject resp = ezPay.issueInvoice(ezpayBaseUrl, cfg, inv); String status = resp == null ? "" : resp.getString("Status");
  113. JSONObject result = resp == null ? null : resp.getJSONObject("Result");
  114. if ("SUCCESS".equals(status) && result != null && StrUtil.isNotBlank(result.getString("InvoiceNumber"))) {
  115. invoiceNumber = result.getString("InvoiceNumber");
  116. randomNum = result.getString("RandomNum");
  117. invoiceTransNo = result.getString("InvoiceTransNo");
  118. invoiceBarCode = result.getString("BarCode");
  119. invoiceQrcodeL = result.getString("QRcodeL");
  120. invoiceQrcodeR = result.getString("QRcodeR");
  121. newStatus = STATUS_ISSUED;
  122. } else {
  123. failReason = resp == null ? "ezPay 无回应" : (status + ":" + resp.getString("Message"));
  124. newStatus = STATUS_FAILED;
  125. }
  126. } catch (Exception e) {
  127. failReason = "ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
  128. newStatus = STATUS_FAILED;
  129. }
  130. // 落库(uk_order_id 兜底防并发重复)
  131. Date now = new Date();
  132. PosOrderInvoice saveRow = row == null ? new PosOrderInvoice() : row;
  133. saveRow.setOrderId(orderId);
  134. saveRow.setOrderNo(order.getDdId());
  135. saveRow.setStoreId(storeId);
  136. saveRow.setInvoiceCategory(dto.getCategory());
  137. saveRow.setBuyerName(dto.getBuyerName());
  138. saveRow.setBuyerUbn(dto.getBuyerUbn());
  139. saveRow.setBuyerEmail(dto.getBuyerEmail());
  140. saveRow.setCarrierType(dto.getCarrierType());
  141. saveRow.setCarrierNum(dto.getCarrierNum());
  142. saveRow.setTotalAmt(invoiceTotal);
  143. saveRow.setSalesAmt(sales);
  144. saveRow.setTaxAmt(tax);
  145. saveRow.setApplyTime(now);
  146. if (newStatus == STATUS_ISSUED) {
  147. saveRow.setInvoiceNumber(invoiceNumber);
  148. saveRow.setRandomNum(randomNum);
  149. saveRow.setInvoiceTransNo(invoiceTransNo);
  150. saveRow.setInvoiceBarCode(invoiceBarCode);
  151. saveRow.setInvoiceQrcodeL(invoiceQrcodeL);
  152. saveRow.setInvoiceQrcodeR(invoiceQrcodeR);
  153. saveRow.setIssueTime(now);
  154. saveRow.setFailReason(null);
  155. } else {
  156. saveRow.setFailReason(StrUtil.sub(failReason, 0, 480));
  157. }
  158. saveRow.setInvoiceStatus(newStatus);
  159. try {
  160. saveOrUpdate(saveRow);
  161. } catch (DuplicateKeyException dup) {
  162. throw new ServiceException("请勿重复提交开票申请");
  163. }
  164. return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
  165. }
  166. /** 客户查询订单发票;门店不可开票时 invoiceStatus=null(客户端据此隐藏入口)。 */
  167. public PosOrderInvoiceVo getInvoice(Long orderId, Long currentUserId) {
  168. PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,orderId));
  169. if (order == null || !Objects.equals(order.getUserId(), currentUserId)) {
  170. throw new ServiceException("订单不存在");
  171. }
  172. boolean can = canInvoice(order.getMdId());
  173. PosOrderInvoiceVo vo = posOrderInvoiceMapper.selectInvoiceDetail(orderId);
  174. if (vo == null) {
  175. vo = new PosOrderInvoiceVo();
  176. vo.setOrderId(orderId);
  177. vo.setOrderNo(order.getDdId());
  178. vo.setInvoiceStatus(can ? STATUS_NOT_ISSUED : null);
  179. }
  180. return vo;
  181. }
  182. /** 商家端查询订单发票(校验商家为该订单门店归属人)。 */
  183. public PosOrderInvoiceVo getInvoiceForMerchant(Long orderId, Long merchantUserId) {
  184. requireOwnedOrder(orderId, merchantUserId);
  185. return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
  186. }
  187. /** 商家端作废发票(校验归属后复用作废逻辑)。 */
  188. public int invalidForMerchant(Long orderId, String reason, Long merchantUserId) {
  189. requireOwnedOrder(orderId, merchantUserId);
  190. return invalid(orderId, reason);
  191. }
  192. /** 校验订单存在且属于该商家门店,返回订单;否则抛异常。 */
  193. private PosOrder requireOwnedOrder(Long orderId, Long merchantUserId) {
  194. PosOrder order = posOrderMapper.selectPosOrderById(orderId);
  195. if (order == null) {
  196. throw new ServiceException("订单不存在");
  197. }
  198. InfoUser user = infoUserService.getOne(
  199. new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, merchantUserId));
  200. if (user == null) {
  201. throw new ServiceException("用户不存在");
  202. }
  203. String ut = user.getUserType();
  204. boolean own;
  205. if ("1".equals(ut) || "3".equals(ut)) {
  206. // 普通/夜市商家:订单 shId 即商家 userId
  207. own = merchantUserId.equals(order.getShId());
  208. } else {
  209. // 摊位商家:订单门店 mdId 即商家 storeId
  210. own = user.getStoreId() != null && user.getStoreId().equals(order.getMdId());
  211. }
  212. if (!own) {
  213. throw new ServiceException("无权操作该订单发票");
  214. }
  215. return order;
  216. }
  217. // ==================== US4/US5:管理端 ====================
  218. /** 管理端:订单发票列表(配合 PageHelper 分页)。 */
  219. public List<PosOrderInvoiceVo> list(PosOrderInvoiceVo query) {
  220. if (query != null && query.getDateRange() != null && query.getDateRange().length == 2) {
  221. query.setCretimStart(query.getDateRange()[0]);
  222. query.setCretimEnd(query.getDateRange()[1]);
  223. }
  224. return posOrderInvoiceMapper.selectInvoiceList(query);
  225. }
  226. /** 管理端:订单发票详情。 */
  227. public PosOrderInvoiceVo detail(Long orderId) {
  228. return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
  229. }
  230. /** 管理端:重新开票(仅 失败/作废 单;运营身份不校验客户归属)。 */
  231. public int retry(Long orderId) {
  232. PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
  233. new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
  234. if (row == null) {
  235. throw new ServiceException("发票记录不存在");
  236. }
  237. int st = row.getInvoiceStatus() == null ? STATUS_NOT_ISSUED : row.getInvoiceStatus();
  238. if (st != STATUS_FAILED && st != STATUS_INVALID) {
  239. throw new ServiceException("仅失败或作废的发票可重新开票");
  240. }
  241. // 重置为未开,复用客户开票参数重开(运营身份 currentUserId 传 null 跳过归属校验)
  242. PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,orderId.toString()));
  243. if (order == null) {
  244. throw new ServiceException("订单不存在");
  245. }
  246. PosStoreEzpay ez = assertInvoiceable(order.getMdId());
  247. ApplyInvoiceDto dto = new ApplyInvoiceDto();
  248. dto.setOrderId(orderId);
  249. dto.setCategory(row.getInvoiceCategory());
  250. dto.setBuyerName(row.getBuyerName());
  251. dto.setBuyerUbn(row.getBuyerUbn());
  252. dto.setBuyerEmail(row.getBuyerEmail());
  253. dto.setCarrierType(row.getCarrierType());
  254. dto.setCarrierNum(row.getCarrierNum());
  255. // 复用核心开票(不含归属校验):直接走 issue + 落库
  256. return reIssueAndSave(order, row, dto, ez);
  257. }
  258. /** 管理端:作废发票(仅 已开 单,调 ezPay invoice_invalid)。 */
  259. public int invalid(Long orderId, String reason) {
  260. PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
  261. new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
  262. if (row == null || !Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
  263. throw new ServiceException("仅已开发票可作废");
  264. }
  265. PosOrder order = posOrderMapper.selectPosOrderById(orderId);
  266. PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
  267. new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, order.getMdId()));
  268. if (ez == null) {
  269. throw new ServiceException("门店 ezPay 凭证缺失,无法作废");
  270. }
  271. Map<String, Object> postData = new LinkedHashMap<>();
  272. postData.put("RespondType", "JSON");
  273. postData.put("Version", "1.0");
  274. postData.put("InvoiceNumber", row.getInvoiceNumber());
  275. postData.put("InvalidReason", StrUtil.isBlank(reason) ? "商家作废" : reason);
  276. try {
  277. EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
  278. JSONObject resp = ezPay.doPost(ezpayBaseUrl + EzPay.URL_INVALID, cfg, postData); String status = resp == null ? "" : resp.getString("Status");
  279. if ("SUCCESS".equals(status)) {
  280. row.setInvoiceStatus(STATUS_INVALID);
  281. row.setInvalidTime(new Date());
  282. saveOrUpdate(row);
  283. return 1;
  284. }
  285. throw new ServiceException("作废失败:" + (resp == null ? "ezPay 无回应" : resp.getString("Message")));
  286. } catch (ServiceException se) {
  287. throw se;
  288. } catch (Exception e) {
  289. throw new ServiceException("ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
  290. }
  291. }
  292. // ==================== 内部辅助 ====================
  293. /** 复用开票核心(运营重开,无归属校验)。 */
  294. private int reIssueAndSave(PosOrder order, PosOrderInvoice row, ApplyInvoiceDto dto, PosStoreEzpay ez) {
  295. int amount = order.getAmount() == null ? 0 : order.getAmount();
  296. int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight());
  297. int invoiceTotal = amount - freight;
  298. int sales = (int) Math.round(invoiceTotal / 1.05);
  299. int tax = invoiceTotal - sales;
  300. Map<String, Object> inv = buildIssueData(dto, order, invoiceTotal, sales, tax);
  301. EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
  302. int newStatus;
  303. String invoiceNumber = null, randomNum = null, invoiceTransNo = null,
  304. invoiceBarCode = null, invoiceQrcodeL = null, invoiceQrcodeR = null, failReason = null;
  305. try {
  306. JSONObject resp = ezPay.issueInvoice(ezpayBaseUrl, cfg, inv);
  307. String status = resp == null ? "" : resp.getString("Status");
  308. JSONObject result = resp == null ? null : resp.getJSONObject("Result");
  309. if ("SUCCESS".equals(status) && result != null && StrUtil.isNotBlank(result.getString("InvoiceNumber"))) {
  310. invoiceNumber = result.getString("InvoiceNumber");
  311. randomNum = result.getString("RandomNum");
  312. invoiceTransNo = result.getString("InvoiceTransNo");
  313. invoiceBarCode = result.getString("BarCode");
  314. invoiceQrcodeL = result.getString("QRcodeL");
  315. invoiceQrcodeR = result.getString("QRcodeR");
  316. newStatus = STATUS_ISSUED;
  317. } else {
  318. failReason = resp == null ? "ezPay 无回应" : (status + ":" + resp.getString("Message"));
  319. newStatus = STATUS_FAILED;
  320. }
  321. } catch (Exception e) {
  322. failReason = "ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
  323. newStatus = STATUS_FAILED;
  324. }
  325. Date now = new Date();
  326. if (newStatus == STATUS_ISSUED) {
  327. row.setInvoiceNumber(invoiceNumber);
  328. row.setRandomNum(randomNum);
  329. row.setInvoiceTransNo(invoiceTransNo);
  330. row.setInvoiceBarCode(invoiceBarCode);
  331. row.setInvoiceQrcodeL(invoiceQrcodeL);
  332. row.setInvoiceQrcodeR(invoiceQrcodeR);
  333. row.setIssueTime(now);
  334. row.setFailReason(null);
  335. } else {
  336. row.setFailReason(StrUtil.sub(failReason, 0, 480));
  337. }
  338. row.setInvoiceStatus(newStatus);
  339. saveOrUpdate(row);
  340. return newStatus == STATUS_ISSUED ? 1 : 0;
  341. }
  342. /** 校验门店可开票并返回凭证;不可开票抛异常。 */
  343. private PosStoreEzpay assertInvoiceable(Long storeId) {
  344. if (storeId == null) {
  345. throw new ServiceException("订单未关联门店,暂不可开票");
  346. }
  347. PosStore store = posStoreMapper.selectPosStoreById(storeId);
  348. if (store != null && Integer.valueOf(1).equals(store.getInvoiceExempt())) {
  349. throw new ServiceException("该门店免用发票,无需开票");
  350. }
  351. PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
  352. new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
  353. if (ez == null || !Integer.valueOf(2).equals(ez.getEzpayStatus())
  354. || !Integer.valueOf(1).equals(ez.getIsEnabled())) {
  355. throw new ServiceException("该门店暂不支持电子发票");
  356. }
  357. return ez;
  358. }
  359. /** 门店是否可开票(不抛异常):免用发票或 ezPay 未开通启用均返回 false。 */
  360. public boolean canInvoice(Long storeId) {
  361. if (storeId == null) {
  362. return false;
  363. }
  364. PosStore store = posStoreMapper.selectPosStoreById(storeId);
  365. if (store != null && Integer.valueOf(1).equals(store.getInvoiceExempt())) {
  366. return false;
  367. }
  368. PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
  369. new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
  370. return ez != null && Integer.valueOf(2).equals(ez.getEzpayStatus())
  371. && Integer.valueOf(1).equals(ez.getIsEnabled());
  372. }
  373. /**
  374. * 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。对齐 ezPay 开票模型:
  375. * B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填,ezPay会员载具(2)时邮箱必填。
  376. */
  377. private void validateInvoiceInput(ApplyInvoiceDto dto) {
  378. if ("B2B".equals(dto.getCategory())) {
  379. if (StrUtil.isBlank(dto.getBuyerUbn()) || !dto.getBuyerUbn().matches("\\d{8}")) {
  380. throw new ServiceException("统编格式不正确,须为 8 位数字");
  381. }
  382. if (StrUtil.isBlank(dto.getBuyerEmail())) {
  383. throw new ServiceException("B2B 发票须填写买方邮箱");
  384. }
  385. if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) {
  386. throw new ServiceException("B2B 发票不支持载具");
  387. }
  388. } else if ("B2C".equals(dto.getCategory())) {
  389. String ct = dto.getCarrierType();
  390. if (!"0".equals(ct) && !"1".equals(ct) && !"2".equals(ct)) {
  391. throw new ServiceException("B2C 发票须选择载具类型(0手机条码/1自然人凭证/2ezPay会员)");
  392. }
  393. if (StrUtil.isBlank(dto.getCarrierNum())) {
  394. throw new ServiceException("载具号码不能为空");
  395. }
  396. if ("2".equals(ct) && StrUtil.isBlank(dto.getBuyerEmail())) {
  397. throw new ServiceException("ezPay 会员载具须填写买方邮箱");
  398. }
  399. }
  400. }
  401. /** 组装 ezPay invoice_issue 业务参数(Category/PrintFlag/Buyer/Amt/明细)。 */
  402. private Map<String, Object> buildIssueData(ApplyInvoiceDto dto, PosOrder order,
  403. int invoiceTotal, int sales, int tax) {
  404. Map<String, Object> inv = new LinkedHashMap<>();
  405. inv.put("MerchantOrderNo", order.getDdId());
  406. inv.put("Category", dto.getCategory());
  407. inv.put("BuyerName", dto.getBuyerName());
  408. if ("B2B".equals(dto.getCategory())) {
  409. inv.put("BuyerUBN", dto.getBuyerUbn());
  410. inv.put("BuyerEmail", dto.getBuyerEmail()); // B2B 邮箱必填:ezPay 发开立通知给买方,凭此查看
  411. inv.put("PrintFlag", "Y");
  412. } else {
  413. // B2C 载具必填(validateInvoiceInput 已校验 carrierType∈0/1/2、号码非空),PrintFlag=N
  414. inv.put("CarrierType", dto.getCarrierType());
  415. inv.put("CarrierNum", dto.getCarrierNum());
  416. inv.put("PrintFlag", "N");
  417. // ezPay 会员载具(2)需邮箱;手机条码(0)/自然人凭证(1)票进载具,邮箱留空
  418. if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) {
  419. inv.put("BuyerEmail", dto.getBuyerEmail());
  420. }
  421. }
  422. inv.put("TaxType", "1");
  423. inv.put("TaxRate", "5");
  424. inv.put("Amt", String.valueOf(sales));
  425. inv.put("TaxAmt", String.valueOf(tax));
  426. inv.put("TotalAmt", String.valueOf(invoiceTotal));
  427. appendItems(inv, order.getFood(), invoiceTotal);
  428. return inv;
  429. }
  430. /**
  431. * 逐商品明细(research D2/D3):从 food JSON 解析,优惠按比例分摊到单价(方案 B)。
  432. * 每行满足 ItemCount × ItemPrice = ItemAmt(单价决定金额,ezPay 逐商品校验通过)。
  433. */
  434. private void appendItems(Map<String, Object> inv, String foodJson, int invoiceTotal) {
  435. JSONArray arr = StrUtil.isBlank(foodJson) ? null : com.alibaba.fastjson.JSON.parseArray(foodJson);
  436. if (arr == null || arr.isEmpty()) {
  437. inv.put("ItemName", "餐点");
  438. inv.put("ItemCount", "1");
  439. inv.put("ItemUnit", "份");
  440. inv.put("ItemPrice", String.valueOf(invoiceTotal));
  441. inv.put("ItemAmt", String.valueOf(invoiceTotal));
  442. return;
  443. }
  444. int foodTotal = foodOriginalTotal(arr);
  445. double ratio = foodTotal > 0 ? (double) invoiceTotal / foodTotal : 1.0;
  446. StringBuilder name = new StringBuilder(), count = new StringBuilder(),
  447. unit = new StringBuilder(), price = new StringBuilder(), amt = new StringBuilder();
  448. for (int i = 0; i < arr.size(); i++) {
  449. com.alibaba.fastjson.JSONObject it = arr.getJSONObject(i);
  450. int p = it.getIntValue("price") + it.getIntValue("otherPrice");
  451. int c = it.getIntValue("number");
  452. if (c <= 0) {
  453. c = 1;
  454. }
  455. int itemPrice = (int) Math.round(p * ratio);
  456. int itemAmt = itemPrice * c;
  457. if (i > 0) {
  458. name.append("|"); count.append("|"); unit.append("|"); price.append("|"); amt.append("|");
  459. }
  460. String nm = it.getString("name");
  461. name.append(StrUtil.isBlank(nm) ? "餐点" : nm);
  462. count.append(c);
  463. unit.append("個");
  464. price.append(itemPrice);
  465. amt.append(itemAmt);
  466. }
  467. inv.put("ItemName", name.toString());
  468. inv.put("ItemCount", count.toString());
  469. inv.put("ItemUnit", unit.toString());
  470. inv.put("ItemPrice", price.toString());
  471. inv.put("ItemAmt", amt.toString());
  472. }
  473. /** food 商品原价总额(含税)= Σ(price + otherPrice) × number。 */
  474. private int foodOriginalTotal(JSONArray arr) {
  475. int sum = 0;
  476. for (int i = 0; i < arr.size(); i++) {
  477. com.alibaba.fastjson.JSONObject it = arr.getJSONObject(i);
  478. int p = it.getIntValue("price") + it.getIntValue("otherPrice");
  479. int c = it.getIntValue("number");
  480. sum += p * (c <= 0 ? 1 : c);
  481. }
  482. return sum;
  483. }
  484. /** 新增或更新(显式设置时间与人)。 */
  485. private void saveOrUpdate(PosOrderInvoice row) {
  486. Date now = new Date();
  487. String user = currentUser();
  488. if (row.getId() == null) {
  489. row.setCreateTime(now);
  490. row.setUpdateTime(now);
  491. row.setCreateBy(user);
  492. row.setUpdateBy(user);
  493. posOrderInvoiceMapper.insert(row);
  494. } else {
  495. row.setUpdateTime(now);
  496. row.setUpdateBy(user);
  497. posOrderInvoiceMapper.updateById(row);
  498. }
  499. }
  500. private String currentUser() {
  501. try {
  502. return SecurityUtils.getUsername();
  503. } catch (Exception e) {
  504. return "system";
  505. }
  506. }
  507. }