diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/PriceSyncEsLogAdvice.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/PriceSyncEsLogAdvice.java
index 5bdc28fc..21af01ba 100644
--- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/PriceSyncEsLogAdvice.java
+++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/PriceSyncEsLogAdvice.java
@@ -1,21 +1,23 @@
package com.ruoyi.web.controller.api;
+import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
+import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.price.domain.vo.TmsPriceBillRequest;
-import com.ruoyi.price.domain.vo.TmsPriceQuoteRequest;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.price.search.EsBizLogSupport;
-import com.ruoyi.price.search.EsLogType;
import com.ruoyi.price.search.IBillSearchStore;
import com.ruoyi.web.controller.price.BillingController;
/**
- * Persist validation failures of quote/bill APIs to Elasticsearch.
+ * Persist quote/bill API failures to Elasticsearch (validation, JSON parse, business, unexpected).
+ * Uses {@link EsBizLogSupport#saveApiFail} so service/controller can also call without duplicate docs.
*/
@Order(0)
@RestControllerAdvice(assignableTypes = { TmsPriceController.class, BillingController.class })
@@ -25,37 +27,61 @@ public class PriceSyncEsLogAdvice
private IBillSearchStore billSearchStore;
@ExceptionHandler(MethodArgumentNotValidException.class)
- public AjaxResult handleValid(MethodArgumentNotValidException e)
+ public AjaxResult handleValid(MethodArgumentNotValidException e, HttpServletRequest request)
{
String message = e.getBindingResult().getFieldError() == null
? e.getMessage()
: e.getBindingResult().getFieldError().getDefaultMessage();
- saveFail(e.getBindingResult().getTarget(), message);
+ saveFail(request, e.getBindingResult().getTarget(), message);
return AjaxResult.error(message);
}
@ExceptionHandler(BindException.class)
- public AjaxResult handleBind(BindException e)
+ public AjaxResult handleBind(BindException e, HttpServletRequest request)
{
String message = e.getAllErrors().isEmpty() ? e.getMessage() : e.getAllErrors().get(0).getDefaultMessage();
- saveFail(e.getTarget(), message);
+ saveFail(request, e.getTarget(), message);
return AjaxResult.error(message);
}
- private void saveFail(Object target, String message)
+ @ExceptionHandler(HttpMessageNotReadableException.class)
+ public AjaxResult handleNotReadable(HttpMessageNotReadableException e, HttpServletRequest request)
{
- if (target instanceof TmsPriceBillRequest)
+ String message = EsBizLogSupport.errorText(e);
+ saveFail(request, null, message);
+ return AjaxResult.error(message);
+ }
+
+ @ExceptionHandler(ServiceException.class)
+ public AjaxResult handleService(ServiceException e, HttpServletRequest request)
+ {
+ saveFail(request, null, e);
+ Integer code = e.getCode();
+ return StringUtils.isNotNull(code) ? AjaxResult.error(code, e.getMessage()) : AjaxResult.error(e.getMessage());
+ }
+
+ @ExceptionHandler(Exception.class)
+ public AjaxResult handleAny(Exception e, HttpServletRequest request)
+ {
+ saveFail(request, null, e);
+ return AjaxResult.error(EsBizLogSupport.errorText(e));
+ }
+
+ private void saveFail(HttpServletRequest request, Object boundTarget, Throwable e)
+ {
+ if (!EsBizLogSupport.isPriceSyncWriteApi(request))
{
- TmsPriceBillRequest request = (TmsPriceBillRequest) target;
- EsBizLogSupport.saveFail(billSearchStore, EsLogType.BILL, EsBizLogSupport.billFailBizId(request), request,
- message);
return;
}
- if (target instanceof TmsPriceQuoteRequest)
+ EsBizLogSupport.saveApiFail(billSearchStore, request, boundTarget, e);
+ }
+
+ private void saveFail(HttpServletRequest request, Object boundTarget, String message)
+ {
+ if (!EsBizLogSupport.isPriceSyncWriteApi(request))
{
- TmsPriceQuoteRequest request = (TmsPriceQuoteRequest) target;
- EsBizLogSupport.saveFail(billSearchStore, EsLogType.QUOTE, EsBizLogSupport.quoteFailBizId(request),
- request, message);
+ return;
}
+ EsBizLogSupport.saveApiFail(billSearchStore, request, boundTarget, message);
}
}
diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceSetController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceSetController.java
index 25c9895f..043f1b3a 100644
--- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceSetController.java
+++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceSetController.java
@@ -1,5 +1,7 @@
package com.ruoyi.web.controller.price;
+import java.util.Date;
+
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -24,6 +26,7 @@ import com.ruoyi.price.domain.PrcCustomerPriceSet;
import com.ruoyi.price.domain.PrcPriceSet;
import com.ruoyi.price.domain.PrcPriceSetDetail;
import com.ruoyi.price.domain.PrcPriceSetSaveTask;
+import com.ruoyi.price.domain.PrcPriceSetVersion;
import com.ruoyi.price.service.IPrcPriceSetService;
/**
@@ -124,9 +127,10 @@ public class PriceSetController extends BaseController
@PreAuthorize("@ss.hasPermi('price:set:edit')")
@Log(title = "Price set version activate", businessType = BusinessType.UPDATE)
@PutMapping("/version/activate/{psvId}")
- public AjaxResult activateVersion(@PathVariable Long psvId)
+ public AjaxResult activateVersion(@PathVariable Long psvId, @RequestBody(required = false) PrcPriceSetVersion body)
{
- return toAjax(priceSetService.activateVersion(psvId, getUsername()));
+ Date effectiveTime = body == null ? null : body.getEffectiveTime();
+ return toAjax(priceSetService.activateVersion(psvId, effectiveTime, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:set:add')")
diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceStatusController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceStatusController.java
deleted file mode 100644
index 8e9224a5..00000000
--- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceStatusController.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.ruoyi.web.controller.price;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-import com.ruoyi.common.core.controller.BaseController;
-import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.utils.MessageUtils;
-import com.ruoyi.price.task.BmsStatusRefreshTask;
-
-/**
- * 状态自动刷新 - 手动触发入口
- *
- *
日常由 BmsStatusRefreshTask 每天 02:00 跑,特殊情况可手动触发。
- *
- * @author ruoyi
- */
-@RestController
-@RequestMapping("/price/status")
-public class PriceStatusController extends BaseController
-{
- @Autowired
- private BmsStatusRefreshTask refreshTask;
-
- /**
- * 立即刷新 bms_product / bms_product_country 的 status 字段
- */
- @PreAuthorize("@ss.hasPermi('price:product:edit')")
- @PostMapping("/refresh")
- public AjaxResult refresh()
- {
- int[] counts = refreshTask.refreshNow();
- String msg = MessageUtils.message("price.status.refresh.success",
- String.valueOf(counts[0]), String.valueOf(counts[1]),
- String.valueOf(counts.length > 2 ? counts[2] : 0));
- return success(msg);
- }
-}
\ No newline at end of file
diff --git a/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties b/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties
index a48b5b97..f4b3b054 100644
--- a/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties
+++ b/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties
@@ -126,6 +126,8 @@ price.quote.freightMissing2=产品默认运费或挂号费为空
price.quote.freightMissing3=运费为空
price.quote.requestBodyRequired=请求体为空
price.quote.customerIdRequired=客户ID为空
+price.quote.customerCodeRequired=客户代码不能为空
+price.quote.customerCodeNotFound=客户代码不存在
price.quote.productCodeRequired=产品代码为空
price.quote.countryRequired=国家代码或国家名称为空
price.quote.weightInvalid=重量必须大于0
@@ -135,8 +137,6 @@ price.quote.noCustomerPackageOverride=没有客户套餐覆盖,使用产品默
price.quote.quoteIdRequired=报价ID为空
price.quote.hitCustomerFixedPackageDetail=命中客户套餐固定价格明细
-# ---- 状态自动刷新 ----
-price.status.refresh.success=状态刷新完成:bms_product 更新 {0} 行,bms_product_country 更新 {1} 行,价格套餐版本 {2} 套
# receivable bill
price.bill.orderNoRequired=订单号为空
price.bill.billIdRequired=计费ID为空
@@ -147,6 +147,7 @@ price.set.version.effectiveTime.empty=版本生效时间不能为空
price.set.version.notExists=套餐版本不存在
price.set.version.activate.statusNotAllowed=仅草稿版本可启动
price.set.version.delete.statusNotAllowed=仅草稿版本可删除
+price.set.version.copy.draftExists=已有草稿版本,请先启动或删除后再复制
price.set.save.task.notExists=保存任务不存在或已过期
price.set.library.empty=产品库无可用价格明细,请检查产品国家配置
price.set.detail.duplicate.fixed=产品{0} 国家{1} 重量段{2} {3}(共{4}条)
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersion.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersion.java
index b0d64122..8a8b965e 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersion.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersion.java
@@ -24,7 +24,7 @@ public class PrcPriceSetVersion extends BaseEntity
/** null = open; set to next version effective_time when that version starts */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date expireTime;
- /** 0 draft / 1 active / 2 expired */
+ /** 0 draft / 3 pending / 1 active / 2 expired */
private String status;
private List details;
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuote.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuote.java
index 815fd1c1..52101e60 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuote.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuote.java
@@ -18,6 +18,8 @@ public class PrcQuote extends BaseEntity
private String quoteId;
private String quoteKey;
private Long customerId;
+ /** bms_customer.customer_code */
+ private String customerCode;
private String productCode;
private String productName;
private String countryCode;
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteRequest.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteRequest.java
index 163553fe..98466e5c 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteRequest.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteRequest.java
@@ -14,6 +14,8 @@ public class TmsPriceQuoteRequest implements Serializable
private Long customerId;
+ /** Required. Maps to bms_customer.customer_code; used in quote unique key. */
+ @NotBlank(message = "customerCode is required")
private String customerCode;
@NotBlank(message = "productCode is required")
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/search/EsBizLogSupport.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/search/EsBizLogSupport.java
index f3d4daf8..df503e28 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/search/EsBizLogSupport.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/search/EsBizLogSupport.java
@@ -1,9 +1,13 @@
package com.ruoyi.price.search;
import java.util.UUID;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.http.HttpHelper;
import com.ruoyi.common.utils.sign.Md5Utils;
import com.ruoyi.price.domain.vo.TmsPriceBillRequest;
import com.ruoyi.price.domain.vo.TmsPriceQuoteRequest;
@@ -14,7 +18,11 @@ public final class EsBizLogSupport
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_FAIL = "FAIL";
+ /** Request attribute: fail already written to ES in this request. */
+ public static final String ATTR_FAIL_LOGGED = "bms.es.fail.logged";
+
private static final int ERROR_MAX = 2000;
+ private static final int RAW_BODY_MAX = 8000;
private EsBizLogSupport()
{
@@ -26,6 +34,10 @@ public final class EsBizLogSupport
{
return null;
}
+ if (request instanceof CharSequence)
+ {
+ return cut(String.valueOf(request), RAW_BODY_MAX);
+ }
JSONObject obj = JSON.parseObject(JSON.toJSONString(request));
if (obj == null)
{
@@ -64,15 +76,20 @@ public final class EsBizLogSupport
}
public static String cut(String msg)
+ {
+ return cut(msg, ERROR_MAX);
+ }
+
+ public static String cut(String msg, int max)
{
if (msg == null)
{
return null;
}
String text = msg.trim();
- if (text.length() > ERROR_MAX)
+ if (text.length() > max)
{
- return text.substring(0, ERROR_MAX);
+ return text.substring(0, max);
}
return text;
}
@@ -104,6 +121,39 @@ public final class EsBizLogSupport
return "FAIL-" + Md5Utils.hash(raw);
}
+ /**
+ * Common entry for quote/bill API failures (validation / JSON parse / business / unexpected).
+ * Dedups with {@link #ATTR_FAIL_LOGGED} so controller/service/advice can all call safely.
+ */
+ public static void saveApiFail(IBillSearchStore store, HttpServletRequest httpRequest, Object boundTarget,
+ Throwable e)
+ {
+ saveApiFail(store, httpRequest, boundTarget, errorText(e));
+ }
+
+ public static void saveApiFail(IBillSearchStore store, HttpServletRequest httpRequest, Object boundTarget,
+ String errorMessage)
+ {
+ if (store == null || isFailLogged(httpRequest))
+ {
+ return;
+ }
+ String rawBody = readRawBody(httpRequest);
+ String logType = resolveLogType(httpRequest, boundTarget);
+ if (StringUtils.isEmpty(logType))
+ {
+ return;
+ }
+ Object payload = boundTarget != null ? boundTarget : rawBody;
+ String bizId = resolveFailBizId(logType, boundTarget, rawBody);
+ // saveFail also marks ATTR_FAIL_LOGGED
+ saveFail(store, logType, bizId, payload, errorMessage);
+ if (!isFailLogged(httpRequest))
+ {
+ markFailLogged(httpRequest);
+ }
+ }
+
public static void saveFail(IBillSearchStore store, String logType, String bizId, Object request, Throwable e)
{
saveFail(store, logType, bizId, request, errorText(e));
@@ -115,8 +165,157 @@ public final class EsBizLogSupport
{
return;
}
+ HttpServletRequest httpRequest = currentRequest();
+ if (isFailLogged(httpRequest))
+ {
+ return;
+ }
String msg = cut(errorMessage);
store.save(logType, bizId, requestJson(request), failResponseJson(msg), STATUS_FAIL, msg);
+ markFailLogged(httpRequest);
+ }
+
+ public static String resolveLogType(HttpServletRequest httpRequest, Object boundTarget)
+ {
+ if (boundTarget instanceof TmsPriceBillRequest)
+ {
+ return EsLogType.BILL;
+ }
+ if (boundTarget instanceof TmsPriceQuoteRequest)
+ {
+ return EsLogType.QUOTE;
+ }
+ if (httpRequest == null)
+ {
+ return null;
+ }
+ String uri = httpRequest.getRequestURI();
+ if (uri == null)
+ {
+ return null;
+ }
+ if (uri.endsWith("/bill"))
+ {
+ return EsLogType.BILL;
+ }
+ if (uri.endsWith("/quote"))
+ {
+ return EsLogType.QUOTE;
+ }
+ return null;
+ }
+
+ public static String resolveFailBizId(String logType, Object boundTarget, String rawBody)
+ {
+ if (EsLogType.BILL.equals(logType))
+ {
+ if (boundTarget instanceof TmsPriceBillRequest)
+ {
+ return billFailBizId((TmsPriceBillRequest) boundTarget);
+ }
+ String orderNo = extractJsonField(rawBody, "orderNo");
+ if (StringUtils.isNotEmpty(orderNo))
+ {
+ return orderNo.trim();
+ }
+ return "FAIL-" + UUID.randomUUID();
+ }
+ if (EsLogType.QUOTE.equals(logType))
+ {
+ if (boundTarget instanceof TmsPriceQuoteRequest)
+ {
+ return quoteFailBizId((TmsPriceQuoteRequest) boundTarget);
+ }
+ String seed = StringUtils.isNotEmpty(rawBody) ? rawBody : UUID.randomUUID().toString();
+ return "FAIL-" + Md5Utils.hash(seed);
+ }
+ return "FAIL-" + UUID.randomUUID();
+ }
+
+ public static boolean isPriceSyncWriteApi(HttpServletRequest httpRequest)
+ {
+ if (httpRequest == null)
+ {
+ return false;
+ }
+ if (!"POST".equalsIgnoreCase(httpRequest.getMethod()))
+ {
+ return false;
+ }
+ String uri = httpRequest.getRequestURI();
+ if (uri == null)
+ {
+ return false;
+ }
+ return uri.endsWith("/quote") || uri.endsWith("/bill");
+ }
+
+ public static String readRawBody(HttpServletRequest httpRequest)
+ {
+ if (httpRequest == null)
+ {
+ return null;
+ }
+ try
+ {
+ String body = HttpHelper.getBodyString(httpRequest);
+ return cut(body, RAW_BODY_MAX);
+ }
+ catch (Exception ignored)
+ {
+ return null;
+ }
+ }
+
+ public static boolean isFailLogged(HttpServletRequest httpRequest)
+ {
+ HttpServletRequest req = httpRequest != null ? httpRequest : currentRequest();
+ return req != null && Boolean.TRUE.equals(req.getAttribute(ATTR_FAIL_LOGGED));
+ }
+
+ public static void markFailLogged(HttpServletRequest httpRequest)
+ {
+ HttpServletRequest req = httpRequest != null ? httpRequest : currentRequest();
+ if (req != null)
+ {
+ req.setAttribute(ATTR_FAIL_LOGGED, Boolean.TRUE);
+ }
+ }
+
+ public static HttpServletRequest currentRequest()
+ {
+ try
+ {
+ ServletRequestAttributes attrs =
+ (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ return attrs == null ? null : attrs.getRequest();
+ }
+ catch (Exception ignored)
+ {
+ return null;
+ }
+ }
+
+ private static String extractJsonField(String rawBody, String field)
+ {
+ if (StringUtils.isEmpty(rawBody) || StringUtils.isEmpty(field))
+ {
+ return null;
+ }
+ try
+ {
+ JSONObject obj = JSON.parseObject(rawBody);
+ if (obj == null)
+ {
+ return null;
+ }
+ Object value = obj.get(field);
+ return value == null ? null : String.valueOf(value);
+ }
+ catch (Exception ignored)
+ {
+ return null;
+ }
}
private static String nvl(Object value)
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IPrcPriceSetService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IPrcPriceSetService.java
index 57c698eb..9c4ecab1 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IPrcPriceSetService.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IPrcPriceSetService.java
@@ -1,5 +1,6 @@
package com.ruoyi.price.service;
+import java.util.Date;
import java.util.List;
import com.ruoyi.price.domain.PrcCustomerPriceSet;
import com.ruoyi.price.domain.PrcPriceSet;
@@ -32,8 +33,11 @@ public interface IPrcPriceSetService
public int disable(Long priceSetId, String updateBy);
- /** Activate a draft version: set effective_time=now, refresh version 0/1/2 */
- public int activateVersion(Long psvId, String updateBy);
+ /**
+ * Activate a draft version with user-specified effectiveTime.
+ * effectiveTime <= now -> EFFECT; future -> PENDING(3). Then refresh 0/3/1/2.
+ */
+ public int activateVersion(Long psvId, Date effectiveTime, String updateBy);
/**
* Copy source version into a new draft version under the same package:
@@ -65,7 +69,7 @@ public interface IPrcPriceSetService
public int deleteCustomerByIds(Long[] customerPricesetIds);
- /** chain expire_time = next version effective_time, then refresh 0/1/2 */
+ /** chain expire_time = next version effective_time, then refresh 0/3/1/2 */
public int refreshAllVersionStatus();
/** submit async save, return taskId immediately */
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcOrderBillServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcOrderBillServiceImpl.java
index 3b434c70..092a5b46 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcOrderBillServiceImpl.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcOrderBillServiceImpl.java
@@ -128,8 +128,18 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
}
bill.setBillKey(billKey);
bill.setOrderNo(orderNo);
- bill.setCustomerId(request.getCustomerId() == null ? Long.valueOf(0L) : request.getCustomerId());
- bill.setCustomerCode(request.getCustomerCode());
+ Long customerId = request.getCustomerId();
+ if (customerId == null && result != null)
+ {
+ customerId = result.getCustomerId();
+ }
+ bill.setCustomerId(customerId == null ? Long.valueOf(0L) : customerId);
+ String customerCode = request.getCustomerCode();
+ if (StringUtils.isEmpty(customerCode) && result != null)
+ {
+ customerCode = result.getCustomerCode();
+ }
+ bill.setCustomerCode(customerCode);
bill.setCustomerName(request.getCustomerName());
bill.setTrackingNo(request.getTrackingNo());
bill.setPieces(request.getPieces());
@@ -178,7 +188,8 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
bill.setRoundMode(result.getRoundMode());
result.setBillId(bill.getBillId());
result.setOrderNo(orderNo);
- result.setCustomerCode(request.getCustomerCode());
+ result.setCustomerId(bill.getCustomerId());
+ result.setCustomerCode(bill.getCustomerCode());
result.setIncomeTotal(bill.getIncomeTotal());
result.setPayTotal(bill.getPayTotal());
result.setPayableTotal(bill.getIncomeTotal());
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcPriceSetServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcPriceSetServiceImpl.java
index 15b3376f..55260142 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcPriceSetServiceImpl.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcPriceSetServiceImpl.java
@@ -57,6 +57,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
private static final String TYPE_DISCOUNT = "DISCOUNT";
private static final String VER_DRAFT = "0";
private static final String VER_EFFECT = "1";
+ /** Pending: effective_time set but not yet reached */
+ private static final String VER_PENDING = "3";
private static final String VER_EXPIRED = "2";
@Autowired
@@ -93,6 +95,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
PrcPriceSet set = setMapper.selectPrcPriceSetById(priceSetId);
if (set != null)
{
+ // Reconcile version status/expire windows so UI always shows at most one EFFECT
+ refreshVersionStatus(priceSetId);
fillExpired(set);
fillVersions(set);
}
@@ -268,12 +272,16 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Override
@Transactional
- public int activateVersion(Long psvId, String updateBy)
+ public int activateVersion(Long psvId, Date effectiveTime, String updateBy)
{
if (psvId == null)
{
throw new ServiceException(MessageUtils.message("price.set.version.notExists"));
}
+ if (effectiveTime == null)
+ {
+ throw new ServiceException(MessageUtils.message("price.set.version.effectiveTime.empty"));
+ }
PrcPriceSetVersion version = versionMapper.selectPrcPriceSetVersionById(psvId);
if (version == null)
{
@@ -288,34 +296,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
throw new ServiceException(MessageUtils.message("price.set.edit.stopped"));
}
- Date now = new Date();
- // Expire currently effective versions under the same package
- PrcPriceSetVersion q = new PrcPriceSetVersion();
- q.setPriceSetId(set.getPriceSetId());
- List siblings = versionMapper.selectPrcPriceSetVersionList(q);
- if (siblings != null)
- {
- for (PrcPriceSetVersion other : siblings)
- {
- if (other.getPsvId() == null || other.getPsvId().equals(psvId))
- {
- continue;
- }
- if (VER_EFFECT.equals(other.getStatus()))
- {
- other.setStatus(VER_EXPIRED);
- other.setExpireTime(now);
- other.setUpdateBy(updateBy);
- versionMapper.updatePrcPriceSetVersion(other);
- }
- }
- }
- version.setEffectiveTime(now);
+ // Set schedule; refreshVersionStatus decides PENDING(3) vs EFFECT(1) and chains expire_time
+ version.setEffectiveTime(effectiveTime);
version.setExpireTime(null);
- version.setStatus(VER_EFFECT);
version.setUpdateBy(updateBy);
versionMapper.updatePrcPriceSetVersion(version);
-
refreshVersionStatus(set.getPriceSetId());
return 1;
}
@@ -338,6 +323,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
throw new ServiceException(MessageUtils.message("price.set.edit.stopped"));
}
+ if (hasDraftVersion(set.getPriceSetId()))
+ {
+ throw new ServiceException(MessageUtils.message("price.set.version.copy.draftExists"));
+ }
Integer maxSeq = versionMapper.selectMaxVersionSeq(set.getPriceSetId());
int seq = maxSeq == null ? 1 : maxSeq.intValue() + 1;
@@ -1925,6 +1914,34 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
version.setSurcharges(saved);
}
+ /** True when package already has a true draft (status=0). Pending(3) does not block copy. */
+ private boolean hasDraftVersion(Long priceSetId)
+ {
+ if (priceSetId == null)
+ {
+ return false;
+ }
+ PrcPriceSetVersion q = new PrcPriceSetVersion();
+ q.setPriceSetId(priceSetId);
+ List list = versionMapper.selectPrcPriceSetVersionList(q);
+ if (list == null)
+ {
+ return false;
+ }
+ for (PrcPriceSetVersion v : list)
+ {
+ if (v == null)
+ {
+ continue;
+ }
+ if (VER_DRAFT.equals(v.getStatus()))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
private void refreshVersionStatus(Long priceSetId)
{
PrcPriceSetVersion q = new PrcPriceSetVersion();
@@ -1958,10 +1975,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return Integer.compare(nullToZero(a.getVersionSeq()), nullToZero(b.getVersionSeq()));
});
Date now = new Date();
+ // First pass: chain expire_time = next sibling's effective_time; compute window status
+ Long effectPsvId = null;
for (int i = 0; i < versions.size(); i++)
{
PrcPriceSetVersion v = versions.get(i);
- // Next start only among versions that already have effective_time
Date nextStart = null;
for (int j = i + 1; j < versions.size(); j++)
{
@@ -1972,46 +1990,57 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
break;
}
}
- String status;
+ v.setExpireTime(nextStart);
if (v.getEffectiveTime() == null)
{
- status = VER_DRAFT;
+ v.setStatus(VER_DRAFT);
+ continue;
}
- else if (startedNotAfter(v.getEffectiveTime(), now)
- && (nextStart == null || nextStart.after(now)))
+ // Not started yet -> pending
+ if (v.getEffectiveTime().getTime() > now.getTime())
{
- status = VER_EFFECT;
+ v.setStatus(VER_PENDING);
+ continue;
}
- else if (startedNotAfter(v.getEffectiveTime(), now))
+ // Window ended: expire_time <= now (same rule as bill: expire_time > asOf to stay active)
+ if (nextStart != null && nextStart.getTime() <= now.getTime())
{
- status = VER_EXPIRED;
+ v.setStatus(VER_EXPIRED);
+ continue;
}
- else
+ // In window: candidate for the single EFFECT version (latest wins)
+ v.setStatus(VER_EFFECT);
+ effectPsvId = v.getPsvId();
+ }
+ // Second pass: only the latest in-window version stays EFFECT
+ if (effectPsvId != null)
+ {
+ for (PrcPriceSetVersion v : versions)
{
- // future-dated: keep as draft until wall-clock reaches effective_time
- status = VER_DRAFT;
+ if (VER_EFFECT.equals(v.getStatus())
+ && v.getPsvId() != null
+ && !v.getPsvId().equals(effectPsvId))
+ {
+ v.setStatus(VER_EXPIRED);
+ }
}
- boolean statusChanged = !status.equals(v.getStatus());
- boolean expireChanged = !sameTime(nextStart, v.getExpireTime());
+ }
+ for (PrcPriceSetVersion v : versions)
+ {
+ PrcPriceSetVersion db = versionMapper.selectPrcPriceSetVersionById(v.getPsvId());
+ if (db == null)
+ {
+ continue;
+ }
+ boolean statusChanged = !String.valueOf(v.getStatus()).equals(String.valueOf(db.getStatus()));
+ boolean expireChanged = !sameTime(v.getExpireTime(), db.getExpireTime());
if (statusChanged || expireChanged)
{
- v.setStatus(status);
- v.setExpireTime(nextStart);
versionMapper.updatePrcPriceSetVersion(v);
}
}
}
- /** effective_time <= now (+1s skew tolerance) */
- private static boolean startedNotAfter(Date effectiveTime, Date now)
- {
- if (effectiveTime == null || now == null)
- {
- return false;
- }
- return effectiveTime.getTime() <= now.getTime() + 1000L;
- }
-
@Override
public int refreshAllVersionStatus()
{
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/TmsPriceQuoteServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/TmsPriceQuoteServiceImpl.java
index ce0e0750..f5ff5603 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/TmsPriceQuoteServiceImpl.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/TmsPriceQuoteServiceImpl.java
@@ -21,6 +21,8 @@ import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.sign.Md5Utils;
+import com.ruoyi.customer.customerinfo.domain.BmsCustomer;
+import com.ruoyi.customer.customerinfo.mapper.BmsCustomerMapper;
import com.ruoyi.price.domain.BmsProduct;
import com.ruoyi.price.domain.BmsProductCountry;
import com.ruoyi.price.domain.PrcCustomerPriceSet;
@@ -33,6 +35,7 @@ import com.ruoyi.price.domain.PrcQuoteFee;
import com.ruoyi.price.domain.PrcSurchargeRel;
import com.ruoyi.price.domain.PrcZone;
import com.ruoyi.price.mapper.PrcZoneDetailMapper;
+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;
@@ -106,6 +109,9 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
@Autowired
private IBillSearchStore billSearchStore;
+ @Autowired
+ private BmsCustomerMapper bmsCustomerMapper;
+
@Override
@Transactional(rollbackFor = Exception.class)
public TmsPriceQuoteResult quote(TmsPriceQuoteRequest request)
@@ -125,6 +131,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
private TmsPriceQuoteResult quoteInternal(TmsPriceQuoteRequest request)
{
validateRequest(request);
+ resolveCustomer(request);
Date bizDate = resolveBizDate(request.getBizDate());
BigDecimal weight = request.getWeight().setScale(3, RoundingMode.HALF_UP);
String quoteKey = buildQuoteKey(request, weight, bizDate);
@@ -156,6 +163,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
public TmsPriceQuoteResult calculate(TmsPriceQuoteRequest request)
{
validateRequest(request);
+ resolveCustomer(request);
Date bizDate = resolveBizDate(request.getBizDate());
TmsPriceQuoteResult result = doQuote(request, bizDate);
applySurchargesAndFees(result);
@@ -312,6 +320,53 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
{
result.setDestPostcode(emptyToNull(request.getDestPostcode()));
}
+ // Always prefer customer resolved from customerCode (ignore TMS/bind ids)
+ if (request.getCustomerId() != null)
+ {
+ result.setCustomerId(request.getCustomerId());
+ }
+ if (StringUtils.isNotEmpty(request.getCustomerCode()))
+ {
+ result.setCustomerCode(request.getCustomerCode());
+ }
+ }
+
+ /**
+ * Require customerCode; resolve customer_id from bms_customer.
+ */
+ private void resolveCustomer(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 list = bmsCustomerMapper.selectBmsCustomerList(query);
+ if (list == null || list.isEmpty())
+ {
+ throw new ServiceException(MessageUtils.message("price.quote.customerCodeNotFound"));
+ }
+ BmsCustomer hit = list.get(0);
+ request.setCustomerId(hit.getCustomerId());
+ if (StringUtils.isNotEmpty(hit.getCustomerCode()))
+ {
+ request.setCustomerCode(hit.getCustomerCode());
+ }
+ if (request instanceof TmsPriceBillRequest)
+ {
+ TmsPriceBillRequest billReq = (TmsPriceBillRequest) request;
+ if (StringUtils.isEmpty(billReq.getCustomerName()) && StringUtils.isNotEmpty(hit.getCustomerName()))
+ {
+ billReq.setCustomerName(hit.getCustomerName());
+ }
+ }
}
private String resolveCountryIso2(String productCode, String countryCode, String countryName)
@@ -684,6 +739,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
quote.setQuoteId(UUID.randomUUID().toString());
quote.setQuoteKey(quoteKey);
quote.setCustomerId(request.getCustomerId() == null ? Long.valueOf(0L) : request.getCustomerId());
+ quote.setCustomerCode(emptyToNull(request.getCustomerCode()));
quote.setProductCode(result.getProductCode());
quote.setProductName(result.getProductName());
quote.setCountryCode(result.getCountryCode());
@@ -810,6 +866,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
r.setMatchSource(quote.getMatchSource());
r.setMatchMessage(quote.getMatchMessage());
r.setCustomerId(quote.getCustomerId());
+ r.setCustomerCode(quote.getCustomerCode());
r.setPriceSetId(quote.getPriceSetId());
r.setPriceSetCode(quote.getPriceSetCode());
r.setPriceSetName(quote.getPriceSetName());
@@ -857,6 +914,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
to.setMatchSource(from.getMatchSource());
to.setMatchMessage(from.getMatchMessage());
to.setCustomerId(from.getCustomerId());
+ to.setCustomerCode(from.getCustomerCode());
to.setPriceSetId(from.getPriceSetId());
to.setPriceSetCode(from.getPriceSetCode());
to.setPriceSetName(from.getPriceSetName());
@@ -892,6 +950,10 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
private String buildQuoteKey(TmsPriceQuoteRequest request, BigDecimal weight, Date bizDate)
{
+ if (StringUtils.isEmpty(request.getCustomerCode()))
+ {
+ throw new ServiceException(MessageUtils.message("price.quote.customerCodeRequired"));
+ }
String countryCode = emptyToNull(request.getCountryCode());
if (countryCode != null)
{
@@ -899,8 +961,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
}
String countryName = emptyToNull(request.getCountryName());
String biz = new SimpleDateFormat("yyyy-MM-dd").format(bizDate);
- String raw = (StringUtils.isNotEmpty(request.getCustomerCode())
- ? request.getCustomerCode() : String.valueOf(request.getCustomerId())) + "|"
+ String raw = request.getCustomerCode().trim() + "|"
+ request.getProductCode().trim() + "|"
+ nvlStr(countryCode) + "|"
+ nvlStr(countryName) + "|"
@@ -995,9 +1056,9 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
{
throw new ServiceException(MessageUtils.message("price.quote.requestBodyRequired"));
}
- if (StringUtils.isEmpty(request.getCustomerCode()) && request.getCustomerId() == null)
+ if (StringUtils.isEmpty(request.getCustomerCode()))
{
- throw new ServiceException(MessageUtils.message("price.quote.customerIdRequired"));
+ throw new ServiceException(MessageUtils.message("price.quote.customerCodeRequired"));
}
if (StringUtils.isEmpty(request.getProductCode()))
{
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/BmsStatusRefreshTask.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/BmsStatusRefreshTask.java
index 1481e79c..bf17bd87 100644
--- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/BmsStatusRefreshTask.java
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/BmsStatusRefreshTask.java
@@ -7,73 +7,42 @@ import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
-import com.ruoyi.price.mapper.BmsProductCountryMapper;
-import com.ruoyi.price.mapper.BmsProductMapper;
import com.ruoyi.price.service.IPrcPriceSetService;
/**
- * bms_product / bms_product_country 状态自动刷新
- *
- * 每天 02:00 根据 effective_date / expire_date 与当天日期,回填 status:
- *
- * - '2' 已失效 EXPIRED:expire_date 非空且 <= 今天
- * - '0' 待生效 PENDING:effective_date 为空 / 未来
- * - '1' 已生效 ACTIVE:其余(已生效且仍在有效期)
- *
- *
- * 对应字典 bms_product_effect_status 的 value=ACTIVE/PENDING/EXPIRED。
- * 实际存储使用 0/1/2 与列 comment 保持一致;前端 dict-tag 需做 value 映射或调整 dict_value。
- *
- *
手动触发:调用 refreshNow()(运维/测试用,详见 StatusRefreshController)。
- *
- * @author ruoyi
+ * Refresh prc_price_set_version status (draft/pending/effect/expired).
+ * Startup + every 5 minutes so PENDING becomes EFFECT when effective_time is reached.
*/
@Component
public class BmsStatusRefreshTask implements ApplicationRunner
{
private static final Logger log = LoggerFactory.getLogger(BmsStatusRefreshTask.class);
- @Autowired
- private BmsProductMapper productMapper;
-
- @Autowired
- private BmsProductCountryMapper productCountryMapper;
-
@Autowired
private IPrcPriceSetService priceSetService;
- /** 每天 02:00 跑(cron: sec min hour DOM month DOW) */
- @Scheduled(cron = "0 0 2 * * ?")
- public void scheduled()
- {
- refreshNow();
- }
-
@Override
public void run(ApplicationArguments args)
+ {
+ refresh("startup");
+ }
+
+ @Scheduled(cron = "0 0/5 * * * ?")
+ public void scheduledRefresh()
+ {
+ refresh("scheduled");
+ }
+
+ private void refresh(String scene)
{
try
{
int n = priceSetService.refreshAllVersionStatus();
- log.info("startup prc_price_set_version expire/status refreshed, sets={}", n);
+ log.info("{} prc_price_set_version expire/status refreshed, sets={}", scene, n);
}
catch (Exception e)
{
- log.error("startup prc_price_set_version refresh failed", e);
+ log.error("{} prc_price_set_version refresh failed", scene, e);
}
}
-
- /**
- * Refresh product/country status and chain version expire_time.
- * Returns [product rows, country rows, price-set count].
- */
- public int[] refreshNow()
- {
- int productCount = productMapper.refreshStatusByDates();
- int countryCount = productCountryMapper.refreshStatusByDates();
- int versionSets = priceSetService.refreshAllVersionStatus();
- log.info("BmsStatusRefreshTask done: bms_product={}, bms_product_country={}, price_set_version_sets={}",
- productCount, countryCount, versionSets);
- return new int[] { productCount, countryCount, versionSets };
- }
-}
\ No newline at end of file
+}
diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcQuoteMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcQuoteMapper.xml
index e267bf7d..3bf96283 100644
--- a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcQuoteMapper.xml
+++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcQuoteMapper.xml
@@ -8,11 +8,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+
-
+
@@ -46,7 +47,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
- select quote_id, quote_key, customer_id, product_code, product_name,
+ select quote_id, quote_key, customer_id, customer_code, product_code, product_name,
country_code, country_name, dest_postcode, weight, biz_date, tms_id,
match_source, match_message, price_set_id, price_set_code, price_set_name,
set_type, bind_priority, customer_priceset_id, product_country_id,
@@ -73,6 +74,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND customer_id = #{customerId}
+
+ AND customer_code = #{customerCode}
+
AND product_code like concat('%', #{productCode}, '%')
@@ -103,7 +107,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
insert into prc_quote (
- quote_id, quote_key, customer_id, product_code, product_name,
+ quote_id, quote_key, customer_id, customer_code, product_code, product_name,
country_code, country_name, dest_postcode, weight, biz_date, tms_id,
match_source, match_message, price_set_id, price_set_code, price_set_name,
set_type, bind_priority, customer_priceset_id, product_country_id,
@@ -112,7 +116,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
freight_amount, surcharge_amount, payable_total, currency, round_mode,
create_time
) values (
- #{quoteId}, #{quoteKey}, #{customerId}, #{productCode}, #{productName},
+ #{quoteId}, #{quoteKey}, #{customerId}, #{customerCode}, #{productCode}, #{productName},
#{countryCode}, #{countryName}, #{destPostcode}, #{weight}, #{bizDate}, #{tmsId},
#{matchSource}, #{matchMessage}, #{priceSetId}, #{priceSetCode}, #{priceSetName},
#{setType}, #{bindPriority}, #{customerPricesetId}, #{productCountryId},
diff --git a/bms-back/sql/price/price_set_version_status_pending_comment.sql b/bms-back/sql/price/price_set_version_status_pending_comment.sql
new file mode 100644
index 00000000..87f8c87a
--- /dev/null
+++ b/bms-back/sql/price/price_set_version_status_pending_comment.sql
@@ -0,0 +1,7 @@
+-- Comment only: version status now includes pending (3)
+-- 0 draft / 3 pending (effective_time in future) / 1 effect / 2 expired
+-- No column change; CHAR(1) already supports '3'.
+
+ALTER TABLE prc_price_set_version
+ MODIFY COLUMN status CHAR(1) NOT NULL DEFAULT '0'
+ COMMENT '0 draft 3 pending 1 effect 2 expired';
diff --git a/bms-back/sql/price/quote_customer_code_alter.sql b/bms-back/sql/price/quote_customer_code_alter.sql
new file mode 100644
index 00000000..4837cec4
--- /dev/null
+++ b/bms-back/sql/price/quote_customer_code_alter.sql
@@ -0,0 +1,29 @@
+-- prc_quote add customer_code; backfill from bms_customer
+-- Idempotent
+SET NAMES utf8mb4;
+SET @db := DATABASE();
+
+SET @has := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_quote' AND COLUMN_NAME = 'customer_code'
+);
+SET @sql := IF(@has = 0,
+ 'ALTER TABLE prc_quote ADD COLUMN customer_code VARCHAR(50) DEFAULT NULL COMMENT ''customer code'' AFTER customer_id',
+ 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @hasIdx := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_quote' AND INDEX_NAME = 'idx_quote_customer_code'
+);
+SET @sql := IF(@hasIdx = 0,
+ 'ALTER TABLE prc_quote ADD INDEX idx_quote_customer_code (customer_code, biz_date)',
+ 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+UPDATE prc_quote q
+INNER JOIN bms_customer c ON c.customer_id = q.customer_id
+SET q.customer_code = c.customer_code
+WHERE q.customer_id IS NOT NULL
+ AND q.customer_id <> 0
+ AND (q.customer_code IS NULL OR q.customer_code = '');
diff --git a/bms-back/sql/price/quote_customer_code_unique_alter.sql b/bms-back/sql/price/quote_customer_code_unique_alter.sql
new file mode 100644
index 00000000..6cc0b76f
--- /dev/null
+++ b/bms-back/sql/price/quote_customer_code_unique_alter.sql
@@ -0,0 +1,59 @@
+-- prc_quote: customer_code required + included in unique business key
+-- Idempotent; safe after quote_customer_code_alter.sql
+SET NAMES utf8mb4;
+SET @db := DATABASE();
+
+-- Ensure column exists
+SET @has := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_quote' AND COLUMN_NAME = 'customer_code'
+);
+SET @sql := IF(@has = 0,
+ 'ALTER TABLE prc_quote ADD COLUMN customer_code VARCHAR(50) DEFAULT NULL COMMENT ''customer code'' AFTER customer_id',
+ 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- Backfill from bms_customer
+UPDATE prc_quote q
+INNER JOIN bms_customer c ON c.customer_id = q.customer_id
+SET q.customer_code = c.customer_code
+WHERE q.customer_id IS NOT NULL
+ AND q.customer_id <> 0
+ AND (q.customer_code IS NULL OR q.customer_code = '');
+
+-- Remove rows that still have no customer_code (cannot join unique NOT NULL)
+DELETE FROM prc_quote WHERE customer_code IS NULL OR customer_code = '';
+
+-- NOT NULL
+ALTER TABLE prc_quote
+ MODIFY COLUMN customer_code VARCHAR(50) NOT NULL COMMENT 'customer code';
+
+-- Drop old non-unique index if present
+SET @hasIdx := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_quote' AND INDEX_NAME = 'idx_quote_customer_code'
+);
+SET @sql := IF(@hasIdx > 0,
+ 'ALTER TABLE prc_quote DROP INDEX idx_quote_customer_code',
+ 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- Normalized postcode for unique (NULL -> '')
+SET @hasGen := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_quote' AND COLUMN_NAME = 'quote_postcode_key'
+);
+SET @sql := IF(@hasGen = 0,
+ 'ALTER TABLE prc_quote ADD COLUMN quote_postcode_key VARCHAR(20) AS (IFNULL(dest_postcode, '''')) STORED COMMENT ''dest_postcode for unique key'' AFTER dest_postcode',
+ 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- Unique: customer_code + product + country + postcode + weight + biz_date
+SET @hasUk := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_quote' AND INDEX_NAME = 'uk_quote_cust_biz'
+);
+SET @sql := IF(@hasUk = 0,
+ 'ALTER TABLE prc_quote ADD UNIQUE KEY uk_quote_cust_biz (customer_code, product_code, country_code, quote_postcode_key, weight, biz_date)',
+ 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/bms-back/sql/price/quote_fee.sql b/bms-back/sql/price/quote_fee.sql
index 8a6937e6..f2942fea 100644
--- a/bms-back/sql/price/quote_fee.sql
+++ b/bms-back/sql/price/quote_fee.sql
@@ -10,10 +10,13 @@ CREATE TABLE IF NOT EXISTS prc_quote (
quote_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '试算ID',
quote_key CHAR(32) NOT NULL COMMENT '试算指纹 MD5(客户|产品|国家|重量|计费日期)',
customer_id BIGINT(20) NOT NULL COMMENT '客户ID',
+ customer_code VARCHAR(50) NOT NULL COMMENT '客户代码',
product_code VARCHAR(20) NOT NULL COMMENT '产品代码',
product_name VARCHAR(100) DEFAULT NULL COMMENT '产品名称',
country_code CHAR(2) DEFAULT NULL COMMENT '国家二字码',
country_name VARCHAR(50) DEFAULT NULL COMMENT '国家名称',
+ dest_postcode VARCHAR(20) DEFAULT NULL COMMENT '目的邮编',
+ quote_postcode_key VARCHAR(20) AS (IFNULL(dest_postcode, '')) STORED COMMENT '邮编唯一键辅助',
weight DECIMAL(10,3) NOT NULL COMMENT '计费重量KG',
biz_date DATE NOT NULL COMMENT '计费日期',
tms_id BIGINT(20) DEFAULT NULL COMMENT '多租户系统ID',
@@ -46,6 +49,7 @@ CREATE TABLE IF NOT EXISTS prc_quote (
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (quote_id),
UNIQUE KEY uk_quote_key (quote_key),
+ UNIQUE KEY uk_quote_cust_biz (customer_code, product_code, country_code, quote_postcode_key, weight, biz_date),
INDEX idx_quote_customer (customer_id, biz_date),
INDEX idx_quote_product (product_code, country_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='计费试算主表';
diff --git a/bms-front/src/api/price/set.js b/bms-front/src/api/price/set.js
index 3507a847..ff8b31bf 100644
--- a/bms-front/src/api/price/set.js
+++ b/bms-front/src/api/price/set.js
@@ -113,10 +113,11 @@ export function rejectPriceSet(priceSetId) {
})
}
-export function activatePriceSetVersion(psvId) {
+export function activatePriceSetVersion(psvId, data) {
return request({
url: '/price/set/version/activate/' + psvId,
- method: 'put'
+ method: 'put',
+ data: data || {}
})
}
diff --git a/bms-front/src/api/price/status.js b/bms-front/src/api/price/status.js
deleted file mode 100644
index a83a3423..00000000
--- a/bms-front/src/api/price/status.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import request from '@/utils/request'
-
-/**
- * 立即刷新 bms_product / bms_product_country 的 status 字段
- *
- * 日常由后端定时任务 BmsStatusRefreshTask 每天 02:00 自动跑,
- * 此接口用于运维/测试手动触发。返回 success msg 形如:
- * 「状态刷新完成:bms_product 更新 X 行,bms_product_country 更新 Y 行」
- */
-export function refreshStatus() {
- return request({
- url: '/price/status/refresh',
- method: 'post'
- })
-}
\ No newline at end of file
diff --git a/bms-front/src/views/price/basic/product/index.vue b/bms-front/src/views/price/basic/product/index.vue
index 91434007..9ad86242 100644
--- a/bms-front/src/views/price/basic/product/index.vue
+++ b/bms-front/src/views/price/basic/product/index.vue
@@ -8,39 +8,23 @@
title="Excel 导入:相同产品代码+生效日期不覆盖;不同生效日期新增一条产品。同时入库国家×重量段(运费/挂号费/尺寸/尾程/时效)。"
/>
-
-
-
-
-
+
新建产品
修改
-
- 删除
-
导入 Excel
-
- 刷新状态
-
@@ -55,7 +39,6 @@
产品明细
附加费
编辑
- 删除
@@ -78,8 +61,7 @@ import ExcelImportDialog from '@/components/ExcelImportDialog'
import ProductFormDialog from './components/ProductFormDialog.vue'
import ProductCountryDrawer from './components/ProductCountryDrawer.vue'
import ProductSurchargeDrawer from './components/ProductSurchargeDrawer.vue'
-import { listProduct, delProduct } from '@/api/price/product'
-import { refreshStatus } from '@/api/price/status'
+import { listProduct } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const { bms_product_effect_status } = useDict('bms_product_effect_status')
@@ -90,41 +72,6 @@ const countryRef = ref()
const surchargeRef = ref()
const importRef = ref()
-const TAB_ACTIVE = 'active'
-const TAB_PENDING = 'pending'
-const TAB_EXPIRED = 'expired'
-
-const effectTab = ref(TAB_ACTIVE)
-
-const statusTabs = computed(() => [
- { name: TAB_ACTIVE, code: '1', label: dictLabel('1', 'ACTIVE') },
- { name: TAB_PENDING, code: '0', label: dictLabel('0', 'PENDING') },
- { name: TAB_EXPIRED, code: '2', label: dictLabel('2', 'EXPIRED') }
-])
-
-const extraParams = computed(() => ({ status: tabToStatus(effectTab.value) }))
-
-function dictLabel(code, legacy) {
- const list = bms_product_effect_status.value || []
- const hit = list.find(d => String(d.value) === code || String(d.value) === legacy)
- return hit?.label || code
-}
-
-function tabToStatus(name) {
- if (name === TAB_ACTIVE || name === 'ACTIVE' || name === '1') return '1'
- if (name === TAB_PENDING || name === 'PENDING' || name === '0') return '0'
- if (name === TAB_EXPIRED || name === 'EXPIRED' || name === '2') return '2'
- return '1'
-}
-
-function onTabChange(name) {
- effectTab.value = String(name || TAB_ACTIVE)
- nextTick(() => {
- tableRef.value?.clearSelection()
- tableRef.value?.reload()
- })
-}
-
const columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
{ prop: 'productName', label: '产品名称', minWidth: 140, showOverflowTooltip: true },
@@ -138,7 +85,7 @@ const columns = [
{ prop: 'surchargeCount', label: '附加费数', width: 90, align: 'center' },
{ prop: 'status', label: '状态', width: 90, align: 'center', slot: 'status' },
{ prop: 'updateNote', label: '更新内容', minWidth: 160, showOverflowTooltip: true },
- { prop: 'action', label: '操作', width: 300, fixed: 'right', slot: 'action' }
+ { prop: 'action', label: '操作', width: 240, fixed: 'right', slot: 'action' }
]
const searchColumns = [
@@ -155,7 +102,7 @@ const searchColumns = [
]
function queryList(params) {
- return listProduct({ ...params, status: tabToStatus(effectTab.value) })
+ return listProduct(params)
}
function isProductActive(row) {
@@ -174,32 +121,9 @@ function handleUpdate(row) {
formRef.value.open(row)
}
-function handleDelete(ids) {
- const idText = Array.isArray(ids) ? ids.join(',') : ids
- proxy.$modal.confirm('是否确认删除所选产品?国家明细将一并删除。').then(() => {
- return delProduct(idText)
- }).then(() => {
- proxy.$modal.msgSuccess('删除成功')
- tableRef.value.refresh()
- }).catch(() => {})
-}
-
function handleImport() {
importRef.value.open()
}
-
-/**
- * 手动触发 bms_product / bms_product_country 状态刷新
- * 日常由后端定时任务每天 02:00 自动跑;此处用于运维即时刷新
- */
-function handleRefreshStatus() {
- proxy.$modal.confirm('将立即按 effective_date / expire_date 重新计算所有产品及国家明细的状态,是否继续?').then(() => {
- return refreshStatus()
- }).then(res => {
- proxy.$modal.msgSuccess(res.msg || '刷新成功')
- tableRef.value && tableRef.value.refresh()
- }).catch(() => {})
-}
diff --git a/bms-front/src/views/price/set/utils/priceSet.js b/bms-front/src/views/price/set/utils/priceSet.js
index e0e31a7f..90644898 100644
--- a/bms-front/src/views/price/set/utils/priceSet.js
+++ b/bms-front/src/views/price/set/utils/priceSet.js
@@ -40,23 +40,43 @@ const STATUS_TAG = {
/** 默认取值方式 */
export const DEFAULT_ROUND_MODE = 'HALF_UP'
-/** version status: 0 draft / 1 active / 2 expired */
+/** version status: 0 draft / 3 pending / 1 active / 2 expired */
export const VER_STATUS = {
DRAFT: '0',
+ PENDING: '3',
EFFECT: '1',
EXPIRED: '2'
}
export function versionStatusLabel(status) {
- const map = { '0': '草稿', '1': '生效', '2': '失效' }
+ const map = {
+ '0': '草稿',
+ '3': '待生效',
+ '1': '生效',
+ '2': '失效'
+ }
return map[status] || status || '-'
}
export function versionStatusTag(status) {
- const map = { '0': 'warning', '1': 'success', '2': 'info' }
+ const map = { '0': 'warning', '3': 'primary', '1': 'success', '2': 'info' }
return map[status] || 'info'
}
+/** True draft only (not pending) */
+export function isDraftVersion(row) {
+ return !!row && String(row.status) === VER_STATUS.DRAFT
+}
+
+export function isPendingVersion(row) {
+ return !!row && String(row.status) === VER_STATUS.PENDING
+}
+
+/** Draft or pending: may continue editing price details */
+export function isPriceEditableVersion(row) {
+ return isDraftVersion(row) || isPendingVersion(row)
+}
+
export function nextVersionNo(versions) {
const seqs = (versions || []).map(v => Number(v.versionSeq) || 0)
const max = seqs.length ? Math.max.apply(null, seqs) : 0