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 ad05c55f..bf019a08 100644 --- a/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties +++ b/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties @@ -101,6 +101,9 @@ price.product.import.needCodeEffUnique=无法新增「同代码、不同生效 price.product.import.success=导入成功:新增 {0} 个产品,跳过 {1} 个(同代码同生效日不覆盖);写入国家/重量段明细 {2} 行。 price.product.import.success.detail1=产品主数据按「产品代码+生效日期」新增,不覆盖已有记录。国家明细:新生效日晚于上次则保留历史,上次失效日=本次生效日。 price.product.import.success.detail2=范围:产品目录 + 重量段 + 运费/挂号费 + 尺寸/尾程/时效;国家码来自 bms_region。 +price.product.import.noSheet=未找到产品明细sheet(需与产品名称一致):{0} +price.product.import.noCountry=产品明细sheet无有效国家/重量段数据:{0} +price.product.import.euEmpty=国家表无欧盟成员(is_eu=1),无法展开「欧盟」 price.product.import.success.unmatched=未匹配国家表(country_code 为空):{0} # 报价试算 TmsPriceQuote(对外 TMS API 错误码,保持英文契约) price.quote.priceNotHit=没有产品/国家/重量段绑定,暂无价格 diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/BmsProductServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/BmsProductServiceImpl.java index fbd2c490..a8b3b55b 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/BmsProductServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/BmsProductServiceImpl.java @@ -23,7 +23,11 @@ import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.WorkbookFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.MessageUtils; @@ -73,6 +77,9 @@ public class BmsProductServiceImpl implements IBmsProductService @Autowired private IPrcSurchargeRelService surchargeRelService; + @Autowired + private PlatformTransactionManager transactionManager; + @Override public BmsProduct selectBmsProductById(Long productId) { @@ -207,7 +214,6 @@ public class BmsProductServiceImpl implements IBmsProductService } @Override - @Transactional(rollbackFor = Exception.class) public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception { if (file == null || file.isEmpty()) @@ -234,12 +240,14 @@ public class BmsProductServiceImpl implements IBmsProductService { throw new ServiceException(MessageUtils.message("price.product.import.noData")); } + products = dedupeCatalogProducts(products); Map regionByNameCn = loadRegionByNameCn(); if (regionByNameCn.isEmpty()) { throw new ServiceException(MessageUtils.message("price.product.import.regionEmpty")); } + List euRegions = loadEuRegions(regionByNameCn); int successNum = 0; int skipNum = 0; @@ -249,61 +257,35 @@ public class BmsProductServiceImpl implements IBmsProductService for (BmsProduct product : products) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + TransactionStatus tx = transactionManager.getTransaction(def); try { - product.setSourceSheet(product.getProductName()); - product.setCreateBy(operName); - product.setUpdateBy(operName); - - List countries = parseProductSheet(workbook, product, regionByNameCn, unmatchedCountries); - // Sheet header date is the price version; catalog column is often stale - Date sheetEff = resolveImportEffectiveDate(countries); - Date newEff = toDateOnly(sheetEff != null ? sheetEff : product.getEffectiveDate()); - product.setEffectiveDate(newEff); - if (newEff == null) - { - failureMsg.append("
").append(product.getProductCode()).append("\uff1a") - .append(MessageUtils.message("price.product.import.effEmpty")); - continue; - } - BmsProduct existing = productMapper.selectBmsProductByCodeAndDate( - product.getProductCode(), newEff); - - if (existing != null) - { - skipNum++; - } - else - { - applyProductStatus(product); - try - { - productMapper.insertBmsProduct(product); - } - catch (Exception e) - { - if (isProductCodeUniqueViolation(e)) - { - throw new ServiceException(MessageUtils.message("price.product.import.needCodeEffUnique")); - } - throw e; - } - productMapper.expireOpenByProductCode(product.getProductCode(), newEff, product.getProductId()); - productMapper.refreshStatusByDates(); - countryNum += saveCountriesVersioned(product, countries); - successNum++; - } + int[] nums = importOneProduct(workbook, product, operName, regionByNameCn, euRegions, + unmatchedCountries); + transactionManager.commit(tx); + successNum += nums[0]; + skipNum += nums[1]; + countryNum += nums[2]; } catch (Exception e) { - failureMsg.append("
").append(product.getProductCode()).append("\uff1a").append(e.getMessage()); + transactionManager.rollback(tx); + String tip = e.getMessage(); + if (StringUtils.isEmpty(tip)) + { + tip = e.getClass().getSimpleName(); + } + failureMsg.append("
").append(product.getProductCode()).append("\uff1a").append(tip); } } if (failureMsg.length() > 0) { - throw new ServiceException(MessageUtils.message("price.product.import.partialFail", - successNum, skipNum, failureMsg.toString())); + // Do not throw: each product already committed/rolled back independently + return MessageUtils.message("price.product.import.partialFail", + Integer.valueOf(successNum), Integer.valueOf(skipNum), failureMsg.toString()); } String msg = MessageUtils.message("price.product.import.success", successNum, skipNum, countryNum) + "
" + MessageUtils.message("price.product.import.success.detail1") @@ -316,10 +298,68 @@ public class BmsProductServiceImpl implements IBmsProductService } } + /** @return int[]{added, skipped, countryRows} */ + private int[] importOneProduct(Workbook workbook, BmsProduct product, String operName, + Map regionByNameCn, List euRegions, Set unmatchedCountries) + { + product.setSourceSheet(product.getProductName()); + product.setCreateBy(operName); + product.setUpdateBy(operName); + + Sheet productSheet = findProductSheet(workbook, product); + if (productSheet == null) + { + throw new ServiceException(MessageUtils.message("price.product.import.noSheet", + product.getProductName())); + } + product.setSourceSheet(productSheet.getSheetName()); + List countries = parseProductSheet(productSheet, product, + regionByNameCn, euRegions, unmatchedCountries); + if (countries == null || countries.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.noCountry", + productSheet.getSheetName())); + } + Date sheetEff = resolveImportEffectiveDate(countries); + Date newEff = toDateOnly(sheetEff != null ? sheetEff : product.getEffectiveDate()); + product.setEffectiveDate(newEff); + if (newEff == null) + { + throw new ServiceException(MessageUtils.message("price.product.import.effEmpty")); + } + BmsProduct existing = productMapper.selectBmsProductByCodeAndDate( + product.getProductCode(), newEff); + + if (existing != null) + { + product.setProductId(existing.getProductId()); + int countryRows = saveCountriesVersioned(product, countries); + return new int[] { 0, 1, countryRows }; + } + + applyProductStatus(product); + try + { + productMapper.insertBmsProduct(product); + } + catch (Exception e) + { + if (isProductCodeUniqueViolation(e)) + { + throw new ServiceException(MessageUtils.message("price.product.import.needCodeEffUnique")); + } + throw e; + } + productMapper.expireOpenByProductCode(product.getProductCode(), newEff, product.getProductId()); + productMapper.refreshStatusByDates(); + int countryRows = saveCountriesVersioned(product, countries); + return new int[] { 1, 0, countryRows }; + } + /** * Versioned country save: * - newEff > last open: close old (expire_date = newEff), insert new (keep history) - * - newEff equals last open: skip (never overwrite) + * - newEff equals last open: replace open rows * - newEff < last open: reject */ private int saveCountriesVersioned(BmsProduct product, List countries) @@ -347,8 +387,8 @@ public class BmsProductServiceImpl implements IBmsProductService } else if (lastOpenEff != null) { - // Same effective day: never overwrite existing country rows - return 0; + // Same effective day: replace open rows so re-import can fill/fix details + countryMapper.deleteOpenByProductCodeAndEffectiveDate(productCode, newEff); } for (BmsProductCountry c : countries) @@ -543,27 +583,141 @@ public class BmsProductServiceImpl implements IBmsProductService return list; } - private List parseProductSheet(Workbook workbook, BmsProduct product, - Map regionByNameCn, Set unmatchedCountries) + /** Keep first row per product_code (catalog often repeats code across country rows). */ + private List dedupeCatalogProducts(List products) { - String sheetName = product.getProductName(); - Sheet sheet = workbook.getSheet(sheetName); - if (sheet == null) + Map map = new HashMap<>(); + for (BmsProduct p : products) { - for (int i = 0; i < workbook.getNumberOfSheets(); i++) + if (p == null || StringUtils.isEmpty(p.getProductCode())) { - String name = workbook.getSheetName(i); - if (SKIP_SHEETS.contains(name)) - { - continue; - } - if (name != null && name.trim().equals(sheetName.trim())) - { - sheet = workbook.getSheetAt(i); - break; - } + continue; + } + String code = p.getProductCode().trim().toUpperCase(Locale.ROOT); + if (!map.containsKey(code)) + { + map.put(code, p); } } + return new ArrayList<>(map.values()); + } + + private Sheet findProductSheet(Workbook workbook, BmsProduct product) + { + if (workbook == null || product == null) + { + return null; + } + String wantName = product.getProductName(); + String wantCode = product.getProductCode(); + if (StringUtils.isNotEmpty(wantName)) + { + Sheet exact = workbook.getSheet(wantName.trim()); + if (exact != null) + { + return exact; + } + } + String nameKey = normalizeSheetKey(wantName); + String codeKey = normalizeSheetKey(wantCode); + Sheet best = null; + int bestScore = 0; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String name = workbook.getSheetName(i); + if (name == null || SKIP_SHEETS.contains(name)) + { + continue; + } + String key = normalizeSheetKey(name); + if (StringUtils.isEmpty(key)) + { + continue; + } + int score = 0; + if (StringUtils.isNotEmpty(nameKey) && key.equals(nameKey)) + { + return workbook.getSheetAt(i); + } + if (StringUtils.isNotEmpty(nameKey) && key.contains(nameKey)) + { + score = 100; + } + else if (StringUtils.isNotEmpty(nameKey) && nameKey.contains(key) && key.length() >= 4) + { + score = 80; + } + else if (StringUtils.isNotEmpty(codeKey) && key.contains(codeKey)) + { + score = 60; + } + if (score > bestScore) + { + bestScore = score; + best = workbook.getSheetAt(i); + } + } + return bestScore >= 60 ? best : null; + } + + private static String normalizeSheetKey(String s) + { + if (s == null) + { + return ""; + } + String out = s.trim().toLowerCase(Locale.ROOT); + // unify dash variants: - \u2013 \u2014 \uff0d + out = out.replace('\u2013', '-').replace('\u2014', '-').replace('\uff0d', '-'); + out = out.replace(" ", "").replace("\u3000", ""); + return out; + } + + private List loadEuRegions(Map regionByNameCn) + { + List list = new ArrayList<>(); + if (regionByNameCn == null) + { + return list; + } + Set seen = new HashSet<>(); + for (BmsRegion region : regionByNameCn.values()) + { + if (region == null || !"1".equals(region.getIsEu())) + { + continue; + } + if (StringUtils.isEmpty(region.getCountryNameCn()) || StringUtils.isEmpty(region.getCountryCode2())) + { + continue; + } + String code = region.getCountryCode2().toUpperCase(Locale.ROOT); + if (seen.contains(code)) + { + continue; + } + seen.add(code); + list.add(region); + } + return list; + } + + private static boolean isEuZoneName(String name) + { + if (StringUtils.isEmpty(name)) + { + return false; + } + String n = name.trim(); + return "\u6b27\u76df".equals(n) + || "EU".equalsIgnoreCase(n) + || "\u6b27\u6d32\u8054\u76df".equals(n) + || "\u6b27\u76df\u56fd\u5bb6".equals(n); + } + + private List parseProductSheet(Sheet sheet, BmsProduct product, + Map regionByNameCn, List euRegions, Set unmatchedCountries) + { if (sheet == null) { return new ArrayList<>(); @@ -631,7 +785,7 @@ public class BmsProductServiceImpl implements IBmsProductService } if (StringUtils.isNotEmpty(countryCell)) { - currentCountry = countryCell.trim(); + currentCountry = normalizeCountryName(countryCell); currentSize = emptyToNull(cellString(row.getCell(colSize))); currentLastMile = emptyToNull(cellString(row.getCell(colLastMile))); currentLeadTime = emptyToNull(cellString(row.getCell(colLeadTime))); @@ -645,32 +799,54 @@ public class BmsProductServiceImpl implements IBmsProductService { continue; } + BigDecimal freight = cellDecimal(row.getCell(colFreight)); + BigDecimal registration = cellDecimal(row.getCell(colRegister)); + Date eff = sheetEffective != null ? sheetEffective : product.getEffectiveDate(); + BmsRegion region = resolveRegion(currentCountry, regionByNameCn); - BmsProductCountry c = new BmsProductCountry(); if (region != null) { - c.setCountryName(region.getCountryNameCn()); - c.setCountryCode(region.getCountryCode2()); + list.add(buildCountryRow(region.getCountryNameCn(), region.getCountryCode2(), + range, freight, registration, currentSize, currentLastMile, currentLeadTime, eff)); + continue; } - else + if (isEuZoneName(currentCountry)) { - c.setCountryName(currentCountry); - c.setCountryCode(null); - unmatchedCountries.add(currentCountry); + if (euRegions == null || euRegions.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.euEmpty")); + } + for (BmsRegion eu : euRegions) + { + list.add(buildCountryRow(eu.getCountryNameCn(), eu.getCountryCode2(), + range, freight, registration, currentSize, currentLastMile, currentLeadTime, eff)); + } + continue; } - c.setWeightMin(range[0]); - c.setWeightMax(range[1]); - c.setFreightPrice(cellDecimal(row.getCell(colFreight))); - c.setRegistrationFee(cellDecimal(row.getCell(colRegister))); - c.setSizeLimit(currentSize); - c.setLastMile(currentLastMile); - c.setLeadTime(currentLeadTime); - c.setEffectiveDate(sheetEffective != null ? sheetEffective : product.getEffectiveDate()); - list.add(c); + unmatchedCountries.add(currentCountry); + list.add(buildCountryRow(currentCountry, null, + range, freight, registration, currentSize, currentLastMile, currentLeadTime, eff)); } return list; } + private BmsProductCountry buildCountryRow(String countryName, String countryCode, BigDecimal[] range, + BigDecimal freight, BigDecimal registration, String sizeLimit, String lastMile, String leadTime, Date eff) + { + BmsProductCountry c = new BmsProductCountry(); + c.setCountryName(countryName); + c.setCountryCode(countryCode); + c.setWeightMin(range[0]); + c.setWeightMax(range[1]); + c.setFreightPrice(freight); + c.setRegistrationFee(registration); + c.setSizeLimit(sizeLimit); + c.setLastMile(lastMile); + c.setLeadTime(leadTime); + c.setEffectiveDate(eff); + return c; + } + /** * Load bms_region map: country_name_cn -> region (for country_code_2). */ @@ -723,11 +899,48 @@ public class BmsProductServiceImpl implements IBmsProductService candidates.add(region); } } + if (candidates.isEmpty()) + { + return null; + } if (candidates.size() == 1) { return candidates.get(0); } - return null; + // Prefer longest Chinese name match (e.g. 捷克共和国 over 捷克) + BmsRegion best = candidates.get(0); + for (int i = 1; i < candidates.size(); i++) + { + String cn = candidates.get(i).getCountryNameCn(); + if (cn != null && cn.length() > best.getCountryNameCn().length()) + { + best = candidates.get(i); + } + } + return best; + } + + /** Strip Excel footnotes like "澳大利亚(1区)\\n分区点击" */ + private static String normalizeCountryName(String name) + { + if (name == null) + { + return null; + } + String n = name.trim(); + int cut = n.length(); + int nl = n.indexOf('\n'); + if (nl >= 0 && nl < cut) + { + cut = nl; + } + int cr = n.indexOf('\r'); + if (cr >= 0 && cr < cut) + { + cut = cr; + } + n = n.substring(0, cut).trim(); + return n.isEmpty() ? null : n; } private BigDecimal[] parseWeightRange(String label) 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 9a653e1b..b363d397 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 @@ -747,7 +747,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService private static String formatWeightLabel(BigDecimal min, BigDecimal max) { - return stripTrailingZeros(min) + "\u2264W\u2264" + stripTrailingZeros(max); + return stripTrailingZeros(min) + " import SimpleTable from '@/components/SimpleTable' import { listProductCountries } from '@/api/price/product' +import { formatWeightLabel } from '@/views/price/set/utils/priceSet' const columns = [ { prop: 'countryName', label: '国家', width: 90, showOverflowTooltip: true }, @@ -32,9 +33,11 @@ const columns = [ { prop: 'weightMin', label: '重量段(KG)', - width: 120, + width: 130, align: 'center', - formatter: (row) => (row.weightMin != null || row.weightMax != null ? (row.weightMin + ' ~ ' + row.weightMax) : '-') + formatter: (row) => (row.weightMin != null || row.weightMax != null + ? formatWeightLabel(row.weightMin, row.weightMax) + : '-') }, { prop: 'freightPrice', label: '运费(RMB/KG)', width: 110, align: 'right' }, { prop: 'registrationFee', label: '挂号费(RMB/票)', width: 120, align: 'right' }, diff --git a/bms-front/src/views/price/billing/quote.vue b/bms-front/src/views/price/billing/quote.vue index 220dcbb7..9e38ff88 100644 --- a/bms-front/src/views/price/billing/quote.vue +++ b/bms-front/src/views/price/billing/quote.vue @@ -294,7 +294,7 @@ const rules = { } function loadProducts() { - listProduct({ pageNum: 1, pageSize: 500, status: '0' }).then(res => { + listProduct({ pageNum: 1, pageSize: 500, status: '1' }).then(res => { productOptions.value = res.rows || [] }) } diff --git a/bms-front/src/views/price/set/composables/usePriceSetRefData.js b/bms-front/src/views/price/set/composables/usePriceSetRefData.js index f7485fbf..ee1a8886 100644 --- a/bms-front/src/views/price/set/composables/usePriceSetRefData.js +++ b/bms-front/src/views/price/set/composables/usePriceSetRefData.js @@ -30,9 +30,9 @@ export function usePriceSetRefData() { const productMap = computed(() => new Map(productOptions.value.map(p => [p.value, p]))) - /** 加载可选产品(在售) */ + /** 加载可选产品(已生效) */ function loadProducts() { - return listProduct({ status: '0', pageNum: 1, pageSize: 1000 }).then(res => { + return listProduct({ status: '1', pageNum: 1, pageSize: 1000 }).then(res => { productOptions.value = (res.rows || []).map(p => ({ value: p.productCode, label: `${p.productCode} ${p.productName || ''}`.trim(), @@ -162,7 +162,7 @@ export function useDetailCascade(refData) { if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee) } - /** 回显历史明细行:补齐字段,并把旧重量段文案归一化为 min≤W≤max */ + /** hydrate detail row; normalize weight label to min