修改套餐导入功能
This commit is contained in:
+11
-4
@@ -244,9 +244,13 @@ public class PriceSetController extends BaseController
|
|||||||
public AjaxResult importDetailPrices(MultipartFile file,
|
public AjaxResult importDetailPrices(MultipartFile file,
|
||||||
@RequestParam("priceSetId") Long priceSetId,
|
@RequestParam("priceSetId") Long priceSetId,
|
||||||
@RequestParam(value = "psvId", required = false) Long psvId,
|
@RequestParam(value = "psvId", required = false) Long psvId,
|
||||||
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly) throws Exception
|
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly,
|
||||||
|
@RequestParam(value = "replaceByProductCountry", required = false, defaultValue = "false") boolean replaceByProductCountry,
|
||||||
|
@RequestParam(value = "countryCode", required = false) String countryCode)
|
||||||
|
throws Exception
|
||||||
{
|
{
|
||||||
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, file, getUsername(), updateOnly);
|
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, file, getUsername(), updateOnly,
|
||||||
|
replaceByProductCountry, countryCode);
|
||||||
Map<String, Object> payload = new HashMap<String, Object>();
|
Map<String, Object> payload = new HashMap<String, Object>();
|
||||||
payload.put("taskId", taskId);
|
payload.put("taskId", taskId);
|
||||||
return success(payload);
|
return success(payload);
|
||||||
@@ -263,12 +267,15 @@ public class PriceSetController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preview Excel import (no DB write). Optional detailsJson for updateOnly merge.
|
* Preview Excel import (no DB write). Optional detailsJson for updateOnly / build-price merge.
|
||||||
|
* Optional countryCode = single-country dedicated-line Excel (same as product country import).
|
||||||
*/
|
*/
|
||||||
@PreAuthorize("@ss.hasPermi('price:set:edit') or @ss.hasPermi('price:set:forceEdit') or @ss.hasPermi('price:set:add')")
|
@PreAuthorize("@ss.hasPermi('price:set:edit') or @ss.hasPermi('price:set:forceEdit') or @ss.hasPermi('price:set:add')")
|
||||||
@PostMapping("/detail/previewImportPrices")
|
@PostMapping("/detail/previewImportPrices")
|
||||||
public AjaxResult previewImportPrices(MultipartFile file,
|
public AjaxResult previewImportPrices(MultipartFile file,
|
||||||
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly,
|
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly,
|
||||||
|
@RequestParam(value = "replaceByProductCountry", required = false, defaultValue = "false") boolean replaceByProductCountry,
|
||||||
|
@RequestParam(value = "countryCode", required = false) String countryCode,
|
||||||
@RequestParam(value = "detailsJson", required = false) String detailsJson) throws Exception
|
@RequestParam(value = "detailsJson", required = false) String detailsJson) throws Exception
|
||||||
{
|
{
|
||||||
if (file == null || file.isEmpty())
|
if (file == null || file.isEmpty())
|
||||||
@@ -281,7 +288,7 @@ public class PriceSetController extends BaseController
|
|||||||
existing = com.alibaba.fastjson2.JSON.parseArray(detailsJson, PrcPriceSetDetail.class);
|
existing = com.alibaba.fastjson2.JSON.parseArray(detailsJson, PrcPriceSetDetail.class);
|
||||||
}
|
}
|
||||||
return success(priceSetService.previewImportDetailPrices(
|
return success(priceSetService.previewImportDetailPrices(
|
||||||
file.getBytes(), file.getOriginalFilename(), updateOnly, existing));
|
file.getBytes(), file.getOriginalFilename(), updateOnly, existing, replaceByProductCountry, countryCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class BmsProductCountry implements Serializable
|
|||||||
/** FK to product.product_id */
|
/** FK to product.product_id */
|
||||||
private Long productId;
|
private Long productId;
|
||||||
private String productCode;
|
private String productCode;
|
||||||
|
/** Parse/import helper only (not a DB column). */
|
||||||
|
private String productName;
|
||||||
private String countryName;
|
private String countryName;
|
||||||
private String countryCode;
|
private String countryCode;
|
||||||
/** prc_zone.zone_id; null when product country has no zone */
|
/** prc_zone.zone_id; null when product country has no zone */
|
||||||
|
|||||||
@@ -54,4 +54,11 @@ public interface IBmsProductService
|
|||||||
public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception;
|
public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception;
|
||||||
|
|
||||||
public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception;
|
public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse single-country dedicated-line quote Excel (same format as product country import).
|
||||||
|
* All weight rows are stamped with the selected country. Keys are upper-case product codes.
|
||||||
|
*/
|
||||||
|
public java.util.Map<String, List<BmsProductCountry>> parseCountryQuoteExcelByProduct(byte[] bytes, String filename,
|
||||||
|
String countryCode) throws Exception;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ public interface IPrcPriceSetService
|
|||||||
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName,
|
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName,
|
||||||
boolean updateOnly) throws Exception;
|
boolean updateOnly) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param replaceByProductCountry true = Excel build-price mode: replace only product+country scopes in this file
|
||||||
|
* @param countryCode optional; when set, parse single-country dedicated-line Excel (product country-import format)
|
||||||
|
*/
|
||||||
|
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName,
|
||||||
|
boolean updateOnly, boolean replaceByProductCountry, String countryCode) throws Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync worker: parse bytes and upsert FIXED detail prices matched by
|
* Sync worker: parse bytes and upsert FIXED detail prices matched by
|
||||||
* product + country + zone(if any) + weight band (insert when missing unless updateOnly).
|
* product + country + zone(if any) + weight band (insert when missing unless updateOnly).
|
||||||
@@ -133,6 +140,13 @@ public interface IPrcPriceSetService
|
|||||||
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
||||||
String taskId, boolean updateOnly) throws Exception;
|
String taskId, boolean updateOnly) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param replaceByProductCountry package Excel build-price only; other import modes pass false
|
||||||
|
* @param countryCode optional single-country dedicated-line import
|
||||||
|
*/
|
||||||
|
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
||||||
|
String taskId, boolean updateOnly, boolean replaceByProductCountry, String countryCode) throws Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit async pull of product library into version details.
|
* Submit async pull of product library into version details.
|
||||||
* @return taskId for polling getSaveTask
|
* @return taskId for polling getSaveTask
|
||||||
@@ -153,4 +167,11 @@ public interface IPrcPriceSetService
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
|
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
|
||||||
List<PrcPriceSetDetail> existingDetails) throws Exception;
|
List<PrcPriceSetDetail> existingDetails) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param replaceByProductCountry package Excel build-price only: keep other product/country rows
|
||||||
|
* @param countryCode optional single-country dedicated-line import
|
||||||
|
*/
|
||||||
|
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
|
||||||
|
List<PrcPriceSetDetail> existingDetails, boolean replaceByProductCountry, String countryCode) throws Exception;
|
||||||
}
|
}
|
||||||
|
|||||||
+61
@@ -628,6 +628,67 @@ public class BmsProductServiceImpl implements IBmsProductService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, List<BmsProductCountry>> parseCountryQuoteExcelByProduct(byte[] bytes, String filename,
|
||||||
|
String countryCode) throws Exception
|
||||||
|
{
|
||||||
|
validateImportBytes(bytes, filename);
|
||||||
|
if (StringUtils.isEmpty(countryCode))
|
||||||
|
{
|
||||||
|
throw new ServiceException(MessageUtils.message("price.product.import.countryRequired"));
|
||||||
|
}
|
||||||
|
BmsRegion region = regionMapper.selectRegionByCode2(countryCode.trim().toUpperCase(Locale.ROOT));
|
||||||
|
if (region == null || "2".equals(region.getDelFlag())
|
||||||
|
|| (StringUtils.isNotEmpty(region.getStatus()) && !"0".equals(region.getStatus())))
|
||||||
|
{
|
||||||
|
throw new ServiceException(MessageUtils.message("price.product.import.countryNotFound", countryCode));
|
||||||
|
}
|
||||||
|
try (InputStream is = new java.io.ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is))
|
||||||
|
{
|
||||||
|
Sheet catalog = workbook.getSheet(CATALOG_SHEET);
|
||||||
|
if (catalog == null)
|
||||||
|
{
|
||||||
|
throw new ServiceException(MessageUtils.message("price.product.import.countrySheetNotFound"));
|
||||||
|
}
|
||||||
|
List<BmsProduct> products = dedupeCatalogProducts(parseCountryCatalog(catalog));
|
||||||
|
if (products.isEmpty())
|
||||||
|
{
|
||||||
|
throw new ServiceException(MessageUtils.message("price.product.import.noData"));
|
||||||
|
}
|
||||||
|
Map<String, PriceBlock> priceBlocks = parseCountryPriceBlocks(workbook);
|
||||||
|
Map<String, List<BmsProductCountry>> out = new LinkedHashMap<String, List<BmsProductCountry>>();
|
||||||
|
for (BmsProduct product : products)
|
||||||
|
{
|
||||||
|
if (product == null || StringUtils.isEmpty(product.getProductCode()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PriceBlock block = findPriceBlock(product.getProductName(), priceBlocks);
|
||||||
|
if (block == null || block.rows == null || block.rows.isEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<BmsProductCountry> countries = stampSelectedCountry(block.rows, region, null);
|
||||||
|
for (BmsProductCountry c : countries)
|
||||||
|
{
|
||||||
|
if (c == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
c.setProductCode(product.getProductCode());
|
||||||
|
c.setProductName(product.getProductName());
|
||||||
|
}
|
||||||
|
assertNoDuplicateCountryWeightRows(product, countries);
|
||||||
|
out.put(product.getProductCode().trim().toUpperCase(Locale.ROOT), countries);
|
||||||
|
}
|
||||||
|
if (out.isEmpty())
|
||||||
|
{
|
||||||
|
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** @return int[]{added, skipped, countryRows} */
|
/** @return int[]{added, skipped, countryRows} */
|
||||||
private int[] importOneProduct(Workbook workbook, BmsProduct product, String operName,
|
private int[] importOneProduct(Workbook workbook, BmsProduct product, String operName,
|
||||||
|
|||||||
+251
-43
@@ -929,6 +929,13 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
@Override
|
@Override
|
||||||
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName,
|
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName,
|
||||||
boolean updateOnly) throws Exception
|
boolean updateOnly) throws Exception
|
||||||
|
{
|
||||||
|
return submitImportDetailPricesAsync(priceSetId, psvId, file, operName, updateOnly, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName,
|
||||||
|
boolean updateOnly, boolean replaceByProductCountry, String countryCode) throws Exception
|
||||||
{
|
{
|
||||||
PrcPriceSet db = requireSet(priceSetId);
|
PrcPriceSet db = requireSet(priceSetId);
|
||||||
assertChildEditable(db);
|
assertChildEditable(db);
|
||||||
@@ -953,7 +960,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
}
|
}
|
||||||
String taskId = IdUtils.fastSimpleUUID();
|
String taskId = IdUtils.fastSimpleUUID();
|
||||||
saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_PENDING, 0, "queued", priceSetId, null);
|
saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_PENDING, 0, "queued", priceSetId, null);
|
||||||
saveAsyncRunner.runImportPrices(taskId, priceSetId, psvId, bytes, filename, operName, updateOnly);
|
saveAsyncRunner.runImportPrices(taskId, priceSetId, psvId, bytes, filename, operName, updateOnly,
|
||||||
|
replaceByProductCountry, countryCode);
|
||||||
return taskId;
|
return taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,14 +1044,23 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
|
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
|
||||||
List<PrcPriceSetDetail> existingDetails) throws Exception
|
List<PrcPriceSetDetail> existingDetails) throws Exception
|
||||||
{
|
{
|
||||||
Map<String, List<BmsProductCountry>> byProduct = productService.parseQuoteExcelByProduct(bytes, filename);
|
return previewImportDetailPrices(bytes, filename, updateOnly, existingDetails, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
|
||||||
|
List<PrcPriceSetDetail> existingDetails, boolean replaceByProductCountry, String countryCode) throws Exception
|
||||||
|
{
|
||||||
|
boolean replaceScope = replaceByProductCountry || StringUtils.isNotEmpty(countryCode);
|
||||||
|
Map<String, List<BmsProductCountry>> byProduct = parseSetImportExcel(bytes, filename, countryCode);
|
||||||
if (byProduct == null || byProduct.isEmpty())
|
if (byProduct == null || byProduct.isEmpty())
|
||||||
{
|
{
|
||||||
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
|
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<PrcPriceSetDetail> details = new ArrayList<PrcPriceSetDetail>();
|
List<PrcPriceSetDetail> details = new ArrayList<PrcPriceSetDetail>();
|
||||||
if (existingDetails != null)
|
// Keep prior draft rows only for updateOnly or Excel build-price merge.
|
||||||
|
if (existingDetails != null && (updateOnly || replaceScope))
|
||||||
{
|
{
|
||||||
for (PrcPriceSetDetail d : existingDetails)
|
for (PrcPriceSetDetail d : existingDetails)
|
||||||
{
|
{
|
||||||
@@ -1053,27 +1070,17 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Map<String, List<PrcPriceSetDetail>> detailIndex = new HashMap<String, List<PrcPriceSetDetail>>();
|
Map<String, List<PrcPriceSetDetail>> detailIndex = buildImportDetailIndex(details);
|
||||||
for (PrcPriceSetDetail d : details)
|
|
||||||
{
|
|
||||||
if (d == null || StringUtils.isEmpty(d.getProductCode()))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String pc = d.getProductCode().trim().toUpperCase(Locale.ROOT);
|
|
||||||
List<PrcPriceSetDetail> list = detailIndex.get(pc);
|
|
||||||
if (list == null)
|
|
||||||
{
|
|
||||||
list = new ArrayList<PrcPriceSetDetail>();
|
|
||||||
detailIndex.put(pc, list);
|
|
||||||
}
|
|
||||||
list.add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateOnly)
|
if (updateOnly)
|
||||||
{
|
{
|
||||||
assertUpdateOnlyWeightBandsMatch(byProduct, detailIndex);
|
assertUpdateOnlyWeightBandsMatch(byProduct, detailIndex);
|
||||||
}
|
}
|
||||||
|
else if (replaceScope && !details.isEmpty())
|
||||||
|
{
|
||||||
|
// Package Excel build-price: replace only product+country scopes in this file.
|
||||||
|
removeDetailsByReplaceScopes(details, detailIndex, collectImportReplaceScopes(byProduct));
|
||||||
|
}
|
||||||
String operName = null;
|
String operName = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1088,6 +1095,16 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
Long pefId = excelFile != null ? excelFile.getPefId() : null;
|
Long pefId = excelFile != null ? excelFile.getPefId() : null;
|
||||||
|
|
||||||
Map<String, PrcPriceSetProduct> productMap = new LinkedHashMap<String, PrcPriceSetProduct>();
|
Map<String, PrcPriceSetProduct> productMap = new LinkedHashMap<String, PrcPriceSetProduct>();
|
||||||
|
if (replaceScope)
|
||||||
|
{
|
||||||
|
for (PrcPriceSetDetail d : details)
|
||||||
|
{
|
||||||
|
if (d != null)
|
||||||
|
{
|
||||||
|
ensurePreviewProduct(productMap, d.getProductCode(), d.getProductName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
int updated = 0;
|
int updated = 0;
|
||||||
int inserted = 0;
|
int inserted = 0;
|
||||||
int skippedFee = 0;
|
int skippedFee = 0;
|
||||||
@@ -1097,21 +1114,26 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
for (Map.Entry<String, List<BmsProductCountry>> e : byProduct.entrySet())
|
for (Map.Entry<String, List<BmsProductCountry>> e : byProduct.entrySet())
|
||||||
{
|
{
|
||||||
String productCode = e.getKey();
|
String productCode = e.getKey();
|
||||||
List<PrcPriceSetDetail> candidates = detailIndex.get(productCode);
|
String pcKey = productCode == null ? null : productCode.trim().toUpperCase(Locale.ROOT);
|
||||||
|
List<PrcPriceSetDetail> candidates = detailIndex.get(pcKey);
|
||||||
if (candidates == null)
|
if (candidates == null)
|
||||||
{
|
{
|
||||||
candidates = new ArrayList<PrcPriceSetDetail>();
|
candidates = new ArrayList<PrcPriceSetDetail>();
|
||||||
detailIndex.put(productCode, candidates);
|
if (pcKey != null)
|
||||||
|
{
|
||||||
|
detailIndex.put(pcKey, candidates);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (updateOnly && candidates.isEmpty())
|
if (updateOnly && candidates.isEmpty())
|
||||||
{
|
{
|
||||||
skippedUnmatched += e.getValue() == null ? 0 : e.getValue().size();
|
skippedUnmatched += e.getValue() == null ? 0 : e.getValue().size();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String productName = resolveImportProductName(null, productCode, candidates);
|
String productName = resolveImportProductNameFromRows(e.getValue(), null, productCode, candidates);
|
||||||
if (!updateOnly)
|
if (!updateOnly)
|
||||||
{
|
{
|
||||||
ensurePreviewProduct(productMap, productCode, productName);
|
ensurePreviewProduct(productMap, productCode, productName);
|
||||||
|
stampPreviewProductDestRegion(productMap, productCode, e.getValue());
|
||||||
}
|
}
|
||||||
for (BmsProductCountry row : e.getValue())
|
for (BmsProductCountry row : e.getValue())
|
||||||
{
|
{
|
||||||
@@ -1189,14 +1211,29 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
|
|
||||||
if (pefId != null)
|
if (pefId != null)
|
||||||
{
|
{
|
||||||
for (PrcPriceSetProduct sp : productMap.values())
|
for (String code : byProduct.keySet())
|
||||||
{
|
{
|
||||||
|
if (StringUtils.isEmpty(code))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PrcPriceSetProduct sp = productMap.get(code.trim().toUpperCase(Locale.ROOT));
|
||||||
if (sp != null)
|
if (sp != null)
|
||||||
{
|
{
|
||||||
sp.setPefId(pefId);
|
sp.setPefId(pefId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (replaceScope)
|
||||||
|
{
|
||||||
|
for (PrcPriceSetDetail d : details)
|
||||||
|
{
|
||||||
|
if (d != null)
|
||||||
|
{
|
||||||
|
ensurePreviewProduct(productMap, d.getProductCode(), d.getProductName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Map<String, Object> result = new HashMap<String, Object>();
|
Map<String, Object> result = new HashMap<String, Object>();
|
||||||
result.put("products", new ArrayList<PrcPriceSetProduct>(productMap.values()));
|
result.put("products", new ArrayList<PrcPriceSetProduct>(productMap.values()));
|
||||||
result.put("details", details);
|
result.put("details", details);
|
||||||
@@ -1214,6 +1251,54 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, List<BmsProductCountry>> parseSetImportExcel(byte[] bytes, String filename, String countryCode)
|
||||||
|
throws Exception
|
||||||
|
{
|
||||||
|
if (StringUtils.isNotEmpty(countryCode))
|
||||||
|
{
|
||||||
|
return productService.parseCountryQuoteExcelByProduct(bytes, filename, countryCode);
|
||||||
|
}
|
||||||
|
return productService.parseQuoteExcelByProduct(bytes, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveImportProductNameFromRows(List<BmsProductCountry> rows, Long psvId, String productCode,
|
||||||
|
List<PrcPriceSetDetail> candidates)
|
||||||
|
{
|
||||||
|
if (rows != null)
|
||||||
|
{
|
||||||
|
for (BmsProductCountry row : rows)
|
||||||
|
{
|
||||||
|
if (row != null && StringUtils.isNotEmpty(row.getProductName()))
|
||||||
|
{
|
||||||
|
return row.getProductName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolveImportProductName(psvId, productCode, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stampPreviewProductDestRegion(Map<String, PrcPriceSetProduct> productMap, String productCode,
|
||||||
|
List<BmsProductCountry> rows)
|
||||||
|
{
|
||||||
|
if (productMap == null || StringUtils.isEmpty(productCode) || rows == null || rows.isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PrcPriceSetProduct sp = productMap.get(productCode.trim().toUpperCase(Locale.ROOT));
|
||||||
|
if (sp == null || StringUtils.isNotEmpty(sp.getDestRegion()))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (BmsProductCountry row : rows)
|
||||||
|
{
|
||||||
|
if (row != null && StringUtils.isNotEmpty(row.getCountryName()))
|
||||||
|
{
|
||||||
|
sp.setDestRegion(row.getCountryName());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ensurePreviewProduct(Map<String, PrcPriceSetProduct> productMap, String productCode, String productName)
|
private void ensurePreviewProduct(Map<String, PrcPriceSetProduct> productMap, String productCode, String productName)
|
||||||
{
|
{
|
||||||
if (productMap == null || StringUtils.isEmpty(productCode))
|
if (productMap == null || StringUtils.isEmpty(productCode))
|
||||||
@@ -1266,6 +1351,14 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
||||||
String taskId, boolean updateOnly) throws Exception
|
String taskId, boolean updateOnly) throws Exception
|
||||||
|
{
|
||||||
|
return importDetailPrices(priceSetId, psvId, bytes, filename, operName, taskId, updateOnly, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
||||||
|
String taskId, boolean updateOnly, boolean replaceByProductCountry, String countryCode) throws Exception
|
||||||
{
|
{
|
||||||
PrcPriceSet db = requireSet(priceSetId);
|
PrcPriceSet db = requireSet(priceSetId);
|
||||||
assertChildEditable(db);
|
assertChildEditable(db);
|
||||||
@@ -1282,7 +1375,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
throw new ServiceException(MessageUtils.message("price.set.version.notExists"));
|
throw new ServiceException(MessageUtils.message("price.set.version.notExists"));
|
||||||
}
|
}
|
||||||
reportImportProgress(taskId, priceSetId, 20, "parsing excel");
|
reportImportProgress(taskId, priceSetId, 20, "parsing excel");
|
||||||
Map<String, List<BmsProductCountry>> byProduct = productService.parseQuoteExcelByProduct(bytes, filename);
|
boolean replaceScope = replaceByProductCountry || StringUtils.isNotEmpty(countryCode);
|
||||||
|
Map<String, List<BmsProductCountry>> byProduct = parseSetImportExcel(bytes, filename, countryCode);
|
||||||
if (byProduct == null || byProduct.isEmpty())
|
if (byProduct == null || byProduct.isEmpty())
|
||||||
{
|
{
|
||||||
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
|
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
|
||||||
@@ -1296,27 +1390,35 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
{
|
{
|
||||||
details = new ArrayList<PrcPriceSetDetail>();
|
details = new ArrayList<PrcPriceSetDetail>();
|
||||||
}
|
}
|
||||||
Map<String, List<PrcPriceSetDetail>> detailIndex = new HashMap<String, List<PrcPriceSetDetail>>();
|
Map<String, List<PrcPriceSetDetail>> detailIndex = buildImportDetailIndex(details);
|
||||||
for (PrcPriceSetDetail d : details)
|
|
||||||
{
|
|
||||||
if (d == null || StringUtils.isEmpty(d.getProductCode()))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String pc = d.getProductCode().trim().toUpperCase(Locale.ROOT);
|
|
||||||
List<PrcPriceSetDetail> list = detailIndex.get(pc);
|
|
||||||
if (list == null)
|
|
||||||
{
|
|
||||||
list = new ArrayList<PrcPriceSetDetail>();
|
|
||||||
detailIndex.put(pc, list);
|
|
||||||
}
|
|
||||||
list.add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateOnly)
|
if (updateOnly)
|
||||||
{
|
{
|
||||||
assertUpdateOnlyWeightBandsMatch(byProduct, detailIndex);
|
assertUpdateOnlyWeightBandsMatch(byProduct, detailIndex);
|
||||||
}
|
}
|
||||||
|
else if (replaceScope && !details.isEmpty())
|
||||||
|
{
|
||||||
|
// Package Excel build-price only.
|
||||||
|
Set<String> replaceScopes = collectImportReplaceScopes(byProduct);
|
||||||
|
List<Long> deleteIds = new ArrayList<Long>();
|
||||||
|
for (PrcPriceSetDetail d : details)
|
||||||
|
{
|
||||||
|
if (d == null || d.getPsdId() == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String scope = importCountryScopeKey(d.getProductCode(), d.getCountryCode(), d.getCountryName());
|
||||||
|
if (scope != null && replaceScopes.contains(scope))
|
||||||
|
{
|
||||||
|
deleteIds.add(d.getPsdId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!deleteIds.isEmpty())
|
||||||
|
{
|
||||||
|
detailMapper.deletePrcPriceSetDetailByIds(deleteIds.toArray(new Long[0]));
|
||||||
|
}
|
||||||
|
removeDetailsByReplaceScopes(details, detailIndex, replaceScopes);
|
||||||
|
}
|
||||||
PrcPriceSetVersion verForFile = versionMapper.selectPrcPriceSetVersionById(psvId);
|
PrcPriceSetVersion verForFile = versionMapper.selectPrcPriceSetVersionById(psvId);
|
||||||
Date excelEff = verForFile != null && verForFile.getEffectiveTime() != null
|
Date excelEff = verForFile != null && verForFile.getEffectiveTime() != null
|
||||||
? verForFile.getEffectiveTime() : new Date();
|
? verForFile.getEffectiveTime() : new Date();
|
||||||
@@ -1338,18 +1440,22 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
reportImportProgress(taskId, priceSetId,
|
reportImportProgress(taskId, priceSetId,
|
||||||
20 + (int) Math.floor(70.0 * productIndex / Math.max(productTotal, 1)),
|
20 + (int) Math.floor(70.0 * productIndex / Math.max(productTotal, 1)),
|
||||||
"importing " + productCode + " (" + productIndex + "/" + productTotal + ")");
|
"importing " + productCode + " (" + productIndex + "/" + productTotal + ")");
|
||||||
List<PrcPriceSetDetail> candidates = detailIndex.get(productCode);
|
String pcKey = productCode == null ? null : productCode.trim().toUpperCase(Locale.ROOT);
|
||||||
|
List<PrcPriceSetDetail> candidates = detailIndex.get(pcKey);
|
||||||
if (candidates == null)
|
if (candidates == null)
|
||||||
{
|
{
|
||||||
candidates = new ArrayList<PrcPriceSetDetail>();
|
candidates = new ArrayList<PrcPriceSetDetail>();
|
||||||
detailIndex.put(productCode, candidates);
|
if (pcKey != null)
|
||||||
|
{
|
||||||
|
detailIndex.put(pcKey, candidates);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (updateOnly && candidates.isEmpty())
|
if (updateOnly && candidates.isEmpty())
|
||||||
{
|
{
|
||||||
skippedUnmatched += e.getValue() == null ? 0 : e.getValue().size();
|
skippedUnmatched += e.getValue() == null ? 0 : e.getValue().size();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String productName = resolveImportProductName(psvId, productCode, candidates);
|
String productName = resolveImportProductNameFromRows(e.getValue(), psvId, productCode, candidates);
|
||||||
Long pspId = null;
|
Long pspId = null;
|
||||||
if (!updateOnly)
|
if (!updateOnly)
|
||||||
{
|
{
|
||||||
@@ -1712,6 +1818,108 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
|||||||
return w;
|
return w;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace scope for upsert import: productCode + country (code preferred, else name).
|
||||||
|
* Rows in Excel for a scope replace all existing details of that product+country.
|
||||||
|
*/
|
||||||
|
private String importCountryScopeKey(String productCode, String countryCode, String countryName)
|
||||||
|
{
|
||||||
|
if (StringUtils.isEmpty(productCode))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String pc = productCode.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (StringUtils.isNotEmpty(countryCode))
|
||||||
|
{
|
||||||
|
return pc + "|C:" + countryCode.trim().toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(countryName))
|
||||||
|
{
|
||||||
|
return pc + "|N:" + countryName.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> collectImportReplaceScopes(Map<String, List<BmsProductCountry>> byProduct)
|
||||||
|
{
|
||||||
|
Set<String> scopes = new HashSet<String>();
|
||||||
|
if (byProduct == null || byProduct.isEmpty())
|
||||||
|
{
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, List<BmsProductCountry>> e : byProduct.entrySet())
|
||||||
|
{
|
||||||
|
if (e.getValue() == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (BmsProductCountry row : e.getValue())
|
||||||
|
{
|
||||||
|
if (row == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = importCountryScopeKey(e.getKey(), row.getCountryCode(), row.getCountryName());
|
||||||
|
if (key != null)
|
||||||
|
{
|
||||||
|
scopes.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, List<PrcPriceSetDetail>> buildImportDetailIndex(List<PrcPriceSetDetail> details)
|
||||||
|
{
|
||||||
|
Map<String, List<PrcPriceSetDetail>> detailIndex = new HashMap<String, List<PrcPriceSetDetail>>();
|
||||||
|
if (details == null)
|
||||||
|
{
|
||||||
|
return detailIndex;
|
||||||
|
}
|
||||||
|
for (PrcPriceSetDetail d : details)
|
||||||
|
{
|
||||||
|
if (d == null || StringUtils.isEmpty(d.getProductCode()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String pc = d.getProductCode().trim().toUpperCase(Locale.ROOT);
|
||||||
|
List<PrcPriceSetDetail> list = detailIndex.get(pc);
|
||||||
|
if (list == null)
|
||||||
|
{
|
||||||
|
list = new ArrayList<PrcPriceSetDetail>();
|
||||||
|
detailIndex.put(pc, list);
|
||||||
|
}
|
||||||
|
list.add(d);
|
||||||
|
}
|
||||||
|
return detailIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeDetailsByReplaceScopes(List<PrcPriceSetDetail> details,
|
||||||
|
Map<String, List<PrcPriceSetDetail>> detailIndex, Set<String> replaceScopes)
|
||||||
|
{
|
||||||
|
if (details == null || replaceScopes == null || replaceScopes.isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
java.util.Iterator<PrcPriceSetDetail> it = details.iterator();
|
||||||
|
while (it.hasNext())
|
||||||
|
{
|
||||||
|
PrcPriceSetDetail d = it.next();
|
||||||
|
if (d == null)
|
||||||
|
{
|
||||||
|
it.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String scope = importCountryScopeKey(d.getProductCode(), d.getCountryCode(), d.getCountryName());
|
||||||
|
if (scope != null && replaceScopes.contains(scope))
|
||||||
|
{
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detailIndex.clear();
|
||||||
|
detailIndex.putAll(buildImportDetailIndex(details));
|
||||||
|
}
|
||||||
|
|
||||||
private List<PrcPriceSetDetail> matchImportDetails(List<PrcPriceSetDetail> candidates, BmsProductCountry row)
|
private List<PrcPriceSetDetail> matchImportDetails(List<PrcPriceSetDetail> candidates, BmsProductCountry row)
|
||||||
{
|
{
|
||||||
List<PrcPriceSetDetail> hits = new ArrayList<PrcPriceSetDetail>();
|
List<PrcPriceSetDetail> hits = new ArrayList<PrcPriceSetDetail>();
|
||||||
|
|||||||
+11
-2
@@ -87,13 +87,22 @@ public class PrcPriceSetSaveAsyncRunner
|
|||||||
@Async("threadPoolTaskExecutor")
|
@Async("threadPoolTaskExecutor")
|
||||||
public void runImportPrices(String taskId, Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
public void runImportPrices(String taskId, Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
||||||
boolean updateOnly)
|
boolean updateOnly)
|
||||||
|
{
|
||||||
|
runImportPrices(taskId, priceSetId, psvId, bytes, filename, operName, updateOnly, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async("threadPoolTaskExecutor")
|
||||||
|
public void runImportPrices(String taskId, Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
|
||||||
|
boolean updateOnly, boolean replaceByProductCountry, String countryCode)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, 10, "parsing excel", priceSetId, null);
|
put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, 10, "parsing excel", priceSetId, null);
|
||||||
String message = priceSetService.importDetailPrices(priceSetId, psvId, bytes, filename, operName, taskId, updateOnly);
|
String message = priceSetService.importDetailPrices(priceSetId, psvId, bytes, filename, operName, taskId,
|
||||||
|
updateOnly, replaceByProductCountry, countryCode);
|
||||||
put(taskId, PrcPriceSetSaveTask.STATUS_SUCCESS, 100, message, priceSetId, null);
|
put(taskId, PrcPriceSetSaveTask.STATUS_SUCCESS, 100, message, priceSetId, null);
|
||||||
log.info("async import prices done, taskId={}, priceSetId={}, updateOnly={}", taskId, priceSetId, updateOnly);
|
log.info("async import prices done, taskId={}, priceSetId={}, updateOnly={}, replaceByProductCountry={}, countryCode={}",
|
||||||
|
taskId, priceSetId, updateOnly, replaceByProductCountry, countryCode);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function addSetDetail(data) {
|
|||||||
/**
|
/**
|
||||||
* Async import quote Excel into FIXED package detail prices; poll until done.
|
* Async import quote Excel into FIXED package detail prices; poll until done.
|
||||||
* @param {File|Blob} file
|
* @param {File|Blob} file
|
||||||
* @param {{ priceSetId: string|number, psvId?: string|number, updateOnly?: boolean }} query
|
* @param {{ priceSetId: string|number, psvId?: string|number, updateOnly?: boolean, replaceByProductCountry?: boolean, countryCode?: string }} query
|
||||||
* @param {(task: object) => void} [onProgress]
|
* @param {(task: object) => void} [onProgress]
|
||||||
* @returns {Promise<{ task: object }>}
|
* @returns {Promise<{ task: object }>}
|
||||||
*/
|
*/
|
||||||
@@ -221,6 +221,9 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
|
|||||||
if (query && query.priceSetId != null) params.set('priceSetId', query.priceSetId)
|
if (query && query.priceSetId != null) params.set('priceSetId', query.priceSetId)
|
||||||
if (query && query.psvId != null && query.psvId !== '') params.set('psvId', query.psvId)
|
if (query && query.psvId != null && query.psvId !== '') params.set('psvId', query.psvId)
|
||||||
if (query && query.updateOnly) params.set('updateOnly', 'true')
|
if (query && query.updateOnly) params.set('updateOnly', 'true')
|
||||||
|
// Package Excel build-price only (product+country replace scope)
|
||||||
|
if (query && query.replaceByProductCountry) params.set('replaceByProductCountry', 'true')
|
||||||
|
if (query && query.countryCode) params.set('countryCode', query.countryCode)
|
||||||
const submitRes = await request({
|
const submitRes = await request({
|
||||||
url: '/price/set/detail/importPrices?' + params.toString(),
|
url: '/price/set/detail/importPrices?' + params.toString(),
|
||||||
method: 'post',
|
method: 'post',
|
||||||
@@ -247,20 +250,24 @@ export function previewPullFromLibrary() {
|
|||||||
/**
|
/**
|
||||||
* Preview Excel import (no DB write).
|
* Preview Excel import (no DB write).
|
||||||
* @param {File|Blob} file
|
* @param {File|Blob} file
|
||||||
* @param {{ updateOnly?: boolean, details?: Array }} [opts]
|
* @param {{ updateOnly?: boolean, replaceByProductCountry?: boolean, countryCode?: string, details?: Array }} [opts]
|
||||||
*/
|
*/
|
||||||
export function previewImportDetailPrices(file, opts = {}) {
|
export function previewImportDetailPrices(file, opts = {}) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
if (opts.updateOnly) formData.append('updateOnly', 'true')
|
if (opts.updateOnly) formData.append('updateOnly', 'true')
|
||||||
if (opts.updateOnly && Array.isArray(opts.details)) {
|
if (opts.replaceByProductCountry) formData.append('replaceByProductCountry', 'true')
|
||||||
|
if (opts.countryCode) formData.append('countryCode', opts.countryCode)
|
||||||
|
// updateOnly / Excel build-price merge need current draft details
|
||||||
|
if ((opts.updateOnly || opts.replaceByProductCountry || opts.countryCode) && Array.isArray(opts.details)) {
|
||||||
formData.append('detailsJson', JSON.stringify(opts.details))
|
formData.append('detailsJson', JSON.stringify(opts.details))
|
||||||
}
|
}
|
||||||
return request({
|
return request({
|
||||||
url: '/price/set/detail/previewImportPrices',
|
url: '/price/set/detail/previewImportPrices',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: formData,
|
data: formData,
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
// FormData must not go through repeat-submit JSON.stringify (collapses to "{}")
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false },
|
||||||
timeout: 10 * 60 * 1000
|
timeout: 10 * 60 * 1000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :title="title" v-model="visible" :width="width" append-to-body @close="handleClose" class="excel-import-dialog">
|
<el-dialog
|
||||||
|
:title="title"
|
||||||
|
v-model="visible"
|
||||||
|
:width="width"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
@closed="handleClosed"
|
||||||
|
class="excel-import-dialog"
|
||||||
|
>
|
||||||
<div class="import-body">
|
<div class="import-body">
|
||||||
<!-- 业务说明(可选) -->
|
<!-- 业务说明(可选) -->
|
||||||
<div v-if="importDesc.length" class="import-desc">
|
<div v-if="importDesc.length" class="import-desc">
|
||||||
@@ -81,6 +89,23 @@ import { getToken } from '@/utils/auth'
|
|||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
|
function plainText(msg) {
|
||||||
|
if (msg == null || msg === '') return '导入完成'
|
||||||
|
return String(msg).replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Do not call el-upload.clearFiles while the dialog is hiding: EP sets inputRef.value.value = ''. */
|
||||||
|
function safeClearFiles() {
|
||||||
|
if (!visible.value) return
|
||||||
|
const inst = uploadRef.value
|
||||||
|
if (!inst || typeof inst.clearFiles !== 'function') return
|
||||||
|
try {
|
||||||
|
inst.clearFiles()
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// 对话框标题
|
// 对话框标题
|
||||||
title: {
|
title: {
|
||||||
@@ -203,17 +228,14 @@ function open() {
|
|||||||
visible.value = true
|
visible.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
uploadRef.value?.clearFiles()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭时清理
|
function handleClosed() {
|
||||||
function handleClose() {
|
|
||||||
isUploading.value = false
|
isUploading.value = false
|
||||||
progressPercent.value = 0
|
progressPercent.value = 0
|
||||||
validateError.value = ''
|
validateError.value = ''
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
uploadRef.value?.clearFiles()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载模板:静态文件直接 <a download>,接口走 proxy.download
|
// 下载模板:静态文件直接 <a download>,接口走 proxy.download
|
||||||
@@ -262,7 +284,7 @@ const handleFileChange = (file, fileList) => {
|
|||||||
if (err) {
|
if (err) {
|
||||||
validateError.value = err
|
validateError.value = err
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
nextTick(() => uploadRef.value?.clearFiles())
|
safeClearFiles()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
validateError.value = ''
|
validateError.value = ''
|
||||||
@@ -280,18 +302,17 @@ const handleFileRemove = (file, fileList) => {
|
|||||||
function handleClearFile() {
|
function handleClearFile() {
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
validateError.value = ''
|
validateError.value = ''
|
||||||
uploadRef.value?.clearFiles()
|
safeClearFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传成功
|
// 上传成功
|
||||||
function handleSuccess(response) {
|
function handleSuccess(response) {
|
||||||
visible.value = false
|
|
||||||
isUploading.value = false
|
isUploading.value = false
|
||||||
progressPercent.value = 0
|
progressPercent.value = 0
|
||||||
validateError.value = ''
|
validateError.value = ''
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
uploadRef.value?.clearFiles()
|
visible.value = false
|
||||||
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + response.msg + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
|
proxy.$modal.msgSuccess(plainText(response && response.msg))
|
||||||
emit('success')
|
emit('success')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,25 +336,21 @@ async function handleSubmit() {
|
|||||||
progressPercent.value = 0
|
progressPercent.value = 0
|
||||||
try {
|
try {
|
||||||
const resultMsg = await props.submitHandler(file)
|
const resultMsg = await props.submitHandler(file)
|
||||||
visible.value = false
|
|
||||||
validateError.value = ''
|
validateError.value = ''
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
uploadRef.value?.clearFiles()
|
visible.value = false
|
||||||
const html = resultMsg == null || resultMsg === ''
|
proxy.$modal.msgSuccess(plainText(resultMsg))
|
||||||
? '导入完成'
|
|
||||||
: String(resultMsg)
|
|
||||||
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + html + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
|
|
||||||
emit('success')
|
emit('success')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const tip = (e && e.message) ? e.message : '导入失败'
|
const tip = (e && e.message) ? e.message : '导入失败'
|
||||||
proxy.$modal.msgError(tip)
|
proxy.$modal.msgError(plainText(tip) || '导入失败')
|
||||||
} finally {
|
} finally {
|
||||||
isUploading.value = false
|
isUploading.value = false
|
||||||
progressPercent.value = 0
|
progressPercent.value = 0
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploadRef.value.submit()
|
uploadRef.value?.submit?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ open })
|
defineExpose({ open })
|
||||||
|
|||||||
@@ -13,11 +13,31 @@
|
|||||||
:disabled="(livePersist && !priceSetId) || pulling"
|
:disabled="(livePersist && !priceSetId) || pulling"
|
||||||
@click="handlePullLibrary"
|
@click="handlePullLibrary"
|
||||||
>拉取产品列表</el-button>
|
>拉取产品列表</el-button>
|
||||||
|
<el-dropdown
|
||||||
|
v-if="showImportAction && !isModifyImport"
|
||||||
|
trigger="click"
|
||||||
|
@command="handleImportCommand"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="(livePersist && !priceSetId) || pulling || (livePersist && buildModeDirty)"
|
||||||
|
>
|
||||||
|
{{ importActionLabel }}
|
||||||
|
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="all">全部导入(多国家价表)</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="country">按国家导入(单国专线成本价)</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="showImportAction"
|
v-else-if="showImportAction"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="(livePersist && !priceSetId) || pulling || buildModeDirty"
|
:disabled="(livePersist && !priceSetId) || pulling || (livePersist && buildModeDirty)"
|
||||||
@click="openImport"
|
@click="openImport"
|
||||||
>{{ importActionLabel }}</el-button>
|
>{{ importActionLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +147,32 @@
|
|||||||
@success="onImportSuccess"
|
@success="onImportSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<excel-import-dialog
|
||||||
|
ref="countryImportRef"
|
||||||
|
title="按国家导入"
|
||||||
|
width="560px"
|
||||||
|
:show-update-support="false"
|
||||||
|
:import-desc="countryImportDesc"
|
||||||
|
:before-submit="validateCountryImport"
|
||||||
|
:submit-handler="runCountryImportAsync"
|
||||||
|
@success="onImportSuccess"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="目的国家" required>
|
||||||
|
<el-select v-model="importCountryCode" filterable clearable placeholder="请选择国家" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="item in regionOptions"
|
||||||
|
:key="item.countryCode2"
|
||||||
|
:label="regionLabel(item)"
|
||||||
|
:value="item.countryCode2"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
</excel-import-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="importProgressVisible"
|
v-model="importProgressVisible"
|
||||||
:title="progressDialogTitle"
|
:title="progressDialogTitle"
|
||||||
@@ -162,6 +208,7 @@ import {
|
|||||||
toDetailPayload
|
toDetailPayload
|
||||||
} from '../../utils/priceSet'
|
} from '../../utils/priceSet'
|
||||||
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, previewPullFromLibrary, previewImportDetailPrices, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
|
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, previewPullFromLibrary, previewImportDetailPrices, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
|
||||||
|
import { listRegionOptions } from '@/api/data/region'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
details: { type: Array, required: true },
|
details: { type: Array, required: true },
|
||||||
@@ -219,6 +266,14 @@ const discTitle = ref('编辑价格')
|
|||||||
const discValue = ref(undefined)
|
const discValue = ref(undefined)
|
||||||
const discCode = ref('')
|
const discCode = ref('')
|
||||||
const importRef = ref()
|
const importRef = ref()
|
||||||
|
const countryImportRef = ref()
|
||||||
|
const importCountryCode = ref('')
|
||||||
|
const regionOptions = ref([])
|
||||||
|
const countryImportDesc = [
|
||||||
|
'选择目的国家后上传专线成本价表(含「产品目录」),目录中的产品都会记到该国家。',
|
||||||
|
'按产品+国家覆盖:仅替换已选国家的明细,其它产品/国家保留。',
|
||||||
|
'邮编、赔偿标准等 sheet 不导入。仅套餐「Excel 导入建价」可用。'
|
||||||
|
]
|
||||||
const importProgressVisible = ref(false)
|
const importProgressVisible = ref(false)
|
||||||
const importProgressPercent = ref(0)
|
const importProgressPercent = ref(0)
|
||||||
const importProgressMessage = ref('')
|
const importProgressMessage = ref('')
|
||||||
@@ -251,8 +306,8 @@ const importDesc = computed(() => {
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
'适用威狮B2C小包价表(含「产品目录」)。',
|
'适用威狮B2C小包价表(含「产品目录」)。',
|
||||||
'按产品+国家+分区(有则匹配)+重量段:命中则更新运费/挂号费,未命中则新增套餐明细。',
|
'按产品+国家覆盖:Excel 中出现的产品+国家会替换已有同范围明细,其它产品/国家保留。',
|
||||||
'仅固定价套餐可导入。'
|
'仅固定价套餐「导入 Excel 建价」可用;不影响导入修改价格/拉取产品库。'
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -381,6 +436,22 @@ async function chooseBuildMode(mode) {
|
|||||||
let localKeySeq = 0
|
let localKeySeq = 0
|
||||||
|
|
||||||
/** Apply preview products/details into parent reactive arrays (create defer mode). */
|
/** Apply preview products/details into parent reactive arrays (create defer mode). */
|
||||||
|
function mergeLocalProducts(existing, incoming) {
|
||||||
|
const map = new Map()
|
||||||
|
for (const p of existing || []) {
|
||||||
|
if (p && p.productCode) map.set(String(p.productCode).toUpperCase(), { ...p })
|
||||||
|
}
|
||||||
|
for (const p of incoming || []) {
|
||||||
|
if (!p || !p.productCode) continue
|
||||||
|
const key = String(p.productCode).toUpperCase()
|
||||||
|
const prev = map.get(key) || {}
|
||||||
|
const next = { ...prev, ...p }
|
||||||
|
if (p.pefId == null && prev.pefId != null) next.pefId = prev.pefId
|
||||||
|
map.set(key, next)
|
||||||
|
}
|
||||||
|
return Array.from(map.values())
|
||||||
|
}
|
||||||
|
|
||||||
function applyLocalDraft(products, details) {
|
function applyLocalDraft(products, details) {
|
||||||
clearDetailCache()
|
clearDetailCache()
|
||||||
const hydrated = (details || []).map(d => {
|
const hydrated = (details || []).map(d => {
|
||||||
@@ -393,10 +464,12 @@ function applyLocalDraft(products, details) {
|
|||||||
return row
|
return row
|
||||||
})
|
})
|
||||||
if (Array.isArray(props.details)) {
|
if (Array.isArray(props.details)) {
|
||||||
props.details.splice(0, props.details.length, ...hydrated)
|
props.details.splice(0, props.details.length)
|
||||||
|
hydrated.forEach(row => props.details.push(row))
|
||||||
}
|
}
|
||||||
if (Array.isArray(props.products)) {
|
if (Array.isArray(props.products)) {
|
||||||
props.products.splice(0, props.products.length, ...((products || []).map(p => ({ ...p }))))
|
props.products.splice(0, props.products.length)
|
||||||
|
;(products || []).forEach(p => props.products.push({ ...p }))
|
||||||
}
|
}
|
||||||
hydrated.forEach(d => {
|
hydrated.forEach(d => {
|
||||||
if (!d || !d.productCode) return
|
if (!d || !d.productCode) return
|
||||||
@@ -405,74 +478,109 @@ function applyLocalDraft(products, details) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openImport() {
|
function regionLabel(item) {
|
||||||
if (props.buildModeDirty) {
|
const name = (item && (item.countryNameCn || item.countryNameEn)) || ''
|
||||||
|
const code = (item && item.countryCode2) || ''
|
||||||
|
return name && code ? name + ' (' + code + ')' : (name || code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRegionOptions() {
|
||||||
|
if (regionOptions.value.length) return
|
||||||
|
listRegionOptions().then(res => {
|
||||||
|
regionOptions.value = res.data || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canOpenImport() {
|
||||||
|
// Edit live: mode change must be saved before import. Create draft: import is local preview.
|
||||||
|
if (props.livePersist && props.buildModeDirty) {
|
||||||
proxy.$modal.msgWarning("请先点「完成」保存建价方式")
|
proxy.$modal.msgWarning("请先点「完成」保存建价方式")
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (props.livePersist && !props.priceSetId) {
|
if (props.livePersist && !props.priceSetId) {
|
||||||
proxy.$modal.msgWarning('缺少套餐ID')
|
proxy.$modal.msgWarning('缺少套餐ID')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (!isFixedType(props.setType)) {
|
if (!isFixedType(props.setType)) {
|
||||||
proxy.$modal.msgWarning('仅固定价套餐支持导入')
|
proxy.$modal.msgWarning('仅固定价套餐支持导入')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (props.livePersist) {
|
if (props.livePersist) {
|
||||||
const psvId = await resolvePsvId(true)
|
const psvId = await resolvePsvId(true)
|
||||||
if (!psvId) {
|
if (!psvId) {
|
||||||
proxy.$modal.msgWarning('缺少套餐版本,请先保存基本信息')
|
proxy.$modal.msgWarning('缺少套餐版本,请先保存基本信息')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openImport() {
|
||||||
|
if (!(await canOpenImport())) return
|
||||||
importRef.value && importRef.value.open()
|
importRef.value && importRef.value.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runImportAsync(uploadFile) {
|
function handleImportCommand(cmd) {
|
||||||
|
if (cmd === 'country') openCountryImport()
|
||||||
|
else openImport()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCountryImport() {
|
||||||
|
if (!(await canOpenImport())) return
|
||||||
|
importCountryCode.value = ''
|
||||||
|
loadRegionOptions()
|
||||||
|
countryImportRef.value && countryImportRef.value.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCountryImport() {
|
||||||
|
if (!importCountryCode.value) return '请先选择目的国家'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImportAsync(uploadFile, countryCode) {
|
||||||
const raw = uploadFile && (uploadFile.raw || uploadFile)
|
const raw = uploadFile && (uploadFile.raw || uploadFile)
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
throw new Error('请先选择文件')
|
throw new Error('请先选择文件')
|
||||||
}
|
}
|
||||||
progressDialogTitle.value = '正在导入价格'
|
// Do not open a second el-dialog over ExcelImportDialog: closing the overlay
|
||||||
importProgressVisible.value = true
|
// click-throughs into el-upload and EP crashes (inputRef.value.value = '').
|
||||||
importProgressPercent.value = 0
|
// Excel build-price (IMPORT mode): merge by product+country. Modify-price / other modes unchanged.
|
||||||
importProgressMessage.value = '上传并解析中…'
|
const replaceByProductCountry = !isModifyImport.value || !!countryCode
|
||||||
importProgressStatus.value = undefined
|
if (!props.livePersist) {
|
||||||
try {
|
const res = await previewImportDetailPrices(raw, {
|
||||||
if (!props.livePersist) {
|
updateOnly: isModifyImport.value && !countryCode,
|
||||||
importProgressMessage.value = '正在解析价表…'
|
replaceByProductCountry,
|
||||||
importProgressPercent.value = 30
|
countryCode: countryCode || undefined,
|
||||||
const res = await previewImportDetailPrices(raw, {
|
details: props.details || []
|
||||||
updateOnly: isModifyImport.value,
|
})
|
||||||
details: isModifyImport.value ? (props.details || []) : undefined
|
const data = res.data || {}
|
||||||
})
|
if (data.pefId != null) {
|
||||||
const data = res.data || {}
|
emit('update:pefId', data.pefId)
|
||||||
if (data.pefId != null) {
|
|
||||||
emit('update:pefId', data.pefId)
|
|
||||||
}
|
|
||||||
const products = isModifyImport.value ? (props.products || []) : (data.products || [])
|
|
||||||
applyLocalDraft(products, data.details || [])
|
|
||||||
importProgressPercent.value = 100
|
|
||||||
importProgressStatus.value = 'success'
|
|
||||||
return data.message || '导入完成'
|
|
||||||
}
|
}
|
||||||
const { task } = await importSetDetailPricesAsync(
|
const products = replaceByProductCountry
|
||||||
raw,
|
? mergeLocalProducts(props.products, data.products || [])
|
||||||
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false), updateOnly: isModifyImport.value },
|
: (isModifyImport.value ? (props.products || []) : (data.products || []))
|
||||||
applyBuildProgress
|
applyLocalDraft(products, data.details || [])
|
||||||
)
|
return data.message || '导入完成'
|
||||||
importProgressPercent.value = 100
|
|
||||||
importProgressStatus.value = 'success'
|
|
||||||
return (task && task.message) ? task.message : '导入完成'
|
|
||||||
} catch (e) {
|
|
||||||
importProgressStatus.value = 'exception'
|
|
||||||
const tip = (e && e.task && e.task.error) || (e && e.message) || '导入失败'
|
|
||||||
importProgressMessage.value = tip
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 600))
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
importProgressVisible.value = false
|
|
||||||
}
|
}
|
||||||
|
const { task } = await importSetDetailPricesAsync(
|
||||||
|
raw,
|
||||||
|
{
|
||||||
|
priceSetId: props.priceSetId,
|
||||||
|
psvId: await resolvePsvId(false),
|
||||||
|
updateOnly: isModifyImport.value && !countryCode,
|
||||||
|
replaceByProductCountry,
|
||||||
|
countryCode: countryCode || undefined
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return (task && task.message) ? task.message : '导入完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCountryImportAsync(uploadFile) {
|
||||||
|
if (!importCountryCode.value) {
|
||||||
|
throw new Error('请先选择目的国家')
|
||||||
|
}
|
||||||
|
return runImportAsync(uploadFile, importCountryCode.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBuildProgress(t) {
|
function applyBuildProgress(t) {
|
||||||
|
|||||||
@@ -316,14 +316,14 @@ export function useDetailCascade(refData) {
|
|||||||
// Do NOT prefetch /surcharge/byProduct here: applySetData hydrates every detail row
|
// Do NOT prefetch /surcharge/byProduct here: applySetData hydrates every detail row
|
||||||
// on step-1 load; use SALES rels on row.surcharges instead. Fetch on demand in picker.
|
// on step-1 load; use SALES rels on row.surcharges instead. Fetch on demand in picker.
|
||||||
if (setType !== 'FIXED') return row
|
if (setType !== 'FIXED') return row
|
||||||
// Bulk load from DB already has productCountryId + weight bounds: normalize locally,
|
// Excel preview / DB bulk already has country + weight: normalize locally.
|
||||||
// do not fire /countries per detail row (was stampeding thousands of identical requests).
|
// Do not fire /countries per detail row (import can be hundreds of rows).
|
||||||
if (row.productCountryId != null && (row.weightMin != null || row.weightMax != null || row.weightLabel)) {
|
if (row.productCountryId != null
|
||||||
|
|| ((row.weightMin != null || row.weightMax != null) && (row.countryCode || row.countryName))) {
|
||||||
if (row.weightMin != null || row.weightMax != null) {
|
if (row.weightMin != null || row.weightMax != null) {
|
||||||
row.weightLabel = formatWeightLabel(row.weightMin, row.weightMax)
|
row.weightLabel = formatWeightLabel(row.weightMin, row.weightMax)
|
||||||
}
|
}
|
||||||
row._countryKey = countryKeyOf(row)
|
row._countryKey = countryKeyOf(row)
|
||||||
// Keep package prices in fv*; product base is stamped later from product_country.
|
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
refData.ensureCountries(row.productCode).then(rows => {
|
refData.ensureCountries(row.productCode).then(rows => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
:set-type="form.basic.setType"
|
:set-type="form.basic.setType"
|
||||||
:price-build-mode="form.version.priceBuildMode"
|
:price-build-mode="form.version.priceBuildMode"
|
||||||
@update:price-build-mode="onPriceBuildModeUpdate"
|
@update:price-build-mode="onPriceBuildModeUpdate"
|
||||||
@update:pef-id="v => { draftPefId.value = v }"
|
@update:pef-id="onPefIdUpdate"
|
||||||
:version-no="form.version.versionNo"
|
:version-no="form.version.versionNo"
|
||||||
:version-status="form.version.status"
|
:version-status="form.version.status"
|
||||||
:ref-data="refData"
|
:ref-data="refData"
|
||||||
@@ -338,6 +338,10 @@ function onPsvIdUpdate(psvId) {
|
|||||||
if (psvId != null && psvId !== '') form.version.psvId = psvId
|
if (psvId != null && psvId !== '') form.version.psvId = psvId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPefIdUpdate(pefId) {
|
||||||
|
draftPefId.value = pefId
|
||||||
|
}
|
||||||
|
|
||||||
/** Step2 chose PULL / IMPORT: edit live-saves mode; create only local + optional preview pull. */
|
/** Step2 chose PULL / IMPORT: edit live-saves mode; create only local + optional preview pull. */
|
||||||
function onPriceBuildModeUpdate(mode) {
|
function onPriceBuildModeUpdate(mode) {
|
||||||
if (!mode) return
|
if (!mode) return
|
||||||
|
|||||||
Reference in New Issue
Block a user