修改订单同步

This commit is contained in:
wuweihua
2026-09-21 10:05:16 +08:00
parent 18ea834a59
commit 974e88715b
37 changed files with 1576 additions and 24 deletions
@@ -607,7 +607,8 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
flow.setEffectiveDate(effectiveDate);
flow.setExpireDate(expireDate);
flow.setReason("账户初始化");
flow.setOperatorId(SecurityUtils.getUserId());
// No login (open API / MQ / job): operatorId null, same as occupyCredit
flow.setOperatorId(currentUserId());
flow.setCreateBy(username);
flow.setRemark(remark);
flow.setDelFlag("0");
@@ -0,0 +1,86 @@
package com.ruoyi.price.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Billing task MQ + Oracle upstream order settings.
*/
@Component
@ConfigurationProperties(prefix = "bms.billing")
public class BillingProperties
{
/** local = Spring event; redis = Redis list queue */
private String mqMode = "redis";
private String mqQueueKey = "bms:billing:task";
private boolean consumerEnabled = true;
private Oracle oracle = new Oracle();
public String getMqMode()
{
return mqMode;
}
public void setMqMode(String mqMode)
{
this.mqMode = mqMode;
}
public String getMqQueueKey()
{
return mqQueueKey;
}
public void setMqQueueKey(String mqQueueKey)
{
this.mqQueueKey = mqQueueKey;
}
public boolean isConsumerEnabled()
{
return consumerEnabled;
}
public void setConsumerEnabled(boolean consumerEnabled)
{
this.consumerEnabled = consumerEnabled;
}
public Oracle getOracle()
{
return oracle;
}
public void setOracle(Oracle oracle)
{
this.oracle = oracle;
}
public static class Oracle
{
/** Upstream main-order table name on SLAVE/Oracle DS */
private String orderTable = "WEISHI_USER.TAB_ORDER";
/** Optional default product when Oracle row has no PRODUCT_CODE */
private String defaultProductCode = "";
public String getOrderTable()
{
return orderTable;
}
public void setOrderTable(String orderTable)
{
this.orderTable = orderTable;
}
public String getDefaultProductCode()
{
return defaultProductCode;
}
public void setDefaultProductCode(String defaultProductCode)
{
this.defaultProductCode = defaultProductCode;
}
}
}
@@ -0,0 +1,29 @@
package com.ruoyi.price.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** Billing task row prc_billing_task */
@Data
@EqualsAndHashCode(callSuper = true)
public class PrcBillingTask extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long taskId;
private String transferNo;
private String trackingNo;
private String orderNo;
/** PENDING / REBILL / REFUND */
private String billType;
/** WAITING / SUCCESS / FAILED */
private String successStatus;
/** Billing time from mobile (not Oracle order time; not overwritten on writeback) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date billTime;
private String billId;
private String errorMessage;
}
@@ -21,6 +21,8 @@ public class PrcOrderBill extends BaseEntity
/** 批量查询:多个订单号精确匹配(仅查询用,非表字段) */
private List<String> orderNos;
private String trackingNo;
/** transfer order no */
private String transferNo;
private Long customerId;
private String customerCode;
private String customerName;
@@ -79,6 +81,10 @@ public class PrcOrderBill extends BaseEntity
private String currency;
/** 应收账单占用状态:UNBILLED/BILLED。 */
private String arBillStatus;
/** ACTIVE / MISSING / DISABLED */
private String customerActiveStatus;
/** NORMAL / ARREARS */
private String creditArrearsStatus;
private String roundMode;
private List<PrcIncomeBillFee> incomeFees;
@@ -0,0 +1,28 @@
package com.ruoyi.price.domain.vo;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/**
* Warehouse inbound request: create billing task then MQ consume.
* Provide at least one of transferNo / trackingNo / orderNo.
* billTime is billing time from mobile (stored in prc_billing_task.bill_time).
*/
@Data
public class BillingTaskSubmitRequest implements Serializable
{
private static final long serialVersionUID = 1L;
private String transferNo;
private String trackingNo;
private String orderNo;
/** Billing time from mobile; persisted to prc_billing_task.bill_time */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date billTime;
/** Optional: PENDING / REBILL / REFUND. Empty = auto PENDING or REBILL. */
private String billType;
/** Optional body apiCode when header X-Api-Code missing */
private String apiCode;
}
@@ -20,6 +20,8 @@ public class TmsPriceBillRequest extends TmsPriceQuoteRequest
private String orderNo;
private String trackingNo;
/** transfer order no */
private String transferNo;
private String customerName;
private Integer pieces;
private BigDecimal actualWeight;
@@ -73,8 +73,14 @@ public class TmsPriceQuoteResult implements Serializable
private String billId;
private String orderNo;
private String trackingNo;
/** transfer order no */
private String transferNo;
private String customerCode;
private String customerName;
/** ACTIVE / MISSING / DISABLED */
private String customerActiveStatus;
/** NORMAL / ARREARS */
private String creditArrearsStatus;
private Integer pieces;
private BigDecimal actualWeight;
private BigDecimal volume;
@@ -0,0 +1,23 @@
package com.ruoyi.price.enums;
/**
* Credit arrears status snapshot on prc_order_bill when billing.
* Billing continues even when ARREARS; occupy still runs with checkAvailable=false.
*/
public enum BillCreditArrearsStatus
{
NORMAL("NORMAL"),
ARREARS("ARREARS");
private final String code;
BillCreditArrearsStatus(String code)
{
this.code = code;
}
public String getCode()
{
return code;
}
}
@@ -0,0 +1,24 @@
package com.ruoyi.price.enums;
/**
* Customer effective status snapshot on prc_order_bill when billing.
* Billing continues even when MISSING / DISABLED.
*/
public enum BillCustomerActiveStatus
{
ACTIVE("ACTIVE"),
MISSING("MISSING"),
DISABLED("DISABLED");
private final String code;
BillCustomerActiveStatus(String code)
{
this.code = code;
}
public String getCode()
{
return code;
}
}
@@ -0,0 +1,21 @@
package com.ruoyi.price.enums;
/** Billing task success flag on prc_billing_task.success_status */
public enum BillTaskSuccessStatus
{
WAITING("WAITING"),
SUCCESS("SUCCESS"),
FAILED("FAILED");
private final String code;
BillTaskSuccessStatus(String code)
{
this.code = code;
}
public String getCode()
{
return code;
}
}
@@ -0,0 +1,40 @@
package com.ruoyi.price.enums;
/** Billing task type on prc_billing_task.bill_type */
public enum BillTaskType
{
/** first-time billing */
PENDING("PENDING"),
/** same transfer/order already billed */
REBILL("REBILL"),
/** refund */
REFUND("REFUND");
private final String code;
BillTaskType(String code)
{
this.code = code;
}
public String getCode()
{
return code;
}
public static BillTaskType fromCode(String code)
{
if (code == null || code.isEmpty())
{
return null;
}
for (BillTaskType t : values())
{
if (t.code.equalsIgnoreCase(code.trim()))
{
return t;
}
}
return null;
}
}
@@ -0,0 +1,20 @@
package com.ruoyi.price.mapper;
import java.util.Map;
import org.apache.ibatis.annotations.Param;
/** Read upstream main order from Oracle (SLAVE datasource). */
public interface OracleUpstreamOrderMapper
{
Map<String, Object> selectByTransferNo(@Param("orderTable") String orderTable, @Param("transferNo") String transferNo);
Map<String, Object> selectByOrderId(@Param("orderTable") String orderTable, @Param("orderNo") String orderNo);
Map<String, Object> selectByOrgOrderId(@Param("orderTable") String orderTable, @Param("orderNo") String orderNo);
Map<String, Object> selectByTrackId(@Param("orderTable") String orderTable, @Param("trackingNo") String trackingNo);
Map<String, Object> selectByBarcode(@Param("orderTable") String orderTable, @Param("trackingNo") String trackingNo);
Map<String, Object> selectByCustomerTrackId(@Param("orderTable") String orderTable, @Param("trackingNo") String trackingNo);
}
@@ -0,0 +1,18 @@
package com.ruoyi.price.mapper;
import java.util.List;
import com.ruoyi.price.domain.PrcBillingTask;
public interface PrcBillingTaskMapper
{
PrcBillingTask selectPrcBillingTaskById(Long taskId);
List<PrcBillingTask> selectPrcBillingTaskList(PrcBillingTask query);
int insertPrcBillingTask(PrcBillingTask task);
int updatePrcBillingTask(PrcBillingTask task);
/** CAS: WAITING -> processing claim not needed; writeback SUCCESS/FAILED */
int updateBillingResult(PrcBillingTask task);
}
@@ -11,6 +11,8 @@ public interface PrcOrderBillMapper
{
public PrcOrderBill selectPrcOrderBillByOrderNo(String orderNo);
public PrcOrderBill selectPrcOrderBillByTransferNo(String transferNo);
public PrcOrderBill selectPrcOrderBillById(String billId);
public List<PrcOrderBill> selectPrcOrderBillList(PrcOrderBill bill);
@@ -0,0 +1,30 @@
package com.ruoyi.price.mq;
import java.io.Serializable;
/** MQ payload: only taskId; consumer loads full row from MySQL. */
public class BillingTaskMessage implements Serializable
{
private static final long serialVersionUID = 1L;
private Long taskId;
public BillingTaskMessage()
{
}
public BillingTaskMessage(Long taskId)
{
this.taskId = taskId;
}
public Long getTaskId()
{
return taskId;
}
public void setTaskId(Long taskId)
{
this.taskId = taskId;
}
}
@@ -0,0 +1,94 @@
package com.ruoyi.price.mq;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.price.config.BillingProperties;
import com.ruoyi.price.service.IPrcBillingTaskService;
/**
* Consume billing task MQ messages and call billing interface.
*/
@Component
@ConditionalOnProperty(prefix = "bms.billing", name = "consumer-enabled", havingValue = "true", matchIfMissing = true)
public class BillingTaskMqConsumer
{
private static final Logger log = LoggerFactory.getLogger(BillingTaskMqConsumer.class);
@Autowired
private IPrcBillingTaskService billingTaskService;
@Autowired
private BillingProperties billingProperties;
@Autowired(required = false)
private StringRedisTemplate stringRedisTemplate;
/** Local MQ mode: async event listener. */
@Async
@EventListener
public void onLocalEvent(BillingTaskMqEvent event)
{
if (!isMode("local"))
{
return;
}
if (event == null || event.getMessage() == null)
{
return;
}
billingTaskService.processTask(event.getMessage().getTaskId());
}
/** Redis MQ mode: poll list every second. */
@Scheduled(fixedDelayString = "${bms.billing.consumer-delay-ms:1000}")
public void pollRedisQueue()
{
if (!isMode("redis"))
{
return;
}
if (stringRedisTemplate == null)
{
return;
}
String key = billingProperties.getMqQueueKey();
String payload = stringRedisTemplate.opsForList().rightPop(key);
if (payload == null || payload.isEmpty())
{
return;
}
try
{
BillingTaskMessage msg = JSON.parseObject(payload, BillingTaskMessage.class);
if (msg == null || msg.getTaskId() == null)
{
log.warn("invalid billing mq payload={}", payload);
return;
}
billingTaskService.processTask(msg.getTaskId());
}
catch (RuntimeException e)
{
log.error("billing mq consume failed payload={}", payload, e);
}
}
private boolean isMode(String expected)
{
String mode = billingProperties.getMqMode();
if (StringUtils.isEmpty(mode))
{
mode = "redis";
}
return expected.equalsIgnoreCase(mode.trim());
}
}
@@ -0,0 +1,21 @@
package com.ruoyi.price.mq;
import org.springframework.context.ApplicationEvent;
public class BillingTaskMqEvent extends ApplicationEvent
{
private static final long serialVersionUID = 1L;
private final BillingTaskMessage message;
public BillingTaskMqEvent(Object source, BillingTaskMessage message)
{
super(source);
this.message = message;
}
public BillingTaskMessage getMessage()
{
return message;
}
}
@@ -0,0 +1,7 @@
package com.ruoyi.price.mq;
/** Publish billing task id to MQ after warehouse inbound insert. */
public interface IBillingTaskMqPublisher
{
void publish(BillingTaskMessage message);
}
@@ -0,0 +1,27 @@
package com.ruoyi.price.mq;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
/**
* Local MQ: Spring application event (dev / no Redis broker).
* Enable with bms.billing.mq-mode=local
*/
@Component
@ConditionalOnProperty(prefix = "bms.billing", name = "mq-mode", havingValue = "local")
public class LocalBillingTaskMqPublisher implements IBillingTaskMqPublisher
{
private final ApplicationEventPublisher eventPublisher;
public LocalBillingTaskMqPublisher(ApplicationEventPublisher eventPublisher)
{
this.eventPublisher = eventPublisher;
}
@Override
public void publish(BillingTaskMessage message)
{
eventPublisher.publishEvent(new BillingTaskMqEvent(this, message));
}
}
@@ -0,0 +1,38 @@
package com.ruoyi.price.mq;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.price.config.BillingProperties;
/**
* Redis list as MQ queue (default). LPUSH on publish; consumer BRPOP/RPOP.
* Enable with bms.billing.mq-mode=redis (default).
*/
@Component
@ConditionalOnProperty(prefix = "bms.billing", name = "mq-mode", havingValue = "redis", matchIfMissing = true)
public class RedisBillingTaskMqPublisher implements IBillingTaskMqPublisher
{
private static final Logger log = LoggerFactory.getLogger(RedisBillingTaskMqPublisher.class);
private final StringRedisTemplate stringRedisTemplate;
private final BillingProperties billingProperties;
public RedisBillingTaskMqPublisher(StringRedisTemplate stringRedisTemplate, BillingProperties billingProperties)
{
this.stringRedisTemplate = stringRedisTemplate;
this.billingProperties = billingProperties;
}
@Override
public void publish(BillingTaskMessage message)
{
String key = billingProperties.getMqQueueKey();
String payload = JSON.toJSONString(message);
stringRedisTemplate.opsForList().leftPush(key, payload);
log.info("billing task mq published taskId={} queue={}", message.getTaskId(), key);
}
}
@@ -0,0 +1,231 @@
package com.ruoyi.price.oracle;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.price.domain.vo.TmsPriceBillRequest;
/**
* Map Oracle main-order row (FIELD_MAP style columns) to TmsPriceBillRequest.
*/
@Component
public class OracleOrderBillMapper
{
public TmsPriceBillRequest toBillRequest(Map<String, Object> row, String defaultProductCode)
{
if (row == null || row.isEmpty())
{
return null;
}
TmsPriceBillRequest req = new TmsPriceBillRequest();
req.setOrderNo(firstStr(row, "ORG_ORDER_ID", "ORDER_ID", "TRANSFER_ORDER_ID"));
req.setTrackingNo(firstStr(row, "TRANSFER_ORDER_ID", "TRACK_ID", "BARCODE_NUMBER", "CUSTOMER_TRACK_ID"));
req.setTransferNo(firstStr(row, "TRANSFER_ORDER_ID"));
req.setCustomerCode(firstStr(row, "CUSTOMER_CODE"));
req.setCustomerName(firstStr(row, "CUSTOMER_NAME"));
String product = firstStr(row, "PRODUCT_CODE", "GOODS_CODE", "CHANNEL_CODE");
if (StringUtils.isEmpty(product))
{
product = defaultProductCode;
}
req.setProductCode(product);
req.setCountryCode(firstStr(row, "COUNTRY"));
String countryName = firstStr(row, "COUNTRY_NAME", "COUNTRY_EINGTH_NAME", "COUNTRY");
req.setCountryName(countryName);
req.setDestPostcode(firstStr(row, "CONSIGNEE_POSTCODE"));
// Prefer positive weight: Oracle WEIGHT is often 0 while ACTUAL_WEIGHT/PUSH_WEIGHT holds real value.
BigDecimal weight = firstPositiveDecimal(row, "ACTUAL_WEIGHT", "PUSH_WEIGHT", "WEIGHT", "VOLUME_WEIGHT");
req.setWeight(weight);
req.setActualWeight(firstPositiveDecimal(row, "ACTUAL_WEIGHT", "PUSH_WEIGHT", "WEIGHT"));
req.setPieces(firstInt(row, "ORDER_PIECE", "PACKAGE_NUM", "PRODUCT_SUM"));
req.setVolume(toVolumeM3(firstDecimal(row, "VOLUME")));
req.setVolumeWeight(firstDecimal(row, "VOLUME_WEIGHT"));
req.setPkgLength(firstDecimal(row, "LENGTH"));
req.setPkgWidth(firstDecimal(row, "WIDTH"));
req.setPkgHeight(firstDecimal(row, "HEIGHT"));
req.setShipperName(firstStr(row, "SHIPPER_NAME", "SHIPPER_COMPANYNAME"));
req.setConsigneeName(firstStr(row, "CONSIGNEE_NAME"));
req.setWarehouseCode(firstStr(row, "REGISTER_SITE_CODE"));
req.setOrderRemark(firstStr(row, "TRACK_REMARK", "EXCEPTION_REMARK", "ORDER_CUSTOMNOTE"));
// CREATE_DATE is order place time only; bizDate (billing date) comes from mobile inbound task.
Date createDate = firstDate(row, "CREATE_DATE");
if (createDate != null)
{
req.setOrderTime(createDate);
}
return req;
}
private static String firstStr(Map<String, Object> row, String... keys)
{
Object v = firstValue(row, keys);
if (v == null)
{
return null;
}
String s = String.valueOf(v).trim();
return s.isEmpty() ? null : s;
}
private static BigDecimal firstDecimal(Map<String, Object> row, String... keys)
{
Object v = firstValue(row, keys);
if (v == null || "".equals(String.valueOf(v).trim()))
{
return null;
}
if (v instanceof BigDecimal)
{
return (BigDecimal) v;
}
if (v instanceof Number)
{
return BigDecimal.valueOf(((Number) v).doubleValue());
}
try
{
return new BigDecimal(String.valueOf(v).trim());
}
catch (Exception e)
{
return null;
}
}
/** First decimal that is strictly &gt; 0 among keys (skip Oracle zero placeholders). */
private static BigDecimal firstPositiveDecimal(Map<String, Object> row, String... keys)
{
if (row == null || keys == null)
{
return null;
}
for (String key : keys)
{
Object raw = getIgnoreCase(row, key);
if (raw == null || "".equals(String.valueOf(raw).trim()))
{
continue;
}
BigDecimal d;
if (raw instanceof BigDecimal)
{
d = (BigDecimal) raw;
}
else if (raw instanceof Number)
{
d = BigDecimal.valueOf(((Number) raw).doubleValue());
}
else
{
try
{
d = new BigDecimal(String.valueOf(raw).trim());
}
catch (Exception e)
{
continue;
}
}
if (d != null && d.signum() > 0)
{
return d;
}
}
return null;
}
private static Integer firstInt(Map<String, Object> row, String... keys)
{
BigDecimal d = firstDecimal(row, keys);
return d == null ? null : Integer.valueOf(d.intValue());
}
private static Date firstDate(Map<String, Object> row, String... keys)
{
Object v = firstValue(row, keys);
if (v == null)
{
return null;
}
if (v instanceof Date)
{
return (Date) v;
}
String s = String.valueOf(v).trim();
String[] formats = {
"dd/MM/yyyy HH:mm:ss", "dd/MM/yyyy HH:mm", "yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd"
};
for (String fmt : formats)
{
try
{
return new SimpleDateFormat(fmt, Locale.ENGLISH).parse(s);
}
catch (Exception ignored)
{
}
}
return null;
}
private static BigDecimal toVolumeM3(BigDecimal v)
{
if (v == null)
{
return null;
}
if (v.doubleValue() > 10d)
{
return v.divide(BigDecimal.valueOf(1_000_000L), 6, java.math.RoundingMode.HALF_UP);
}
return v;
}
private static Object firstValue(Map<String, Object> row, String... keys)
{
if (row == null || keys == null)
{
return null;
}
for (String key : keys)
{
Object v = getIgnoreCase(row, key);
if (v != null && !"".equals(String.valueOf(v).trim()))
{
return v;
}
}
return null;
}
private static Object getIgnoreCase(Map<String, Object> row, String key)
{
if (row.containsKey(key))
{
return row.get(key);
}
String upper = key.toUpperCase(Locale.ROOT);
if (row.containsKey(upper))
{
return row.get(upper);
}
String lower = key.toLowerCase(Locale.ROOT);
if (row.containsKey(lower))
{
return row.get(lower);
}
for (Map.Entry<String, Object> e : row.entrySet())
{
if (e.getKey() != null && e.getKey().equalsIgnoreCase(key))
{
return e.getValue();
}
}
return null;
}
}
@@ -0,0 +1,125 @@
package com.ruoyi.price.oracle;
import java.util.Map;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.github.pagehelper.PageHelper;
import com.ruoyi.common.annotation.DataSource;
import com.ruoyi.common.enums.DataSourceType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.price.config.BillingProperties;
import com.ruoyi.price.mapper.OracleUpstreamOrderMapper;
/**
* Query Oracle main order via SLAVE datasource.
* Requires spring.datasource.druid.slave.enabled=true and Oracle URL.
*/
@Service
public class OracleUpstreamOrderService
{
private static final Logger log = LoggerFactory.getLogger(OracleUpstreamOrderService.class);
@Autowired
private OracleUpstreamOrderMapper oracleUpstreamOrderMapper;
@Autowired
private BillingProperties billingProperties;
@DataSource(DataSourceType.SLAVE)
public Map<String, Object> findMainOrder(String transferNo, String orderNo, String trackingNo)
{
if (StringUtils.isEmpty(transferNo) && StringUtils.isEmpty(orderNo) && StringUtils.isEmpty(trackingNo))
{
throw new ServiceException("transferNo / orderNo / trackingNo required for Oracle order query");
}
String table = billingProperties.getOracle().getOrderTable();
if (StringUtils.isEmpty(table))
{
throw new ServiceException("bms.billing.oracle.order-table is empty");
}
if (!table.matches("[A-Za-z0-9_\\.]+"))
{
throw new ServiceException("invalid oracle order table name");
}
String tf = emptyToNull(transferNo);
String ono = emptyToNull(orderNo);
String track = emptyToNull(trackingNo);
try
{
// Avoid PageHelper wrapping this Oracle query (can slow / break SQL).
PageHelper.clearPage();
// 1) Prefer exact match by transfer no (TRANSFER_ORDER_ID)
if (tf != null)
{
Map<String, Object> byTransfer = query("TRANSFER_ORDER_ID",
() -> oracleUpstreamOrderMapper.selectByTransferNo(table, tf));
if (!isEmpty(byTransfer))
{
return byTransfer;
}
}
// 2) Fallback: order no
Map<String, Object> row = null;
if (ono != null)
{
row = query("ORDER_ID", () -> oracleUpstreamOrderMapper.selectByOrderId(table, ono));
}
if (isEmpty(row) && ono != null)
{
row = query("ORG_ORDER_ID", () -> oracleUpstreamOrderMapper.selectByOrgOrderId(table, ono));
}
// 3) Fallback: tracking no
if (isEmpty(row) && track != null)
{
row = query("TRACK_ID", () -> oracleUpstreamOrderMapper.selectByTrackId(table, track));
}
if (isEmpty(row) && track != null)
{
row = query("BARCODE_NUMBER", () -> oracleUpstreamOrderMapper.selectByBarcode(table, track));
}
if (isEmpty(row) && track != null)
{
row = query("CUSTOMER_TRACK_ID", () -> oracleUpstreamOrderMapper.selectByCustomerTrackId(table, track));
}
return row;
}
catch (ServiceException e)
{
throw e;
}
catch (RuntimeException e)
{
throw new ServiceException("Oracle order query failed: " + e.getMessage());
}
finally
{
PageHelper.clearPage();
}
}
private Map<String, Object> query(String by, Supplier<Map<String, Object>> supplier)
{
long t0 = System.currentTimeMillis();
Map<String, Object> row = supplier.get();
log.info("oracle order lookup by={} hit={} costMs={}", by, !isEmpty(row), System.currentTimeMillis() - t0);
return row;
}
private static boolean isEmpty(Map<String, Object> row)
{
return row == null || row.isEmpty();
}
private static String emptyToNull(String s)
{
return StringUtils.isEmpty(s) ? null : s.trim();
}
}
@@ -0,0 +1,15 @@
package com.ruoyi.price.service;
import com.ruoyi.price.domain.PrcBillingTask;
import com.ruoyi.price.domain.vo.BillingTaskSubmitRequest;
public interface IPrcBillingTaskService
{
/** Warehouse inbound: create task + publish MQ. */
PrcBillingTask submitInbound(BillingTaskSubmitRequest request);
/** MQ consumer entry: Oracle query -> bill -> writeback success status. */
void processTask(Long taskId);
PrcBillingTask getById(Long taskId);
}
@@ -0,0 +1,238 @@
package com.ruoyi.price.service.impl;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.price.config.BillingProperties;
import com.ruoyi.price.domain.PrcBillingTask;
import com.ruoyi.price.domain.PrcOrderBill;
import com.ruoyi.price.domain.vo.BillingTaskSubmitRequest;
import com.ruoyi.price.domain.vo.TmsPriceBillRequest;
import com.ruoyi.price.domain.vo.TmsPriceQuoteResult;
import com.ruoyi.price.enums.BillTaskSuccessStatus;
import com.ruoyi.price.enums.BillTaskType;
import com.ruoyi.price.mapper.PrcBillingTaskMapper;
import com.ruoyi.price.mapper.PrcOrderBillMapper;
import com.ruoyi.price.mq.BillingTaskMessage;
import com.ruoyi.price.mq.IBillingTaskMqPublisher;
import com.ruoyi.price.oracle.OracleOrderBillMapper;
import com.ruoyi.price.oracle.OracleUpstreamOrderService;
import com.ruoyi.price.service.IPrcBillingTaskService;
import com.ruoyi.price.service.IPrcOrderBillService;
@Service
public class PrcBillingTaskServiceImpl implements IPrcBillingTaskService
{
private static final Logger log = LoggerFactory.getLogger(PrcBillingTaskServiceImpl.class);
@Autowired
private PrcBillingTaskMapper billingTaskMapper;
@Autowired
private PrcOrderBillMapper orderBillMapper;
@Autowired
private IBillingTaskMqPublisher billingTaskMqPublisher;
@Autowired
private OracleUpstreamOrderService oracleUpstreamOrderService;
@Autowired
private OracleOrderBillMapper oracleOrderBillMapper;
@Autowired
private IPrcOrderBillService prcOrderBillService;
@Autowired
private BillingProperties billingProperties;
@Override
@Transactional(rollbackFor = Exception.class)
public PrcBillingTask submitInbound(BillingTaskSubmitRequest request)
{
if (request == null)
{
throw new ServiceException("billing task request required");
}
String transferNo = trim(request.getTransferNo());
String trackingNo = trim(request.getTrackingNo());
String orderNo = trim(request.getOrderNo());
Date billTime = request.getBillTime();
if (StringUtils.isEmpty(transferNo) && StringUtils.isEmpty(trackingNo) && StringUtils.isEmpty(orderNo))
{
throw new ServiceException("transferNo or trackingNo or orderNo required");
}
if (billTime == null)
{
throw new ServiceException("billTime required (billing time from mobile)");
}
BillTaskType type = BillTaskType.fromCode(request.getBillType());
if (type == null)
{
type = resolveAutoType(transferNo, trackingNo, orderNo);
}
PrcBillingTask task = new PrcBillingTask();
task.setTransferNo(transferNo);
task.setTrackingNo(trackingNo);
task.setOrderNo(orderNo);
task.setBillTime(billTime);
task.setBillType(type.getCode());
task.setSuccessStatus(BillTaskSuccessStatus.WAITING.getCode());
task.setCreateBy("tms");
billingTaskMapper.insertPrcBillingTask(task);
billingTaskMqPublisher.publish(new BillingTaskMessage(task.getTaskId()));
log.info("billing task submitted taskId={} type={} billTime={} transferNo={} trackingNo={} orderNo={}",
task.getTaskId(), task.getBillType(), billTime, transferNo, trackingNo, orderNo);
return task;
}
@Override
public void processTask(Long taskId)
{
if (taskId == null)
{
return;
}
PrcBillingTask task = billingTaskMapper.selectPrcBillingTaskById(taskId);
if (task == null)
{
log.warn("billing task not found taskId={}", taskId);
return;
}
if (BillTaskSuccessStatus.SUCCESS.getCode().equals(task.getSuccessStatus()))
{
log.info("billing task already success taskId={}", taskId);
return;
}
try
{
Map<String, Object> row = oracleUpstreamOrderService.findMainOrder(
task.getTransferNo(), task.getOrderNo(), task.getTrackingNo());
if (row == null || row.isEmpty())
{
throw new ServiceException("Oracle main order not found");
}
TmsPriceBillRequest billReq = oracleOrderBillMapper.toBillRequest(row,
billingProperties.getOracle().getDefaultProductCode());
if (billReq == null || StringUtils.isEmpty(billReq.getOrderNo()))
{
throw new ServiceException("Oracle order mapped orderNo empty");
}
if (StringUtils.isEmpty(billReq.getCustomerCode()))
{
throw new ServiceException("Oracle order mapped customerCode empty");
}
if (StringUtils.isEmpty(billReq.getProductCode()))
{
throw new ServiceException("Oracle order mapped productCode empty");
}
if (billReq.getWeight() == null || billReq.getWeight().signum() <= 0)
{
throw new ServiceException("Oracle order mapped weight invalid");
}
// Prefer task keys when Oracle mapping missed them
if (StringUtils.isEmpty(billReq.getTransferNo()) && StringUtils.isNotEmpty(task.getTransferNo()))
{
billReq.setTransferNo(task.getTransferNo());
}
if (StringUtils.isEmpty(billReq.getTrackingNo()) && StringUtils.isNotEmpty(task.getTrackingNo()))
{
billReq.setTrackingNo(task.getTrackingNo());
}
// Billing date from task.bill_time (mobile), not Oracle CREATE_DATE.
if (task.getBillTime() == null)
{
throw new ServiceException("task billTime empty (billing time from mobile required)");
}
billReq.setBizDate(new SimpleDateFormat("yyyy-MM-dd").format(task.getBillTime()));
BillTaskType type = BillTaskType.fromCode(task.getBillType());
if (BillTaskType.REFUND == type)
{
// V1: refund still syncs latest Oracle snapshot into bill (business refine later).
log.info("billing task REFUND path uses bill upsert taskId={}", taskId);
}
TmsPriceQuoteResult result = prcOrderBillService.bill(billReq);
writeback(task, BillTaskSuccessStatus.SUCCESS, result == null ? null : result.getBillId(),
billReq, null);
}
catch (RuntimeException e)
{
log.error("billing task failed taskId={}", taskId, e);
writeback(task, BillTaskSuccessStatus.FAILED, null, null, cut(e.getMessage(), 900));
}
}
@Override
public PrcBillingTask getById(Long taskId)
{
return billingTaskMapper.selectPrcBillingTaskById(taskId);
}
private BillTaskType resolveAutoType(String transferNo, String trackingNo, String orderNo)
{
PrcOrderBill existed = null;
if (StringUtils.isNotEmpty(orderNo))
{
existed = orderBillMapper.selectPrcOrderBillByOrderNo(orderNo);
}
if (existed == null && StringUtils.isNotEmpty(transferNo))
{
existed = orderBillMapper.selectPrcOrderBillByTransferNo(transferNo);
}
if (existed == null && StringUtils.isNotEmpty(trackingNo))
{
PrcOrderBill q = new PrcOrderBill();
q.setTrackingNo(trackingNo);
java.util.List<PrcOrderBill> list = orderBillMapper.selectPrcOrderBillList(q);
if (list != null && !list.isEmpty())
{
existed = list.get(0);
}
}
return existed == null ? BillTaskType.PENDING : BillTaskType.REBILL;
}
private void writeback(PrcBillingTask task, BillTaskSuccessStatus status, String billId,
TmsPriceBillRequest billReq, String errorMessage)
{
PrcBillingTask upd = new PrcBillingTask();
upd.setTaskId(task.getTaskId());
upd.setSuccessStatus(status.getCode());
// Keep bill_time as mobile billing time; do not overwrite on writeback.
upd.setBillId(billId);
upd.setErrorMessage(errorMessage);
if (billReq != null)
{
upd.setOrderNo(billReq.getOrderNo());
upd.setTrackingNo(billReq.getTrackingNo());
upd.setTransferNo(billReq.getTransferNo());
}
billingTaskMapper.updateBillingResult(upd);
}
private static String trim(String s)
{
return s == null ? null : s.trim();
}
private static String cut(String s, int max)
{
if (s == null)
{
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
}
@@ -27,6 +27,8 @@ import com.ruoyi.price.domain.PrcPayBillFee;
import com.ruoyi.price.domain.vo.TmsPriceBillRequest;
import com.ruoyi.price.domain.vo.TmsPriceQuoteFeeLine;
import com.ruoyi.price.domain.vo.TmsPriceQuoteResult;
import com.ruoyi.price.enums.BillCreditArrearsStatus;
import com.ruoyi.price.enums.BillCustomerActiveStatus;
import com.ruoyi.price.mapper.PrcIncomeBillFeeMapper;
import com.ruoyi.price.mapper.PrcOrderBillExtMapper;
import com.ruoyi.price.mapper.PrcOrderBillMapper;
@@ -37,6 +39,7 @@ import com.ruoyi.price.search.IBillSearchStore;
import com.ruoyi.customer.customermoney.api.CreditUsageCommand;
import com.ruoyi.customer.customermoney.api.CustomerFundBizType;
import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
import com.ruoyi.customer.customermoney.vo.CustomerAccountStatusVO;
import com.ruoyi.price.service.IPrcOrderBillService;
import com.ruoyi.price.service.ITmsPriceQuoteService;
@@ -192,6 +195,13 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
bill.setCustomerCode(customerCode);
bill.setCustomerName(request.getCustomerName());
bill.setTrackingNo(request.getTrackingNo());
bill.setTransferNo(request.getTransferNo());
String customerActiveStatus = result == null ? null : result.getCustomerActiveStatus();
if (StringUtils.isEmpty(customerActiveStatus))
{
customerActiveStatus = BillCustomerActiveStatus.ACTIVE.getCode();
}
bill.setCustomerActiveStatus(customerActiveStatus);
bill.setPieces(request.getPieces());
bill.setActualWeight(request.getActualWeight());
bill.setVolume(request.getVolume());
@@ -236,10 +246,16 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
bill.setPayTotal(result.getPayTotal() == null ? BigDecimal.ZERO : result.getPayTotal());
bill.setCurrency(result.getCurrency());
bill.setRoundMode(result.getRoundMode());
// Credit soft-fail before persist so insert/update stores arrears flag in one write.
String creditArrearsStatus = syncOrderCredit(bill, previousIncome, customerActiveStatus);
bill.setCreditArrearsStatus(creditArrearsStatus);
result.setBillId(bill.getBillId());
result.setOrderNo(orderNo);
result.setTransferNo(bill.getTransferNo());
result.setCustomerId(bill.getCustomerId());
result.setCustomerCode(bill.getCustomerCode());
result.setCustomerActiveStatus(customerActiveStatus);
result.setCreditArrearsStatus(creditArrearsStatus);
result.setIncomeTotal(bill.getIncomeTotal());
result.setPayTotal(bill.getPayTotal());
result.setPayableTotal(bill.getIncomeTotal());
@@ -263,33 +279,40 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
String syncJson = toRequestJson(request);
persistBillExt(bill, syncJson);
result.setRequestJson(syncJson);
syncOrderCredit(bill, previousIncome);
billSearchStore.save(EsLogType.BILL, orderNo, syncJson, toResponseJson(result));
}
/**
* Occupy/release credit by income total delta (bizType=ORDER).
* Always write CREDIT log to ES (SUCCESS / FAIL), then rethrow on failure.
* Missing/disabled customer: skip occupy, return NORMAL.
* Insufficient credit: still occupy with checkAvailable=false, return ARREARS.
* Always write CREDIT log to ES (SUCCESS / FAIL / SKIP).
*/
private void syncOrderCredit(PrcOrderBill bill, BigDecimal previousIncome)
private String syncOrderCredit(PrcOrderBill bill, BigDecimal previousIncome, String customerActiveStatus)
{
String normal = BillCreditArrearsStatus.NORMAL.getCode();
String arrears = BillCreditArrearsStatus.ARREARS.getCode();
if (bill == null)
{
return;
return normal;
}
if (!BillCustomerActiveStatus.ACTIVE.getCode().equals(customerActiveStatus))
{
saveCreditEsLog(bill, null, "SKIP", true, "customerActiveStatus=" + customerActiveStatus);
return normal;
}
Long customerId = bill.getCustomerId();
if (customerId == null || customerId.longValue() <= 0L)
{
ServiceException ex = new ServiceException(MessageUtils.message("price.bill.customerRequired"));
saveCreditEsLog(bill, null, "SKIP", false, EsBizLogSupport.errorText(ex));
throw ex;
saveCreditEsLog(bill, null, "SKIP", true, "customerId missing");
return normal;
}
BigDecimal oldAmt = nz(previousIncome);
BigDecimal newAmt = nz(bill.getIncomeTotal());
BigDecimal delta = newAmt.subtract(oldAmt);
if (delta.compareTo(BigDecimal.ZERO) == 0)
{
return;
return normal;
}
CreditUsageCommand cmd = new CreditUsageCommand();
cmd.setCustomerId(customerId);
@@ -297,8 +320,14 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
cmd.setCurrency(StringUtils.isEmpty(bill.getCurrency()) ? "CNY" : bill.getCurrency());
cmd.setBizType(CustomerFundBizType.ORDER);
cmd.setBizNo(bill.getOrderNo());
cmd.setCheckAvailable(Boolean.TRUE);
// Soft: allow used_credit to exceed available; mark ARREARS on the bill instead of failing.
cmd.setCheckAvailable(Boolean.FALSE);
String action = delta.compareTo(BigDecimal.ZERO) > 0 ? "OCCUPY" : "RELEASE";
boolean markArrears = false;
if (delta.compareTo(BigDecimal.ZERO) > 0)
{
markArrears = isCreditInsufficient(customerId, bill.getCustomerCode(), delta);
}
try
{
if (delta.compareTo(BigDecimal.ZERO) > 0)
@@ -311,15 +340,44 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
cmd.setAmount(delta.abs());
customerAccountApi.releaseCredit(cmd);
}
saveCreditEsLog(bill, cmd, action, true, null);
saveCreditEsLog(bill, cmd, action, true, markArrears ? "ARREARS" : null);
return markArrears ? arrears : normal;
}
catch (RuntimeException e)
{
// Unexpected occupy/release failure (lock conflict etc.): fail the bill txn.
saveCreditEsLog(bill, cmd, action, false, EsBizLogSupport.errorText(e));
throw e;
}
}
/** True when available credit cannot cover this occupy delta (or already overdue). */
private boolean isCreditInsufficient(Long customerId, String customerCode, BigDecimal delta)
{
try
{
CustomerAccountStatusVO status = customerAccountApi.getAccountStatus(customerId, customerCode);
if (status == null)
{
return true;
}
if (status.isOverdue())
{
return true;
}
BigDecimal available = status.getAvailableCredit();
if (available == null)
{
return true;
}
return available.compareTo(nz(delta)) < 0;
}
catch (RuntimeException e)
{
return true;
}
}
/** CREDIT ES log; does not set ATTR_FAIL_LOGGED so BILL fail can still be written. */
private void saveCreditEsLog(PrcOrderBill bill, CreditUsageCommand cmd, String action, boolean success,
String errorMessage)
@@ -352,6 +410,10 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
{
resp.put("msg", errorMessage);
}
if (StringUtils.isNotEmpty(errorMessage) && success)
{
resp.put("note", errorMessage);
}
String status = success ? EsBizLogSupport.STATUS_SUCCESS : EsBizLogSupport.STATUS_FAIL;
String err = success ? null : EsBizLogSupport.cut(errorMessage);
billSearchStore.save(EsLogType.CREDIT, bill.getOrderNo(), req.toJSONString(), resp.toJSONString(), status, err);
@@ -529,7 +591,10 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
private void fillResultOrder(TmsPriceQuoteResult r, PrcOrderBill bill)
{
r.setTrackingNo(bill.getTrackingNo());
r.setTransferNo(bill.getTransferNo());
r.setCustomerName(bill.getCustomerName());
r.setCustomerActiveStatus(bill.getCustomerActiveStatus());
r.setCreditArrearsStatus(bill.getCreditArrearsStatus());
r.setPieces(bill.getPieces());
r.setActualWeight(bill.getActualWeight());
r.setVolume(bill.getVolume());
@@ -39,6 +39,7 @@ import com.ruoyi.price.domain.vo.TmsPriceBillRequest;
import com.ruoyi.price.domain.vo.TmsPriceQuoteFeeLine;
import com.ruoyi.price.domain.vo.TmsPriceQuoteRequest;
import com.ruoyi.price.domain.vo.TmsPriceQuoteResult;
import com.ruoyi.price.enums.BillCustomerActiveStatus;
import com.ruoyi.price.mapper.BmsProductCountryMapper;
import com.ruoyi.price.mapper.BmsProductMapper;
import com.ruoyi.price.mapper.PrcCustomerPriceSetMapper;
@@ -163,10 +164,12 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
public TmsPriceQuoteResult calculate(TmsPriceQuoteRequest request)
{
validateRequest(request);
resolveCustomer(request);
// Bill path: missing/disabled customer only marks status, pricing still continues.
String customerActiveStatus = softResolveCustomer(request);
Date bizDate = resolveBizDate(request.getBizDate());
TmsPriceQuoteResult result = doQuote(request, bizDate);
applySurchargesAndFees(result);
result.setCustomerActiveStatus(customerActiveStatus);
return result;
}
@@ -359,6 +362,48 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
{
throw new ServiceException(MessageUtils.message("price.quote.customerDisabled"));
}
applyResolvedCustomer(request, hit);
}
/**
* Soft resolve for billing: never throws on missing/disabled customer.
* Returns ACTIVE / MISSING / DISABLED. Quote path still uses hard {@link #resolveCustomer}.
*/
private String softResolveCustomer(TmsPriceQuoteRequest request)
{
if (request == null || StringUtils.isEmpty(request.getCustomerCode()))
{
throw new ServiceException(MessageUtils.message("price.quote.customerCodeRequired"));
}
String code = request.getCustomerCode().trim();
if (StringUtils.isEmpty(code))
{
throw new ServiceException(MessageUtils.message("price.quote.customerCodeRequired"));
}
request.setCustomerCode(code);
BmsCustomer query = new BmsCustomer();
query.setCustomerCode(code);
List<BmsCustomer> list = bmsCustomerMapper.selectBmsCustomerList(query);
if (list == null || list.isEmpty())
{
request.setCustomerId(null);
return BillCustomerActiveStatus.MISSING.getCode();
}
BmsCustomer hit = list.get(0);
applyResolvedCustomer(request, hit);
if ("1".equals(hit.getIsEnable()))
{
return BillCustomerActiveStatus.DISABLED.getCode();
}
return BillCustomerActiveStatus.ACTIVE.getCode();
}
private void applyResolvedCustomer(TmsPriceQuoteRequest request, BmsCustomer hit)
{
if (request == null || hit == null)
{
return;
}
request.setCustomerId(hit.getCustomerId());
if (StringUtils.isNotEmpty(hit.getCustomerCode()))
{