diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/monitor/TabOrderMonitorController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/monitor/TabOrderMonitorController.java index fa09df9f..35a8c980 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/monitor/TabOrderMonitorController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/monitor/TabOrderMonitorController.java @@ -24,6 +24,7 @@ import com.bms.common.core.page.TableSupport; import com.bms.common.enums.BusinessType; import com.bms.common.utils.StringUtils; import com.bms.price.domain.PrcBillingTaskRetryTask; +import com.bms.price.domain.vo.TabOrderCompareTask; import com.bms.price.domain.vo.TabOrderMonitorQuery; import com.bms.price.domain.vo.TabOrderMonitorVo; import com.bms.price.domain.vo.TabOrderProductCheckResult; @@ -35,6 +36,7 @@ import com.bms.price.search.BillSearchPage; import com.bms.price.search.EsLogType; import com.bms.price.search.IBillSearchStore; import com.bms.price.search.TabOrderSyncEsLog; +import com.bms.price.task.TabOrderCompareAsyncRunner; import com.bms.price.task.TabOrderSyncAsyncRunner; /** @@ -53,6 +55,9 @@ public class TabOrderMonitorController extends BaseController @Autowired private TabOrderSyncAsyncRunner tabOrderSyncAsyncRunner; + @Autowired + private TabOrderCompareAsyncRunner tabOrderCompareAsyncRunner; + @Autowired private IBillSearchStore billSearchStore; @@ -82,6 +87,36 @@ public class TabOrderMonitorController extends BaseController pageSize == null ? 10 : pageSize.intValue()); } + /** + * 比对缺失转单号回填搜索专用:仅按转单号列查询, + * 不走普通列表/三列单号收集路径。 + */ + @PreAuthorize("@ss.hasPermi('monitor:orderSource:list')") + @PostMapping("/listByTransferNos") + public TableDataInfo listByTransferNos(@RequestBody TabOrderMonitorQuery query, + @RequestParam(value = "pageNum", required = false) Integer pageNum, + @RequestParam(value = "pageSize", required = false) Integer pageSize) + { + TableDataInfo rsp = new TableDataInfo(); + rsp.setCode(HttpStatus.SUCCESS); + rsp.setMsg("查询成功"); + if (query == null || !hasNonBlankItem(query.getOrderIdentifiers())) + { + rsp.setRows(Collections.emptyList()); + rsp.setTotal(0L); + return rsp; + } + // clear time so shared resolvers cannot accidentally reuse month window + query.setBeginTime(null); + query.setEndTime(null); + customerKeywordResolver.resolve(query); + TabOrderPageResult page = tabOrderMonitorService.searchPageByTransferNos( + query, pageNum == null ? 1 : pageNum.intValue(), pageSize == null ? 10 : pageSize.intValue()); + rsp.setRows(page.getRows()); + rsp.setTotal(page.getTotal()); + return rsp; + } + private TableDataInfo listPage(TabOrderMonitorQuery query, int pageNum, int pageSize) { TableDataInfo rsp = new TableDataInfo(); @@ -102,6 +137,46 @@ public class TabOrderMonitorController extends BaseController return rsp; } + /** + * 订单比对(异步):按客户+提货时间分页比对转单号(上游有/本地无)。 + */ + @PreAuthorize("@ss.hasPermi('monitor:orderSource:list')") + @Log(title = "order source compare", businessType = BusinessType.OTHER) + @PostMapping("/compareByFilter") + public AjaxResult compareByFilter(@RequestBody TabOrderMonitorQuery query) + { + if (!hasRequiredFilters(query)) + { + return error("订单比对需先选择客户和提货时间"); + } + // 比对不受列表单号/回填影响 + query.setOrderIdentifiers(null); + query.setOrderId(null); + query.setOrgOrderId(null); + query.setTransferOrderId(null); + customerKeywordResolver.resolve(query); + String asyncTaskId = UUID.randomUUID().toString().replace("-", ""); + tabOrderCompareAsyncRunner.put(asyncTaskId, TabOrderCompareTask.STATUS_PENDING, 0, "queued", + 0, 0, 0, null, null); + tabOrderCompareAsyncRunner.run(asyncTaskId, query); + Map data = new HashMap(); + data.put("asyncTaskId", asyncTaskId); + TabOrderSyncEsLog.save(billSearchStore, asyncTaskId, "COMPARE", query, data, "SUCCESS", null); + return success(data); + } + + @PreAuthorize("@ss.hasPermi('monitor:orderSource:list')") + @GetMapping("/compareTask/{asyncTaskId}") + public AjaxResult compareTaskProgress(@PathVariable("asyncTaskId") String asyncTaskId) + { + TabOrderCompareTask task = tabOrderCompareAsyncRunner.get(asyncTaskId); + if (task == null) + { + return error("compare task not found or expired"); + } + return success(task); + } + @PreAuthorize("@ss.hasPermi('monitor:orderSource:list')") @PostMapping("/checkProducts") public AjaxResult checkProducts(@RequestBody TabOrderMonitorQuery query) diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/BillingController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/BillingController.java index a38a1cf9..ead7b505 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/BillingController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/BillingController.java @@ -47,6 +47,7 @@ import com.bms.price.domain.vo.RecalcBillPriceRequest; import com.bms.price.domain.vo.UpdateChargeWeightRequest; import com.bms.price.domain.vo.UpdateIncomeBillFeeRequest; import com.bms.price.domain.vo.TmsPriceBillRequest; +import com.bms.price.domain.vo.OrderBillProductCheckResult; import com.bms.price.domain.vo.TmsPriceQuoteRequest; import com.bms.price.domain.vo.TmsPriceQuoteResult; import com.bms.price.enums.BillTaskSuccessStatus; @@ -54,6 +55,7 @@ import com.bms.price.enums.BillTaskType; import com.bms.price.service.IPrcBillingTaskService; import com.bms.price.service.IPrcOrderBillService; import com.bms.price.service.ITmsPriceQuoteService; +import com.bms.price.service.OrderBillProductCheckService; /** * Billing center: order quote trial (authenticated). @@ -76,6 +78,9 @@ public class BillingController extends BaseController @Autowired private IBmsBizAttachmentService bizAttachmentService; + @Autowired + private OrderBillProductCheckService orderBillProductCheckService; + /** * Historical quote list. */ @@ -173,6 +178,25 @@ public class BillingController extends BaseController return getDataTable(list); } + /** + * Detect package sales products (as of billing end time) missing from product catalog. + */ + @PreAuthorize("@ss.hasPermi('price:orderBill:list')") + @PostMapping("/orderBill/checkProducts") + public AjaxResult checkOrderBillProducts(@RequestBody PrcOrderBill query) + { + OrderBillProductCheckResult data = orderBillProductCheckService.check(query); + return success(data); + } + + /** Drill-down order bills for one product under the same check filters. */ + @PreAuthorize("@ss.hasPermi('price:orderBill:list')") + @PostMapping("/orderBill/checkProducts/orders") + public AjaxResult listOrderBillsByProduct(@RequestBody PrcOrderBill query) + { + return success(orderBillProductCheckService.listOrdersByProduct(query)); + } + /** * Order no options for remote-search dropdown (bill list multi search). */ diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/OrderBillProductCheckResult.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/OrderBillProductCheckResult.java new file mode 100644 index 00000000..c2c6f903 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/OrderBillProductCheckResult.java @@ -0,0 +1,37 @@ +package com.bms.price.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * Order-bill product check: customer package (sales) products vs product catalog. + */ +@Data +public class OrderBillProductCheckResult implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** Orders matched by search filters (customer + billing time). */ + private long orderTotal; + private int packageProductCount; + private int matchedCount; + private int missingCount; + private String asOfTime; + private List packageProducts = new ArrayList(); + private List missingProducts = new ArrayList(); + + @Data + public static class ProductRow implements Serializable + { + private static final long serialVersionUID = 1L; + private String customerCode; + private String productCode; + private String productName; + /** Whether product exists in catalog (effective at asOf). */ + private boolean inLibrary; + /** Order count in search billing window for this product. */ + private int orderCount; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/TabOrderCompareTask.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/TabOrderCompareTask.java new file mode 100644 index 00000000..20c6f4ee --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/TabOrderCompareTask.java @@ -0,0 +1,56 @@ +package com.bms.price.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 订单比对异步任务进度(Redis)。 + * ok=本地已有;fail/缺失=上游有而本地无(仅转单号)。 + */ +public class TabOrderCompareTask implements Serializable +{ + private static final long serialVersionUID = 1L; + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_RUNNING = "RUNNING"; + public static final String STATUS_SUCCESS = "SUCCESS"; + public static final String STATUS_FAIL = "FAIL"; + + private String taskId; + private String status; + private int percent; + private String message; + /** 已扫描上游转单号数 */ + private int scanned; + /** 本地已存在 */ + private int matched; + /** 本地缺失 */ + private int missing; + private String error; + /** 缺失转单号样本(有上限) */ + private List missingTransferNos = new ArrayList(); + + public String getTaskId() { return taskId; } + public void setTaskId(String taskId) { this.taskId = taskId; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public int getPercent() { return percent; } + public void setPercent(int percent) { this.percent = percent; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public int getScanned() { return scanned; } + public void setScanned(int scanned) { this.scanned = scanned; } + public int getMatched() { return matched; } + public void setMatched(int matched) { this.matched = matched; } + public int getMissing() { return missing; } + public void setMissing(int missing) { this.missing = missing; } + public String getError() { return error; } + public void setError(String error) { this.error = error; } + public List getMissingTransferNos() { return missingTransferNos; } + public void setMissingTransferNos(List missingTransferNos) + { + this.missingTransferNos = missingTransferNos == null + ? new ArrayList() : missingTransferNos; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/OracleUpstreamOrderMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/OracleUpstreamOrderMapper.java index a145f934..a44aef51 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/OracleUpstreamOrderMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/OracleUpstreamOrderMapper.java @@ -29,6 +29,15 @@ public interface OracleUpstreamOrderMapper @Param("startRow") long startRow, @Param("endRow") long endRow); + /** + * 订单比对专用(异步分页):仅返回 TRANSFER_ORDER_ID。 + */ + List> selectTabOrderCompareKeyPage( + @Param("orderTable") String orderTable, + @Param("query") TabOrderMonitorQuery query, + @Param("startRow") long startRow, + @Param("endRow") long endRow); + /** List detail by ids: display columns only. */ List> selectTabOrderMonitorListByIds( @Param("orderTable") String orderTable, @@ -54,6 +63,16 @@ public interface OracleUpstreamOrderMapper @Param("query") TabOrderMonitorQuery query, @Param("batch") List batch); + /** + * 比对回填转单号专用:仅按 TRANSFER_ORDER_ID IN 分批收集, + * 不走提货时间,与 selectMatchedOrderKeys 完全隔离。 + */ + List> selectMatchedTransferOrderKeys( + @Param("orderTable") String orderTable, + @Param("batch") List batch, + @Param("customerCodes") List customerCodes); + + /** 按已收集到的订单主键批量查明细(一批为一个内存分页切片)。 */ List> selectTabOrderMonitorByIds( @Param("orderTable") String orderTable, diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java index a0082249..8952ffa3 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java @@ -3,6 +3,7 @@ package com.bms.price.mapper; import java.math.BigDecimal; import java.util.Date; import java.util.List; +import java.util.Map; import org.apache.ibatis.annotations.Param; import com.bms.price.domain.PrcOrderBill; @@ -20,6 +21,22 @@ public interface PrcOrderBillMapper public List selectPrcOrderBillList(PrcOrderBill bill); + /** Distinct customer+product groups for product-check (order counts in filter window). */ + List> selectDistinctProductGroups(PrcOrderBill bill); + + /** Drill-down orders for one product under product-check filters. */ + List selectOrderBillsByProductForCheck(@Param("query") PrcOrderBill query, @Param("limit") int limit); + + /** + * 订单比对:批量查本地已存在的单号(order_no / tracking_no / transfer_no)。 + */ + List selectExistingOrderIdentifiers(@Param("identifiers") List identifiers, + @Param("customerCodes") List customerCodes); + + /** 订单比对:仅按转单号批量查本地已存在。 */ + List selectExistingTransferNos(@Param("transferNos") List transferNos, + @Param("customerCodes") List customerCodes); + /** 订单号下拉搜索(带数据权限) */ public List selectOrderNoOptions(@Param("query") PrcOrderBill query, @Param("limit") int limit); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/oracle/OracleOrderKeyCollectWorker.java b/bms-back/bms-system/src/main/java/com/bms/price/oracle/OracleOrderKeyCollectWorker.java index 8cb86f53..80346f6c 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/oracle/OracleOrderKeyCollectWorker.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/oracle/OracleOrderKeyCollectWorker.java @@ -25,4 +25,11 @@ public class OracleOrderKeyCollectWorker { return oracleUpstreamOrderMapper.selectMatchedOrderKeys(table, query, batch); } + + /** 比对回填转单号专用分批(不走三列 UNION)。 */ + @DataSource(DataSourceType.SLAVE) + public List> collectTransferBatch(String table, List batch, List customerCodes) + { + return oracleUpstreamOrderMapper.selectMatchedTransferOrderKeys(table, batch, customerCodes); + } } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/oracle/TabOrderMonitorService.java b/bms-back/bms-system/src/main/java/com/bms/price/oracle/TabOrderMonitorService.java index a9cd8c5e..20d6f5cd 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/oracle/TabOrderMonitorService.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/oracle/TabOrderMonitorService.java @@ -62,10 +62,11 @@ public class TabOrderMonitorService private static final int COLLECT_CACHE_MAX_ENTRIES = 20; /** - * 客户+时间列表:一次拉取的主键上限。一次取齐后内存分页,总数稳定; - * 超过此数量仅展示前 N 条(避免 COUNT / 翻页估总数导致「共 101→201」)。 + * 客户+时间列表:一次 ROWNUM 拉主键后内存分页(总数稳定)。 + * 上限 8000:覆盖常见月窗口,又避免分批深翻 ROWNUM 把查询拖死。 + * 全量差异请用「订单比对」异步任务。 */ - private static final int FILTER_LIST_KEY_LIMIT = 2000; + private static final int FILTER_LIST_KEY_LIMIT = 8000; /** 单批 Oracle 查询最长等待(从库 socketTimeout 为 120s,留同样余量)。 */ private static final long COLLECT_BATCH_TIMEOUT_SECONDS = 120L; @@ -121,6 +122,19 @@ public class TabOrderMonitorService } }); + /** 客户+时间精确总数缓存(翻页复用,避免每页重复 COUNT)。 */ + private final Map filterCountCache = + Collections.synchronizedMap(new LinkedHashMap(16, 0.75f, true) + { + private static final long serialVersionUID = 1L; + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) + { + return size() > COLLECT_CACHE_MAX_ENTRIES; + } + }); + private static final class CacheEntry { private final List keys; @@ -145,6 +159,18 @@ public class TabOrderMonitorService } } + private static final class FilterCountCacheEntry + { + private final long total; + private final long expireAt; + + private FilterCountCacheEntry(long total, long expireAt) + { + this.total = total; + this.expireAt = expireAt; + } + } + @PreDestroy public void shutdownCollectPool() { @@ -261,6 +287,72 @@ public class TabOrderMonitorService } } + /** + * 订单比对异步分页:专用 SQL 仅返回当前页转单号。 + */ + @DataSource(DataSourceType.SLAVE) + public List selectCompareTransferPage(TabOrderMonitorQuery query, int pageNum, int pageSize) + { + // 比对与回填列表完全隔离:先清单号再 validate,仅走客户+提货时间 + if (query != null) + { + query.setOrderIdentifiers(null); + query.setOrderId(null); + query.setOrgOrderId(null); + query.setTransferOrderId(null); + } + validate(query); + List codes = query.getCustomerCodes(); + boolean hasCustomer = codes != null && !codes.isEmpty(); + boolean hasTime = StringUtils.isNotEmpty(query.getBeginTime()) && StringUtils.isNotEmpty(query.getEndTime()); + if (!hasCustomer || !hasTime) + { + throw new ServiceException("订单比对需先选择客户和提货时间"); + } + int pn = pageNum < 1 ? 1 : pageNum; + int ps = pageSize < 1 ? 100 : pageSize; + if (ps > 1000) + { + ps = 1000; + } + long endRow = (long) pn * ps; + long startRow = endRow - ps; + String table = resolveTable(); + try + { + PageHelper.clearPage(); + List> rows = + oracleUpstreamOrderMapper.selectTabOrderCompareKeyPage(table, query, startRow, endRow); + if (rows == null || rows.isEmpty()) + { + return Collections.emptyList(); + } + List out = new ArrayList(rows.size()); + for (Map row : rows) + { + Object v = value(row, "TRANSFER_ORDER_ID"); + if (v == null) + { + continue; + } + String tn = String.valueOf(v).trim(); + if (!tn.isEmpty()) + { + out.add(tn); + } + } + return out; + } + catch (RuntimeException e) + { + throw new ServiceException("Oracle TAB_ORDER compare page failed: " + e.getMessage()); + } + finally + { + PageHelper.clearPage(); + } + } + /** * 列表查询统一入口:一次数据源调用内返回总数 + 当前页, * 大批量单号场景只做一次分批收集,避免 count 和 page 重复扫描。 @@ -279,15 +371,67 @@ public class TabOrderMonitorService List keys = doCollectMatchedKeys(query, table); List rows = doPageByKeys(table, keys, pn, ps); log.info("oracle tab_order identifier page identifiers={} matched={} page={}/{} rows={} costMs={}", - Integer.valueOf(query.getOrderIdentifiers().size()), Integer.valueOf(keys.size()), + Integer.valueOf(query.getOrderIdentifiers().size()), + Integer.valueOf(keys.size()), Integer.valueOf(pn), Integer.valueOf(ps), Integer.valueOf(rows.size()), Long.valueOf(System.currentTimeMillis() - t0)); return new TabOrderPageResult(keys.size(), rows); } - // Customer+time: collect keys once (capped), memory-page; stable total (no 101->201). + // Customer+time: exact COUNT(1) + current-page keys. return doSelectFilterListPage(table, query, pn, ps); } + /** + * 比对缺失转单号回填搜索专用入口: + * 仅按 TRANSFER_ORDER_ID 分批收集,不带提货时间, + * 与列表/同步/比对共用 searchPage 完全隔离。 + */ + @DataSource(DataSourceType.SLAVE) + public TabOrderPageResult searchPageByTransferNos(TabOrderMonitorQuery query, int pageNum, int pageSize) + { + if (query == null || query.getOrderIdentifiers() == null || query.getOrderIdentifiers().isEmpty()) + { + throw new ServiceException("转单号不能为空"); + } + List nos = normalizeLineList(query.getOrderIdentifiers()); + if (nos == null || nos.isEmpty()) + { + throw new ServiceException("转单号不能为空"); + } + if (nos.size() > IDENTIFIER_HARD_LIMIT) + { + throw new ServiceException("单号数量超过服务端上限 " + IDENTIFIER_HARD_LIMIT + + ",请分批查询,避免内存溢出"); + } + // optional customer filter only; never use pickup time on this path + List codes = query.getCustomerCodes(); + List cleanedCodes = new ArrayList(); + if (codes != null) + { + for (String c : codes) + { + if (StringUtils.isNotEmpty(c)) + { + cleanedCodes.add(c.trim()); + } + } + } + List customerCodes = cleanedCodes.isEmpty() ? null : cleanedCodes; + int[] page = normalizePage(pageNum, pageSize); + int pn = page[0]; + int ps = page[1]; + String table = resolveTable(); + long t0 = System.currentTimeMillis(); + List keys = doCollectTransferKeys(table, nos, customerCodes); + List rows = doPageByKeys(table, keys, pn, ps); + log.info("oracle tab_order transfer-fill page transferNos={} matched={} page={}/{} rows={} costMs={}", + Integer.valueOf(nos.size()), Integer.valueOf(keys.size()), + Integer.valueOf(pn), Integer.valueOf(ps), Integer.valueOf(rows.size()), + Long.valueOf(System.currentTimeMillis() - t0)); + return new TabOrderPageResult(keys.size(), rows); + } + + @DataSource(DataSourceType.SLAVE) public List selectPage(TabOrderMonitorQuery query, int pageNum, int pageSize) { @@ -360,34 +504,67 @@ public class TabOrderMonitorService } /** - * Customer+time list: pull up to {@link #FILTER_LIST_KEY_LIMIT} keys once (cached), - * then memory-page and load display rows by ID. Total stays stable across pages. + * Customer+time list: exact COUNT(1) for total + key-page for current rows. + * Total uses countTabOrderMonitor (same filters as Oct-13 style COUNT). */ private TabOrderPageResult doSelectFilterListPage(String table, TabOrderMonitorQuery query, int pn, int ps) { long t0 = System.currentTimeMillis(); - List allIds = doCollectFilterKeyIds(table, query); - int total = allIds.size(); - int from = (pn - 1) * ps; - if (from >= total) + long total = countFilterTotal(table, query); + long startRow = (long) (pn - 1) * ps; + long endRow = startRow + ps; + if (startRow >= total) { log.info("oracle tab_order filter-list customerSize={} begin={} end={} page={}/{} rows=0 total={} costMs={}", query.getCustomerCodes() == null ? 0 : query.getCustomerCodes().size(), - query.getBeginTime(), query.getEndTime(), pn, ps, Integer.valueOf(total), + query.getBeginTime(), query.getEndTime(), pn, ps, Long.valueOf(total), Long.valueOf(System.currentTimeMillis() - t0)); return new TabOrderPageResult(total, Collections.emptyList()); } - List slice = allIds.subList(from, Math.min(from + ps, total)); - List list = loadListVosByIds(table, new ArrayList(slice)); + List ids = selectKeyIds(table, query, startRow, endRow); + List list = loadListVosByIds(table, ids); log.info("oracle tab_order filter-list customerSize={} begin={} end={} page={}/{} rows={} total={} costMs={}", query.getCustomerCodes() == null ? 0 : query.getCustomerCodes().size(), query.getBeginTime(), query.getEndTime(), pn, ps, - Integer.valueOf(list.size()), Integer.valueOf(total), + Integer.valueOf(list.size()), Long.valueOf(total), Long.valueOf(System.currentTimeMillis() - t0)); return new TabOrderPageResult(total, list); } - /** Collect (and cache) filter-path primary keys, newest first, capped at FILTER_LIST_KEY_LIMIT. */ + /** Exact total for customer+time list; short TTL cache to avoid recount on every page flip. */ + private long countFilterTotal(String table, TabOrderMonitorQuery query) + { + String fingerprint = "count|" + collectFingerprint(table, query); + FilterCountCacheEntry cached = filterCountCache.get(fingerprint); + long now = System.currentTimeMillis(); + if (cached != null && cached.expireAt > now) + { + return cached.total; + } + try + { + PageHelper.clearPage(); + long t0 = System.currentTimeMillis(); + long total = oracleUpstreamOrderMapper.countTabOrderMonitor(table, query); + log.info("oracle tab_order count customerSize={} begin={} end={} total={} costMs={}", + query.getCustomerCodes() == null ? 0 : query.getCustomerCodes().size(), + query.getBeginTime(), query.getEndTime(), + Long.valueOf(total), Long.valueOf(System.currentTimeMillis() - t0)); + filterCountCache.put(fingerprint, + new FilterCountCacheEntry(total, now + COLLECT_CACHE_TTL_MS)); + return total; + } + catch (RuntimeException e) + { + throw new ServiceException("Oracle TAB_ORDER count failed: " + e.getMessage()); + } + finally + { + PageHelper.clearPage(); + } + } + + /** Collect (and cache) filter-path primary keys, newest first; hard-capped at FILTER_LIST_KEY_LIMIT. */ private List doCollectFilterKeyIds(String table, TabOrderMonitorQuery query) { String fingerprint = "filter|" + collectFingerprint(table, query); @@ -397,11 +574,11 @@ public class TabOrderMonitorService { return new ArrayList(cached.ids); } - // Fetch limit+1 to detect cap; keep at most FILTER_LIST_KEY_LIMIT. + // One Oracle key-page (ROWNUM <= limit+1). Avoid deep multi-page ROWNUM which hangs month windows. List ids = selectKeyIds(table, query, 0L, FILTER_LIST_KEY_LIMIT + 1L); if (ids.size() > FILTER_LIST_KEY_LIMIT) { - log.warn("oracle tab_order filter-list capped at {} keys (customer+time window too large); narrow pickup range", + log.warn("oracle tab_order filter-list capped at {} keys (customer+time window too large); use order compare for full diff", Integer.valueOf(FILTER_LIST_KEY_LIMIT)); ids = new ArrayList(ids.subList(0, FILTER_LIST_KEY_LIMIT)); } @@ -575,6 +752,118 @@ public class TabOrderMonitorService * 查 Oracle(每批 UNION ALL 三列各走索引),Java 侧按 ID 去重, * 再按 PICKUP_DATE DESC, ID DESC 排序。相同查询条件短期缓存,翻页/重复查询秒回。 */ + /** + * 回填转单号专用收集:只走 selectMatchedTransferOrderKeys, + * 不走三列 UNION、不带提货时间。 + */ + private List doCollectTransferKeys(String table, List transferNos, List customerCodes) + { + StringBuilder fp = new StringBuilder("tf|").append(table == null ? "" : table).append('|'); + appendSorted(fp, transferNos); + fp.append('|'); + appendSorted(fp, customerCodes); + String fingerprint = Integer.toHexString(fp.toString().hashCode()); + long now = System.currentTimeMillis(); + CacheEntry cached = collectCache.get(fingerprint); + if (cached != null && now < cached.expireAt) + { + log.info("oracle tab_order transfer-fill cache-hit transferNos={} matched={}", + Integer.valueOf(transferNos.size()), Integer.valueOf(cached.keys.size())); + return new ArrayList(cached.keys); + } + + List> batches = new ArrayList>(); + for (int bi = 0; bi < transferNos.size(); bi += IDENTIFIER_BATCH_SIZE) + { + batches.add(transferNos.subList(bi, Math.min(bi + IDENTIFIER_BATCH_SIZE, transferNos.size()))); + } + + List>>> futures = + new ArrayList>>>(batches.size()); + for (List batch : batches) + { + futures.add(collectPool.submit(() -> collectWorker.collectTransferBatch(table, batch, customerCodes))); + } + + Map unique = new LinkedHashMap(); + long t0 = System.currentTimeMillis(); + try + { + for (Future>> future : futures) + { + List> rows; + try + { + rows = future.get(COLLECT_BATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + catch (ExecutionException e) + { + Throwable cause = e.getCause() == null ? e : e.getCause(); + throw new ServiceException("Oracle transfer-fill collect failed: " + cause.getMessage()); + } + catch (TimeoutException e) + { + future.cancel(true); + throw new ServiceException("Oracle transfer-fill collect timeout"); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + throw new ServiceException("Oracle transfer-fill collect interrupted"); + } + if (rows == null) + { + continue; + } + for (Map row : rows) + { + Object idVal = value(row, "ID"); + if (idVal == null) + { + continue; + } + String id = String.valueOf(idVal).trim(); + if (id.isEmpty()) + { + continue; + } + Object dateVal = value(row, "PICKUP_DATE"); + Timestamp pickup = dateVal instanceof Timestamp ? (Timestamp) dateVal : null; + if (!unique.containsKey(id) || (unique.get(id) == null && pickup != null)) + { + unique.put(id, pickup); + } + } + } + } + finally + { + for (Future>> f : futures) + { + if (!f.isDone()) + { + f.cancel(true); + } + } + PageHelper.clearPage(); + } + List keys = new ArrayList(unique.size()); + for (Map.Entry e : unique.entrySet()) + { + keys.add(new MatchedOrderKey(e.getKey(), e.getValue())); + } + keys.sort(Comparator + .comparing(MatchedOrderKey::getPickupDate, + Comparator.nullsFirst(Comparator.reverseOrder())) + .thenComparing(MatchedOrderKey::getId, Comparator.reverseOrder())); + log.info("oracle tab_order transfer-fill collect transferNos={} batches={} matched={} costMs={}", + Integer.valueOf(transferNos.size()), Integer.valueOf(batches.size()), + Integer.valueOf(keys.size()), Long.valueOf(System.currentTimeMillis() - t0)); + collectCache.put(fingerprint, new CacheEntry(Collections.unmodifiableList(keys), + System.currentTimeMillis() + COLLECT_CACHE_TTL_MS)); + return keys; + } + private List doCollectMatchedKeys(TabOrderMonitorQuery query, String table) { List identifiers = query.getOrderIdentifiers(); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/OrderBillProductCheckService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/OrderBillProductCheckService.java new file mode 100644 index 00000000..ae007701 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/OrderBillProductCheckService.java @@ -0,0 +1,317 @@ +package com.bms.price.service; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.bms.common.annotation.DataScope; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.StringUtils; +import com.bms.price.domain.BmsProduct; +import com.bms.price.domain.PrcOrderBill; +import com.bms.price.domain.PrcPriceSetProduct; +import com.bms.price.domain.vo.OrderBillProductCheckResult; +import com.bms.price.domain.vo.OrderBillProductCheckResult.ProductRow; +import com.bms.price.mapper.BmsProductMapper; +import com.bms.price.mapper.PrcOrderBillMapper; + +/** + * Detect package sales products (as of billing end time) that are missing from product catalog. + */ +@Service +public class OrderBillProductCheckService +{ + private static final int ORDER_DRILL_LIMIT = 500; + + @Autowired + private ITmsPriceQuoteService tmsPriceQuoteService; + + @Autowired + private BmsProductMapper productMapper; + + @Autowired + private PrcOrderBillMapper orderBillMapper; + + @DataScope(deptAlias = "su", deptField = "dept_id", userAlias = "su", userField = "user_id", + permission = "price:orderBill:list") + public OrderBillProductCheckResult check(PrcOrderBill query) + { + validate(query); + Date asOf = resolveAsOf(query); + OrderBillProductCheckResult result = new OrderBillProductCheckResult(); + result.setAsOfTime(formatAsOf(asOf)); + + List customerCodes = normalizeCodes(query.getCustomerCodes()); + Map orderCountByKey = loadOrderCounts(query); + long orderTotal = 0L; + for (Integer c : orderCountByKey.values()) + { + if (c != null) + { + orderTotal += c.intValue(); + } + } + result.setOrderTotal(orderTotal); + + List packageRows = new ArrayList(); + List missingRows = new ArrayList(); + int matched = 0; + + for (String customerCode : customerCodes) + { + List products = tmsPriceQuoteService.listAllBoundPackageProducts(customerCode, asOf); + Set seen = new HashSet(); + if (products == null) + { + continue; + } + for (PrcPriceSetProduct p : products) + { + if (p == null || StringUtils.isEmpty(p.getProductCode())) + { + continue; + } + String code = p.getProductCode().trim(); + String upper = code.toUpperCase(Locale.ROOT); + if (!seen.add(upper)) + { + continue; + } + boolean inLibrary = existsInLibrary(code, asOf); + ProductRow row = new ProductRow(); + row.setCustomerCode(customerCode); + row.setProductCode(code); + row.setProductName(trim(p.getProductName())); + row.setInLibrary(inLibrary); + Integer oc = orderCountByKey.get(key(customerCode, upper)); + row.setOrderCount(oc == null ? 0 : oc.intValue()); + packageRows.add(row); + if (inLibrary) + { + matched++; + } + else + { + missingRows.add(row); + } + } + } + + packageRows.sort(Comparator + .comparing(ProductRow::getCustomerCode, nullSafe()) + .thenComparing(ProductRow::getProductCode, nullSafe())); + missingRows.sort(Comparator + .comparing(ProductRow::getCustomerCode, nullSafe()) + .thenComparing(ProductRow::getProductCode, nullSafe())); + + result.setPackageProductCount(packageRows.size()); + result.setMatchedCount(matched); + result.setMissingCount(missingRows.size()); + result.setPackageProducts(packageRows); + result.setMissingProducts(missingRows); + return result; + } + + @DataScope(deptAlias = "su", deptField = "dept_id", userAlias = "su", userField = "user_id", + permission = "price:orderBill:list") + public List listOrdersByProduct(PrcOrderBill query) + { + validate(query); + if (StringUtils.isEmpty(query.getProductCode())) + { + throw new ServiceException("productCode required"); + } + query.setProductCode(query.getProductCode().trim()); + List rows = orderBillMapper.selectOrderBillsByProductForCheck(query, ORDER_DRILL_LIMIT); + return rows == null ? new ArrayList() : rows; + } + + private Map loadOrderCounts(PrcOrderBill query) + { + List> groups = orderBillMapper.selectDistinctProductGroups(query); + Map map = new HashMap(); + if (groups == null) + { + return map; + } + for (Map g : groups) + { + String customerCode = trim(str(g, "customer_code")); + String productCode = trim(str(g, "product_code")); + if (StringUtils.isEmpty(customerCode) || StringUtils.isEmpty(productCode)) + { + continue; + } + map.put(key(customerCode, productCode.toUpperCase(Locale.ROOT)), + Integer.valueOf((int) Math.min(Integer.MAX_VALUE, longValue(g, "order_count")))); + } + return map; + } + + private boolean existsInLibrary(String productCode, Date asOf) + { + BmsProduct hit = productMapper.selectEffectiveBmsProductByCode(productCode, asOf); + return hit != null && StringUtils.isNotEmpty(hit.getProductCode()); + } + + private void validate(PrcOrderBill query) + { + if (query == null) + { + throw new ServiceException("customer and billing time required"); + } + List codes = normalizeCodes(query.getCustomerCodes()); + if (codes.isEmpty()) + { + throw new ServiceException("please select customer"); + } + query.setCustomerCodes(codes); + Map params = query.getParams(); + String begin = params == null ? null : strObj(params.get("beginBizDate")); + String end = params == null ? null : strObj(params.get("endBizDate")); + if (StringUtils.isEmpty(begin) || StringUtils.isEmpty(end)) + { + throw new ServiceException("please select billing time range"); + } + } + + private static List normalizeCodes(List codes) + { + if (codes == null || codes.isEmpty()) + { + return new ArrayList(); + } + LinkedHashSet set = new LinkedHashSet(); + for (String c : codes) + { + if (StringUtils.isNotEmpty(c)) + { + set.add(c.trim()); + } + } + return new ArrayList(set); + } + + private static Date resolveAsOf(PrcOrderBill query) + { + Map params = query.getParams(); + String raw = params == null ? null : strObj(params.get("endBizDate")); + if (StringUtils.isEmpty(raw) && params != null) + { + raw = strObj(params.get("beginBizDate")); + } + Date parsed = parseDateTime(raw); + return parsed != null ? parsed : new Date(); + } + + private static Date parseDateTime(String raw) + { + if (StringUtils.isEmpty(raw)) + { + return null; + } + String s = raw.trim().replace('T', ' '); + try + { + if (s.length() >= 19) + { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(s.substring(0, 19)); + } + if (s.length() >= 10) + { + return new SimpleDateFormat("yyyy-MM-dd").parse(s.substring(0, 10)); + } + } + catch (ParseException ignore) + { + return null; + } + return null; + } + + private static String formatAsOf(Date asOf) + { + if (asOf == null) + { + return null; + } + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(asOf); + } + + private static String key(String customerCode, String productUpper) + { + return customerCode + "|" + productUpper; + } + + private static String trim(String s) + { + return StringUtils.isEmpty(s) ? null : s.trim(); + } + + private static String strObj(Object v) + { + return v == null ? null : String.valueOf(v).trim(); + } + + private static Comparator nullSafe() + { + return Comparator.nullsFirst(String.CASE_INSENSITIVE_ORDER); + } + + private static String str(Map row, String key) + { + Object v = value(row, key); + return v == null ? null : String.valueOf(v).trim(); + } + + private static long longValue(Map row, String key) + { + Object v = value(row, key); + if (v == null) + { + return 0L; + } + if (v instanceof Number) + { + return ((Number) v).longValue(); + } + try + { + return Long.parseLong(String.valueOf(v).trim()); + } + catch (Exception e) + { + return 0L; + } + } + + private static Object value(Map row, String key) + { + if (row == null || key == null) + { + return null; + } + if (row.containsKey(key)) + { + return row.get(key); + } + for (Map.Entry e : row.entrySet()) + { + if (e.getKey() != null && e.getKey().equalsIgnoreCase(key)) + { + return e.getValue(); + } + } + return null; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/task/TabOrderCompareAsyncRunner.java b/bms-back/bms-system/src/main/java/com/bms/price/task/TabOrderCompareAsyncRunner.java new file mode 100644 index 00000000..da55bf78 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/task/TabOrderCompareAsyncRunner.java @@ -0,0 +1,211 @@ +package com.bms.price.task; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import com.bms.common.core.redis.RedisCache; +import com.bms.common.utils.StringUtils; +import com.bms.price.domain.vo.TabOrderCompareTask; +import com.bms.price.domain.vo.TabOrderMonitorQuery; +import com.bms.price.mapper.PrcOrderBillMapper; +import com.bms.price.oracle.TabOrderMonitorService; +import com.bms.price.search.IBillSearchStore; +import com.bms.price.search.TabOrderSyncEsLog; + +/** + * 订单比对异步任务:分页拉上游转单号,与本地 transfer_no 比对差异。 + */ +@Component +public class TabOrderCompareAsyncRunner +{ + private static final Logger log = LoggerFactory.getLogger(TabOrderCompareAsyncRunner.class); + + public static final String CACHE_KEY_PREFIX = "billing:orderSource:compare:"; + public static final int TTL_MINUTES = 120; + public static final int PAGE_SIZE = 500; + /** 单次任务最多扫描上游转单号数(防崩) */ + public static final int SCAN_HARD_LIMIT = 50000; + /** 进度/抽屉中保留的缺失转单号上限 */ + public static final int MISSING_SAMPLE_LIMIT = 5000; + + @Autowired + private TabOrderMonitorService tabOrderMonitorService; + + @Autowired + private PrcOrderBillMapper prcOrderBillMapper; + + @Autowired + private RedisCache redisCache; + + @Autowired + private IBillSearchStore billSearchStore; + + @Async("threadPoolTaskExecutor") + public void run(String asyncTaskId, TabOrderMonitorQuery query) + { + try + { + if (query != null) + { + query.setOrderIdentifiers(null); + query.setOrderId(null); + query.setOrgOrderId(null); + query.setTransferOrderId(null); + } + put(asyncTaskId, TabOrderCompareTask.STATUS_RUNNING, 0, "starting", 0, 0, 0, null, null); + int scanned = 0; + int matched = 0; + int missing = 0; + List missingSample = new ArrayList(); + int pageNum = 1; + while (true) + { + if (scanned >= SCAN_HARD_LIMIT) + { + log.warn("orderSource compare capped at {} asyncTaskId={}", Integer.valueOf(SCAN_HARD_LIMIT), asyncTaskId); + break; + } + int remain = SCAN_HARD_LIMIT - scanned; + int pageSize = Math.min(PAGE_SIZE, remain); + List transferNos = tabOrderMonitorService.selectCompareTransferPage(query, pageNum, pageSize); + if (transferNos == null || transferNos.isEmpty()) + { + break; + } + Set existing = findExisting(transferNos, query == null ? null : query.getCustomerCodes()); + for (String tn : transferNos) + { + if (StringUtils.isEmpty(tn)) + { + continue; + } + scanned++; + String key = tn.trim().toUpperCase(Locale.ROOT); + if (existing.contains(key)) + { + matched++; + } + else + { + missing++; + if (missingSample.size() < MISSING_SAMPLE_LIMIT) + { + missingSample.add(tn.trim()); + } + } + } + int percent = Math.min(99, (int) ((scanned * 100L) / Math.max(scanned + pageSize, 1))); + // 最后一页不满页时近乎完成;否则按已扫描量给一个缓慢增长的百分比 + if (transferNos.size() < pageSize) + { + percent = 99; + } + put(asyncTaskId, TabOrderCompareTask.STATUS_RUNNING, percent, + "scanning page=" + pageNum + " scanned=" + scanned + " missing=" + missing, + scanned, matched, missing, null, missingSample); + if (transferNos.size() < pageSize) + { + break; + } + pageNum++; + } + Map resp = new LinkedHashMap(); + resp.put("asyncTaskId", asyncTaskId); + resp.put("scanned", Integer.valueOf(scanned)); + resp.put("matched", Integer.valueOf(matched)); + resp.put("missing", Integer.valueOf(missing)); + resp.put("missingTransferNos", missingSample); + resp.put("missingTruncated", Boolean.valueOf(missing > missingSample.size())); + put(asyncTaskId, TabOrderCompareTask.STATUS_SUCCESS, 100, + "done scanned=" + scanned + " missing=" + missing, + scanned, matched, missing, null, missingSample); + TabOrderSyncEsLog.save(billSearchStore, asyncTaskId, "COMPARE_DONE", query, resp, "SUCCESS", null); + log.info("orderSource compare done asyncTaskId={} scanned={} matched={} missing={}", + asyncTaskId, Integer.valueOf(scanned), Integer.valueOf(matched), Integer.valueOf(missing)); + } + catch (Exception e) + { + log.error("orderSource compare failed asyncTaskId={}", asyncTaskId, e); + String msg = e.getMessage(); + if (StringUtils.isEmpty(msg)) + { + msg = e.getClass().getSimpleName(); + } + put(asyncTaskId, TabOrderCompareTask.STATUS_FAIL, 100, "failed", 0, 0, 0, msg, null); + TabOrderSyncEsLog.save(billSearchStore, asyncTaskId, "COMPARE_DONE", query, null, "FAIL", msg); + } + } + + private Set findExisting(List transferNos, List customerCodes) + { + Set found = new HashSet(); + if (transferNos == null || transferNos.isEmpty()) + { + return found; + } + List hit = prcOrderBillMapper.selectExistingTransferNos(transferNos, cleanCodes(customerCodes)); + if (hit == null) + { + return found; + } + for (String s : hit) + { + if (StringUtils.isNotEmpty(s)) + { + found.add(s.trim().toUpperCase(Locale.ROOT)); + } + } + return found; + } + + private static List cleanCodes(List codes) + { + if (codes == null || codes.isEmpty()) + { + return null; + } + List out = new ArrayList(); + for (String c : codes) + { + if (StringUtils.isNotEmpty(c)) + { + out.add(c.trim()); + } + } + return out.isEmpty() ? null : out; + } + + public void put(String taskId, String status, int percent, String message, + int scanned, int matched, int missing, String error, List missingSample) + { + TabOrderCompareTask task = new TabOrderCompareTask(); + task.setTaskId(taskId); + task.setStatus(status); + task.setPercent(percent); + task.setMessage(message); + task.setScanned(scanned); + task.setMatched(matched); + task.setMissing(missing); + task.setError(error); + if (missingSample != null) + { + task.setMissingTransferNos(new ArrayList(missingSample)); + } + redisCache.setCacheObject(CACHE_KEY_PREFIX + taskId, task, TTL_MINUTES, TimeUnit.MINUTES); + } + + public TabOrderCompareTask get(String taskId) + { + return redisCache.getCacheObject(CACHE_KEY_PREFIX + taskId); + } +} diff --git a/bms-back/bms-system/src/main/resources/mapper/price/OracleUpstreamOrderMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/OracleUpstreamOrderMapper.xml index e403e0ce..6d1d79b3 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/OracleUpstreamOrderMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/OracleUpstreamOrderMapper.xml @@ -154,6 +154,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" WHERE rn > #{startRow} + + + - + + + + + SELECT DISTINCT p.transfer_no + FROM prc_order_bill p + WHERE p.transfer_no IN + + #{tn} + + + AND p.customer_code IN + + #{ccode} + + + AND p.transfer_no IS NOT NULL AND p.transfer_no != '' + + + + + + + + + AND p.customer_code IN + + #{ccode} + + + + AND p.biz_date >= #{params.beginBizDate} + + + AND p.biz_date <= #{params.endBizDate} + + + AND p.pickup_date >= #{params.beginPickupDate} + + + AND p.pickup_date <= #{params.endPickupDate} + + + AND p.order_status IN + + #{os} + + + + AND p.billing_fail_status IN + + #{bfs} + + + + AND ( + p.order_no IN + + #{idn} + + OR p.tracking_no IN + + #{idn} + + OR p.transfer_no IN + + #{idn} + + ) + + + + + + + +