增加专属报价功能

This commit is contained in:
wuweihua
2026-11-05 12:57:13 +08:00
parent 557d43a0b8
commit 308526ecee
32 changed files with 3817 additions and 567 deletions
@@ -0,0 +1,453 @@
package com.bms.web.controller.price;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.bms.price.service.support.UploadFileBatch;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcCustomerPriceSetBatch;
import com.bms.price.domain.PrcPriceExcelFile;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetDetail;
import com.bms.price.domain.PrcPriceSetSaveTask;
import com.bms.price.domain.PrcPriceSetVersion;
import com.bms.price.service.IPrcPriceExcelFileService;
import com.bms.price.service.IPrcPriceSetService;
/**
* Exclusive quote (shares prc_price_set with PACKAGE)
*
* @author bms
*/
@RestController
@RequestMapping("/price/exclusive")
public class ExclusiveQuoteController extends BaseController
{
@Autowired
private IPrcPriceSetService priceSetService;
@Autowired
private IPrcPriceExcelFileService priceExcelFileService;
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/list")
public TableDataInfo list(PrcPriceSet set)
{
set.setSetCategory(PrcPriceSet.CATEGORY_EXCLUSIVE);
startPage();
List<PrcPriceSet> list = priceSetService.selectPrcPriceSetList(set);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:query')")
@GetMapping("/{priceSetId:[0-9]+}")
public AjaxResult getInfo(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return success(priceSetService.selectPrcPriceSetById(priceSetId));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:add')")
@Log(title = "Exclusive quote", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody PrcPriceSet set)
{
if (set.getPriceSetId() != null)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
}
set.setSetCategory(PrcPriceSet.CATEGORY_EXCLUSIVE);
set.setCreateBy(getUsername());
set.setUpdateBy(getUsername());
String taskId = priceSetService.submitSaveAsync(set);
// Do not pass bare String to success(): overload puts it into msg, not data.
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:query')")
@GetMapping("/save/task/{taskId}")
public AjaxResult getSaveTask(@PathVariable String taskId)
{
PrcPriceSetSaveTask task = priceSetService.getSaveTask(taskId);
if (task == null)
{
return error(MessageUtils.message("price.set.save.task.notExists"));
}
return success(task);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody PrcPriceSet set)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
set.setSetCategory(PrcPriceSet.CATEGORY_EXCLUSIVE);
set.setUpdateBy(getUsername());
return toAjax(priceSetService.updatePrcPriceSet(set));
}
/** Update package name and note only. */
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote basic", businessType = BusinessType.UPDATE)
@PutMapping("/basic")
public AjaxResult editBasic(@RequestBody PrcPriceSet set)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
set.setUpdateBy(getUsername());
return toAjax(priceSetService.updateBasicInfo(set));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:remove')")
@Log(title = "Exclusive quote", businessType = BusinessType.DELETE)
@DeleteMapping("/{priceSetIds}")
public AjaxResult remove(@PathVariable Long[] priceSetIds)
{
if (priceSetIds != null)
{
for (Long id : priceSetIds)
{
priceSetService.assertSetCategory(id, PrcPriceSet.CATEGORY_EXCLUSIVE);
}
}
return toAjax(priceSetService.deletePrcPriceSetByIds(priceSetIds));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote提交审核", businessType = BusinessType.UPDATE)
@PutMapping("/submit/{priceSetId}")
public AjaxResult submit(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.submit(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote生效", businessType = BusinessType.UPDATE)
@PutMapping("/approve/{priceSetId}")
public AjaxResult approve(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.approve(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote驳回", businessType = BusinessType.UPDATE)
@PutMapping("/reject/{priceSetId}")
public AjaxResult reject(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.reject(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote version activate", businessType = BusinessType.UPDATE)
@PutMapping("/version/activate/{psvId}")
public AjaxResult activateVersion(@PathVariable Long psvId, @RequestBody(required = false) PrcPriceSetVersion body)
{
Date effectiveTime = body == null ? null : body.getEffectiveTime();
return toAjax(priceSetService.activateVersion(psvId, effectiveTime, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:add')")
@Log(title = "Exclusive quote version copy", businessType = BusinessType.INSERT)
@PostMapping("/version/copy/{psvId}")
public AjaxResult copyVersion(@PathVariable Long psvId)
{
Long newPsvId = priceSetService.copyVersion(psvId, getUsername());
return success(newPsvId);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:add')")
@Log(title = "Exclusive quote version create", businessType = BusinessType.INSERT)
@PostMapping("/version/create/{priceSetId}")
public AjaxResult createVersion(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
Long newPsvId = priceSetService.createVersion(priceSetId, getUsername());
return success(newPsvId);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote version delete", businessType = BusinessType.DELETE)
@DeleteMapping("/version/{psvId}")
public AjaxResult deleteVersion(@PathVariable Long psvId)
{
return toAjax(priceSetService.deleteVersion(psvId, getUsername()));
}
/** Import excel attachments for a version (prc_price_excel_file.psv_id). */
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/version/{psvId}/excelFiles")
public AjaxResult versionExcelFiles(@PathVariable Long psvId)
{
List<PrcPriceExcelFile> list = priceExcelFileService.selectByPsvId(psvId);
return success(list);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote disable", businessType = BusinessType.UPDATE)
@PutMapping("/disable/{priceSetId}")
public AjaxResult disable(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.disable(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote enable", businessType = BusinessType.UPDATE)
@PutMapping("/enable/{priceSetId}")
public AjaxResult enable(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.enable(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:add')")
@Log(title = "Exclusive quote copy", businessType = BusinessType.INSERT)
@PostMapping("/copy/{priceSetId}")
public AjaxResult copy(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
Long id = priceSetService.copy(priceSetId, getUsername());
return success(id);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:query')")
@PostMapping("/validate")
public AjaxResult validate(@RequestBody PrcPriceSet set)
{
return success(priceSetService.validate(set));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/detail/list")
public TableDataInfo detailList(PrcPriceSetDetail detail)
{
startPage();
List<PrcPriceSetDetail> list = priceSetService.selectDetailList(detail);
return getDataTable(list);
}
/** All detail rows for one product (no page); used by edit-price drawer. */
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/detail/byProduct")
public AjaxResult detailByProduct(@RequestParam("psvId") Long psvId,
@RequestParam("productCode") String productCode)
{
return success(priceSetService.selectDetailListByProduct(psvId, productCode));
}
/** All detail rows for a version (no page); batch prefetch instead of N byProduct calls. */
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/detail/byVersion")
public AjaxResult detailByVersion(@RequestParam("psvId") Long psvId)
{
return success(priceSetService.selectDetailListByVersion(psvId));
}
/**
* 版本明细搜索下拉选项:按版本去重后的 产品/国家/分区
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/detail/options")
public AjaxResult detailOptions(@RequestParam("psvId") Long psvId)
{
return success(priceSetService.selectDetailOptions(psvId));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote明细", businessType = BusinessType.INSERT)
@PostMapping("/detail")
public AjaxResult addDetail(@RequestBody PrcPriceSetDetail detail)
{
return toAjax(priceSetService.insertDetail(detail));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote明细", businessType = BusinessType.UPDATE)
@PutMapping("/detail")
public AjaxResult editDetail(@RequestBody PrcPriceSetDetail detail)
{
return toAjax(priceSetService.updateDetail(detail));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote价格导入", businessType = BusinessType.IMPORT)
@PostMapping("/detail/importPrices")
public AjaxResult importDetailPrices(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam("priceSetId") Long priceSetId,
@RequestParam(value = "psvId", required = false) Long psvId,
@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
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, UploadFileBatch.collect(files, file),
getUsername(), updateOnly, replaceByProductCountry, countryCode);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/**
* Preview pull product library (no DB write). For create wizard before finish.
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit') or @ss.hasPermi('price:exclusive:add')")
@PostMapping("/detail/previewPullFromLibrary")
public AjaxResult previewPullFromLibrary()
{
return success(priceSetService.previewPullFromLibrary());
}
/**
* 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:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit') or @ss.hasPermi('price:exclusive:add')")
@PostMapping("/detail/previewImportPrices")
public AjaxResult previewImportPrices(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file,
@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
{
java.util.List<MultipartFile> batch = UploadFileBatch.collect(files, file);
java.util.List<byte[]> bytesList = new java.util.ArrayList<byte[]>();
java.util.List<String> names = new java.util.ArrayList<String>();
for (MultipartFile item : batch)
{
bytesList.add(item.getBytes());
names.add(item.getOriginalFilename());
}
List<PrcPriceSetDetail> existing = null;
if (StringUtils.isNotEmpty(detailsJson))
{
existing = com.alibaba.fastjson2.JSON.parseArray(detailsJson, PrcPriceSetDetail.class);
}
return success(priceSetService.previewImportDetailPricesMany(
bytesList, names, updateOnly, existing, replaceByProductCountry, countryCode, true));
}
/**
* Async pull product library into version details. Returns taskId; poll /save/task/{taskId}.
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote拉取产品", businessType = BusinessType.IMPORT)
@PostMapping("/detail/pullFromLibrary")
public AjaxResult pullFromLibrary(@RequestParam("priceSetId") Long priceSetId,
@RequestParam(value = "psvId", required = false) Long psvId,
@RequestParam(value = "force", required = false, defaultValue = "false") boolean force)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
String taskId = priceSetService.submitPullFromLibraryAsync(priceSetId, psvId, force, getUsername());
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/**
* Async batch write of detail surcharge snapshots. Returns taskId; poll /save/task/{taskId}.
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote附加费", businessType = BusinessType.UPDATE)
@PostMapping("/detail/surcharge/async")
public AjaxResult batchSurchargeAsync(@RequestBody List<PrcPriceSetDetail> details)
{
String taskId = priceSetService.submitDetailSurchargeAsync(details);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote明细", businessType = BusinessType.DELETE)
@DeleteMapping("/detail/{psdIds}")
public AjaxResult removeDetail(@PathVariable Long[] psdIds)
{
return toAjax(priceSetService.deleteDetailByIds(psdIds));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/customer/list")
public TableDataInfo customerList(PrcCustomerPriceSet customer)
{
startPage();
List<PrcCustomerPriceSet> list = priceSetService.selectCustomerList(customer);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote客户绑定", businessType = BusinessType.INSERT)
@PostMapping("/customer")
public AjaxResult addCustomer(@RequestBody PrcCustomerPriceSet customer)
{
if (customer != null && customer.getPriceSetId() != null)
{
priceSetService.assertSetCategory(customer.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
}
customer.setCreateBy(getUsername());
return toAjax(priceSetService.insertCustomer(customer));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote客户批量绑定", businessType = BusinessType.INSERT)
@PostMapping("/customer/batch")
public AjaxResult batchAddCustomer(@RequestBody PrcCustomerPriceSetBatch batch)
{
if (batch != null && batch.getPriceSetId() != null)
{
priceSetService.assertSetCategory(batch.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
}
batch.setCreateBy(getUsername());
batch.setUpdateBy(getUsername());
return success(priceSetService.batchInsertCustomers(batch));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote客户绑定", businessType = BusinessType.UPDATE)
@PutMapping("/customer")
public AjaxResult editCustomer(@RequestBody PrcCustomerPriceSet customer)
{
customer.setUpdateBy(getUsername());
return toAjax(priceSetService.updateCustomer(customer));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote客户绑定", businessType = BusinessType.DELETE)
@DeleteMapping("/customer/{customerPricesetIds}")
public AjaxResult removeCustomer(@PathVariable Long[] customerPricesetIds)
{
return toAjax(priceSetService.deleteCustomerByIds(customerPricesetIds));
}
}
@@ -55,6 +55,8 @@ public class PriceSetController extends BaseController
@GetMapping("/list")
public TableDataInfo list(PrcPriceSet set)
{
// Ordinary package module: never mix exclusive quotes into this list.
set.setSetCategory(PrcPriceSet.CATEGORY_PACKAGE);
startPage();
List<PrcPriceSet> list = priceSetService.selectPrcPriceSetList(set);
return getDataTable(list);
@@ -64,6 +66,7 @@ public class PriceSetController extends BaseController
@GetMapping("/{priceSetId:[0-9]+}")
public AjaxResult getInfo(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_PACKAGE);
return success(priceSetService.selectPrcPriceSetById(priceSetId));
}
@@ -72,6 +75,11 @@ public class PriceSetController extends BaseController
@PostMapping
public AjaxResult add(@Validated @RequestBody PrcPriceSet set)
{
if (set.getPriceSetId() != null)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_PACKAGE);
}
set.setSetCategory(PrcPriceSet.CATEGORY_PACKAGE);
set.setCreateBy(getUsername());
set.setUpdateBy(getUsername());
String taskId = priceSetService.submitSaveAsync(set);
@@ -98,6 +106,8 @@ public class PriceSetController extends BaseController
@PutMapping
public AjaxResult edit(@Validated @RequestBody PrcPriceSet set)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_PACKAGE);
set.setSetCategory(PrcPriceSet.CATEGORY_PACKAGE);
set.setUpdateBy(getUsername());
return toAjax(priceSetService.updatePrcPriceSet(set));
}
@@ -108,6 +118,7 @@ public class PriceSetController extends BaseController
@PutMapping("/basic")
public AjaxResult editBasic(@RequestBody PrcPriceSet set)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_PACKAGE);
set.setUpdateBy(getUsername());
return toAjax(priceSetService.updateBasicInfo(set));
}
@@ -15,12 +15,19 @@ public class PrcPriceSet extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** Ordinary package (default). */
public static final String CATEGORY_PACKAGE = "PACKAGE";
/** Boss exclusive quote. */
public static final String CATEGORY_EXCLUSIVE = "EXCLUSIVE";
private Long priceSetId;
private String priceSetCode;
private String priceSetName;
private String priceSetNote;
/** FIXED / DISCOUNT */
private String setType;
/** PACKAGE=ordinary set / EXCLUSIVE=boss exclusive quote */
private String setCategory;
private BigDecimal defaultDiscount;
private String defaultType;
private String calculateSign;
@@ -59,10 +59,6 @@ public interface IBmsProductService
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;
@@ -22,6 +22,9 @@ public interface IPrcPriceSetService
public List<PrcPriceSet> selectPrcPriceSetList(PrcPriceSet set);
/** Fail if the package is not in the expected category (PACKAGE / EXCLUSIVE). */
public void assertSetCategory(Long priceSetId, String expectedCategory);
public Long savePrcPriceSet(PrcPriceSet set);
public int updatePrcPriceSet(PrcPriceSet set);
@@ -149,6 +152,13 @@ public interface IPrcPriceSetService
boolean updateOnly, List<PrcPriceSetDetail> existingDetails, boolean replaceByProductCountry, String countryCode)
throws Exception;
/**
* @param exclusiveTemplate true = HALARA large-customer exclusive quote Excel (destination / handling fee)
*/
public Map<String, Object> previewImportDetailPricesMany(List<byte[]> bytesList, List<String> names,
boolean updateOnly, List<PrcPriceSetDetail> existingDetails, boolean replaceByProductCountry, String countryCode,
boolean exclusiveTemplate) throws Exception;
/**
* Sync worker: parse bytes and upsert FIXED detail prices matched by
* product + country + zone(if any) + weight band (insert when missing unless updateOnly).
@@ -2574,7 +2574,7 @@ public class BmsProductServiceImpl implements IBmsProductService
Date sheetEffective = parseSheetEffectiveDate(sheet);
List<BmsProductCountry> list = new ArrayList<>();
int[] countryHeader = findHeaderCell(sheet, "国家");
int[] countryHeader = findHeaderCell(sheet, "\u56fd\u5bb6");
if (countryHeader == null)
{
return list;
@@ -2582,12 +2582,12 @@ public class BmsProductServiceImpl implements IBmsProductService
int headerRowIdx = countryHeader[0];
Row headerRow = sheet.getRow(headerRowIdx);
int colCountry = countryHeader[1];
int colWeight = findColByHeader(headerRow, "重量", colCountry + 1);
int colFreight = findColByHeader(headerRow, "运费", colCountry + 1);
int colRegister = findColByHeader(headerRow, "挂号", colCountry + 1);
int colSize = findColByHeader(headerRow, "尺寸", colCountry + 1);
int colLastMile = findColByHeader(headerRow, "尾程", colCountry + 1);
int colLeadTime = findColByHeader(headerRow, "时效", colCountry + 1);
int colWeight = findColByHeader(headerRow, "\u91cd\u91cf", colCountry + 1);
int colFreight = findColByHeader(headerRow, "\u8fd0\u8d39", colCountry + 1);
int colRegister = findColByHeader(headerRow, "\u6302\u53f7", colCountry + 1);
int colSize = findColByHeader(headerRow, "\u5c3a\u5bf8", colCountry + 1);
int colLastMile = findColByHeader(headerRow, "\u5c3e\u7a0b", colCountry + 1);
int colLeadTime = findColByHeader(headerRow, "\u65f6\u6548", colCountry + 1);
if (colWeight < 0)
{
colWeight = colCountry + 1;
@@ -2627,7 +2627,8 @@ public class BmsProductServiceImpl implements IBmsProductService
}
String countryCell = cellString(row.getCell(colCountry));
String weightLabel = cellString(row.getCell(colWeight));
if ("国家".equals(countryCell) || weightLabel.contains("重量段"))
if ("\u56fd\u5bb6".equals(countryCell)
|| (weightLabel != null && weightLabel.contains("\u91cd\u91cf\u6bb5")))
{
continue;
}
@@ -2847,7 +2848,8 @@ public class BmsProductServiceImpl implements IBmsProductService
{
return null;
}
Matcher m = WEIGHT_RANGE_PATTERN.matcher(label.replace(" ", ""));
String compact = label.replace(" ", "").replace("\u3000", "");
Matcher m = WEIGHT_RANGE_PATTERN.matcher(compact);
if (!m.find())
{
return null;
@@ -47,6 +47,7 @@ import com.bms.price.mapper.PrcPriceSetVersionMapper;
import com.bms.price.mapper.PrcSurchargeMapper;
import com.bms.price.mapper.PrcSurchargeRelMapper;
import com.bms.price.service.IBmsProductService;
import com.bms.price.service.support.ExclusiveHalaraQuoteExcelParser;
import com.bms.price.search.IBillSearchStore;
import com.bms.price.search.PriceChangeEsLog;
import com.bms.price.service.IPrcPriceExcelFileService;
@@ -68,6 +69,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
private static final String STATUS_REJECT = "R";
private static final String STATUS_STOP = "S";
private static final String TYPE_FIXED = "FIXED";
private static final String CATEGORY_PACKAGE = PrcPriceSet.CATEGORY_PACKAGE;
private static final String CATEGORY_EXCLUSIVE = PrcPriceSet.CATEGORY_EXCLUSIVE;
private static final String TYPE_DISCOUNT = "DISCOUNT";
private static final String VER_DRAFT = "0";
private static final String VER_EFFECT = "1";
@@ -114,6 +117,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Autowired
private IBmsProductService productService;
@Autowired
private ExclusiveHalaraQuoteExcelParser exclusiveHalaraQuoteExcelParser;
@Lazy
@Autowired
private PrcPriceSetSaveAsyncRunner saveAsyncRunner;
@@ -228,10 +234,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
set.setSetType(TYPE_FIXED);
}
set.setSetCategory(normalizeSetCategory(set.getSetCategory()));
setMapper.insertPrcPriceSet(set);
if (StringUtils.isEmpty(set.getPriceSetCode()))
{
set.setPriceSetCode(String.format("SET%04d", set.getPriceSetId()));
set.setPriceSetCode(buildPriceSetCode(set.getSetCategory(), set.getPriceSetId()));
setMapper.updatePrcPriceSet(set);
}
}
@@ -240,6 +247,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
PrcPriceSet db = requireSet(set.getPriceSetId());
assertBasicEditable(db);
set.setStatus(db.getStatus());
// Category is immutable after create; keep DB value for name uniqueness scope.
set.setSetCategory(normalizeSetCategory(db.getSetCategory()));
setMapper.updatePrcPriceSet(set);
}
reportSaveProgress(set, 20, "saving version");
@@ -275,6 +284,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
throw new ServiceException(MessageUtils.message("price.set.name.empty"));
}
set.setPriceSetName(set.getPriceSetName().trim());
set.setSetCategory(normalizeSetCategory(db.getSetCategory()));
if (setMapper.countByName(set) > 0)
{
throw new ServiceException(MessageUtils.message("price.set.name.exists"));
@@ -641,6 +651,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
copy.setPriceSetName(src.getPriceSetName() + "-副本");
copy.setPriceSetNote(src.getPriceSetNote());
copy.setSetType(src.getSetType());
copy.setSetCategory(normalizeSetCategory(src.getSetCategory()));
copy.setDefaultDiscount(src.getDefaultDiscount());
copy.setDefaultType(src.getDefaultType());
copy.setCalculateSign(src.getCalculateSign());
@@ -731,8 +742,13 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
set.setCustomers(db.getCustomers());
}
if (StringUtils.isEmpty(set.getSetCategory()))
{
set.setSetCategory(db.getSetCategory());
}
}
}
set.setSetCategory(normalizeSetCategory(set.getSetCategory()));
int nameCount = setMapper.countByName(set);
if (StringUtils.isEmpty(set.getPriceSetName()))
@@ -1201,9 +1217,18 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Override
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
List<PrcPriceSetDetail> existingDetails, boolean replaceByProductCountry, String countryCode) throws Exception
{
return previewImportDetailPrices(bytes, filename, updateOnly, existingDetails, replaceByProductCountry,
countryCode, false);
}
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
List<PrcPriceSetDetail> existingDetails, boolean replaceByProductCountry, String countryCode,
boolean exclusiveTemplate) throws Exception
{
boolean replaceScope = replaceByProductCountry || StringUtils.isNotEmpty(countryCode);
Map<String, List<BmsProductCountry>> byProduct = parseSetImportExcel(bytes, filename, countryCode);
Map<String, List<BmsProductCountry>> byProduct = parseSetImportExcel(bytes, filename, countryCode,
exclusiveTemplate);
if (byProduct == null || byProduct.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
@@ -1391,6 +1416,14 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
public Map<String, Object> previewImportDetailPricesMany(List<byte[]> bytesList, List<String> names,
boolean updateOnly, List<PrcPriceSetDetail> existingDetails, boolean replaceByProductCountry, String countryCode)
throws Exception
{
return previewImportDetailPricesMany(bytesList, names, updateOnly, existingDetails, replaceByProductCountry,
countryCode, false);
}
public Map<String, Object> previewImportDetailPricesMany(List<byte[]> bytesList, List<String> names,
boolean updateOnly, List<PrcPriceSetDetail> existingDetails, boolean replaceByProductCountry, String countryCode,
boolean exclusiveTemplate) throws Exception
{
if (bytesList == null || bytesList.isEmpty())
{
@@ -1407,7 +1440,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
String filename = names != null && i < names.size() ? names.get(i) : null;
last = previewImportDetailPrices(bytesList.get(i), filename, updateOnly, existing, replaceByProductCountry,
countryCode);
countryCode, exclusiveTemplate);
Object details = last == null ? null : last.get("details");
if (details instanceof List)
{
@@ -1429,6 +1462,21 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
private Map<String, List<BmsProductCountry>> parseSetImportExcel(byte[] bytes, String filename, String countryCode)
throws Exception
{
return parseSetImportExcel(bytes, filename, countryCode, false);
}
private Map<String, List<BmsProductCountry>> parseSetImportExcel(byte[] bytes, String filename, String countryCode,
boolean exclusiveTemplate) throws Exception
{
if (exclusiveTemplate)
{
if (StringUtils.isNotEmpty(countryCode))
{
throw new ServiceException(
"\u4e13\u5c5e\u62a5\u4ef7\u4e0d\u652f\u6301\u5355\u56fd\u5bfc\u5165\uff0c\u8bf7\u4e0a\u4f20\u5b8c\u6574\u5927\u5ba2\u6298\u6263\u62a5\u4ef7\u8868");
}
return exclusiveHalaraQuoteExcelParser.parse(bytes, filename);
}
if (StringUtils.isNotEmpty(countryCode))
{
return productService.parseCountryQuoteExcelByProduct(bytes, filename, countryCode);
@@ -1551,7 +1599,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
reportImportProgress(taskId, priceSetId, 20, "parsing excel");
boolean replaceScope = replaceByProductCountry || StringUtils.isNotEmpty(countryCode);
Map<String, List<BmsProductCountry>> byProduct = parseSetImportExcel(bytes, filename, countryCode);
boolean exclusiveTemplate = CATEGORY_EXCLUSIVE.equals(normalizeSetCategory(db.getSetCategory()));
Map<String, List<BmsProductCountry>> byProduct = parseSetImportExcel(bytes, filename, countryCode,
exclusiveTemplate);
if (byProduct == null || byProduct.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
@@ -2746,6 +2796,15 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
throw new ServiceException(MessageUtils.message("price.set.discount.range"));
}
}
if (StringUtils.isEmpty(set.getSetCategory()) && set.getPriceSetId() != null)
{
PrcPriceSet db = setMapper.selectPrcPriceSetHeaderById(set.getPriceSetId());
if (db != null)
{
set.setSetCategory(db.getSetCategory());
}
}
set.setSetCategory(normalizeSetCategory(set.getSetCategory()));
if (setMapper.countByName(set) > 0)
{
throw new ServiceException(MessageUtils.message("price.set.name.exists"));
@@ -2756,6 +2815,34 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
}
/** PACKAGE (default) or EXCLUSIVE. */
public static String normalizeSetCategory(String setCategory)
{
if (CATEGORY_EXCLUSIVE.equalsIgnoreCase(StringUtils.trim(setCategory)))
{
return CATEGORY_EXCLUSIVE;
}
return CATEGORY_PACKAGE;
}
private static String buildPriceSetCode(String setCategory, Long priceSetId)
{
String prefix = CATEGORY_EXCLUSIVE.equals(normalizeSetCategory(setCategory)) ? "EXC" : "SET";
return String.format("%s%04d", prefix, priceSetId);
}
/** Ensure the package belongs to the expected module category. */
public void assertSetCategory(Long priceSetId, String expectedCategory)
{
PrcPriceSet db = requireSet(priceSetId);
String actual = normalizeSetCategory(db.getSetCategory());
String expect = normalizeSetCategory(expectedCategory);
if (!expect.equals(actual))
{
throw new ServiceException(MessageUtils.message("price.set.notExists"));
}
}
private void validateDetail(PrcPriceSet set, PrcPriceSetDetail detail)
{
if (StringUtils.isEmpty(detail.getProductCode()))
@@ -0,0 +1,913 @@
package com.bms.price.service.support;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
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.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
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.Component;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.PrcZone;
import com.bms.price.mapper.PrcZoneMapper;
import com.bms.price.util.ZoneCodeAbbrevs;
import com.bms.system.domain.BmsRegion;
import com.bms.system.mapper.BmsRegionMapper;
/**
* Exclusive / large-customer (HALARA) quote Excel parser.
* Completely separate from ordinary package / product quote Excel parsing.
*
* Catalog columns: category / country / level / product name / price sheet / product code / feature / lead time.
* Price sheets: destination / origin / weight / freight(CNY/KG) / handling(CNY/PCS).
*/
@Component
public class ExclusiveHalaraQuoteExcelParser
{
private static final String CATALOG_SHEET = "\u4ea7\u54c1\u76ee\u5f55";
/** Parse 0.01<=W<=0.5 / 0.3<W<=1 */
private static final Pattern WEIGHT_W_PATTERN = Pattern.compile(
"(\\d+(?:\\.\\d+)?)\\s*[<\u2264]=?\\s*W\\s*[<\u2264]=?\\s*(\\d+(?:\\.\\d+)?)",
Pattern.CASE_INSENSITIVE);
/** Parse HALARA bands: 0-0.3kg / 0kg-1kg / 1.01kg-2kg */
private static final Pattern WEIGHT_DASH_PATTERN = Pattern.compile(
"(?i)(\\d+(?:\\.\\d+)?)\\s*(?:kg)?\\s*[-~\uFF5E\u2014\u2013]\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg)?");
@Autowired
private BmsRegionMapper regionMapper;
@Autowired
private PrcZoneMapper zoneMapper;
public Map<String, List<BmsProductCountry>> parse(byte[] bytes, String filename) throws Exception
{
if (bytes == null || bytes.length == 0)
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u5bfc\u5165\u6587\u4ef6\u4e3a\u7a7a");
}
if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx")
|| filename.toLowerCase(Locale.ROOT).endsWith(".xls")))
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u4ec5\u652f\u6301 .xls / .xlsx");
}
try (InputStream is = new ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is))
{
assertHalaraFormat(workbook);
Sheet catalog = workbook.getSheet(CATALOG_SHEET);
if (catalog == null)
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u7f3a\u5c11\u300c\u4ea7\u54c1\u76ee\u5f55\u300d");
}
List<BmsProduct> products = dedupeByCode(parseCatalog(catalog));
if (products.isEmpty())
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u4ea7\u54c1\u76ee\u5f55\u65e0\u6709\u6548\u4ea7\u54c1");
}
Map<String, List<BmsProduct>> ownersBySheet = new LinkedHashMap<String, List<BmsProduct>>();
for (BmsProduct product : products)
{
if (product == null || StringUtils.isEmpty(product.getProductCode()))
{
continue;
}
Sheet productSheet = findSheetForProduct(workbook, product);
if (productSheet == null)
{
continue;
}
List<BmsProduct> owners = ownersBySheet.get(productSheet.getSheetName());
if (owners == null)
{
owners = new ArrayList<BmsProduct>();
ownersBySheet.put(productSheet.getSheetName(), owners);
}
owners.add(product);
}
Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn();
List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode();
Map<String, List<BmsProductCountry>> out = new LinkedHashMap<String, List<BmsProductCountry>>();
List<String> dupTips = new ArrayList<String>();
for (int i = 0; i < workbook.getNumberOfSheets(); i++)
{
String sheetName = workbook.getSheetName(i);
if (shouldSkipSheet(sheetName))
{
continue;
}
Sheet sheet = workbook.getSheetAt(i);
if (sheet == null || findHeaderCell(sheet, "\u76ee\u7684\u5730") == null)
{
continue;
}
List<BmsProduct> owners = ownersBySheet.get(sheetName);
if (owners == null || owners.isEmpty())
{
continue;
}
for (BmsProduct owner : owners)
{
List<BmsProductCountry> rows = parsePriceSheet(sheet, owner, regionByNameCn, euRegions,
zoneByCountryAndCode);
String tip = dupCountryWeightTip(owner, rows);
if (StringUtils.isNotEmpty(tip))
{
dupTips.add(tip);
}
if (rows == null || rows.isEmpty())
{
continue;
}
String key = owner.getProductCode().trim().toUpperCase(Locale.ROOT);
List<BmsProductCountry> existing = out.get(key);
if (existing == null)
{
out.put(key, new ArrayList<BmsProductCountry>(rows));
}
else
{
existing.addAll(rows);
}
}
}
if (!dupTips.isEmpty())
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u5b58\u5728\u91cd\u590d\u56fd\u5bb6+\u91cd\u91cf\u6bb5\uff1a<br/>" + String.join("<br/>", dupTips));
}
if (out.isEmpty())
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u672a\u89e3\u6790\u5230\u6709\u6548\u4ef7\u683c\u884c");
}
return out;
}
}
private void assertHalaraFormat(Workbook workbook)
{
boolean hasDest = false;
for (int i = 0; i < workbook.getNumberOfSheets(); i++)
{
String sheetName = workbook.getSheetName(i);
if (shouldSkipSheet(sheetName))
{
continue;
}
Sheet sheet = workbook.getSheetAt(i);
if (sheet != null && findHeaderCell(sheet, "\u76ee\u7684\u5730") != null)
{
hasDest = true;
break;
}
}
if (!hasDest)
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u4ec5\u652f\u6301\u5927\u5ba2\u6298\u6263\u62a5\u4ef7\u8868\uff08\u542b\u300c\u76ee\u7684\u5730\u300d\u300c\u8fd0\u8d39\u300d\u300c\u64cd\u4f5c\u8d39\u300d\u5217\uff09\uff0c\u8bf7\u52ff\u4f7f\u7528\u666e\u901a\u5957\u9910 Excel");
}
}
private static boolean shouldSkipSheet(String name)
{
if (StringUtils.isEmpty(name))
{
return true;
}
String n = name.trim();
if (CATALOG_SHEET.equals(n))
{
return true;
}
if ("Sheet1".equalsIgnoreCase(n) || "Sheet2".equalsIgnoreCase(n))
{
return true;
}
if (n.contains("\u90ae\u7f16") || n.contains("\u8d54\u507f") || n.contains("\u9000\u4ef6")
|| n.contains("\u7533\u62a5"))
{
return true;
}
return n.contains("\u7a0e\u7387") || n.toUpperCase(Locale.ROOT).contains("VAT");
}
private List<BmsProduct> parseCatalog(Sheet sheet)
{
List<BmsProduct> list = new ArrayList<BmsProduct>();
int[] header = findHeaderCell(sheet, "\u4ea7\u54c1\u4ee3\u7801");
if (header == null)
{
header = findHeaderCell(sheet, "\u4ea7\u54c1\u540d\u79f0");
}
if (header == null)
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u300c\u4ea7\u54c1\u76ee\u5f55\u300d\u672a\u627e\u5230\u8868\u5934\u300c\u4ea7\u54c1\u4ee3\u7801\u300d/\u300c\u4ea7\u54c1\u540d\u79f0\u300d");
}
int headerRowIdx = header[0];
Row head = sheet.getRow(headerRowIdx);
int colCode = findColByHeader(head, "\u4ea7\u54c1\u4ee3\u7801", 0);
int colName = findColByHeader(head, "\u4ea7\u54c1\u540d\u79f0", 0);
int colLevel = findColByHeader(head, "\u7b49\u7ea7", 0);
if (colLevel < 0)
{
colLevel = findColByHeader(head, "\u65f6\u6548", 0);
}
int colCargo = findColByHeader(head, "\u4ea7\u54c1\u7279\u6027", 0);
if (colCargo < 0)
{
colCargo = findColByHeader(head, "\u8d70\u8d27\u5c5e\u6027", 0);
}
int colDest = findColByHeader(head, "\u56fd\u5bb6", 0);
if (colDest < 0)
{
colDest = findColByHeader(head, "\u76ee\u7684", 0);
}
int colLead = findColByHeader(head, "\u53c2\u8003\u65f6\u6548", 0);
if (colCode < 0 && colName < 0)
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u300c\u4ea7\u54c1\u76ee\u5f55\u300d\u7f3a\u5c11\u300c\u4ea7\u54c1\u4ee3\u7801\u300d/\u300c\u4ea7\u54c1\u540d\u79f0\u300d\u5217");
}
int lastRow = sheet.getLastRowNum();
for (int i = headerRowIdx + 1; i <= lastRow; i++)
{
Row row = sheet.getRow(i);
if (row == null)
{
continue;
}
String rawCode = colCode >= 0 ? cellString(row.getCell(colCode)) : "";
String name = colName >= 0 ? cellString(row.getCell(colName)) : "";
if (StringUtils.isEmpty(rawCode) && StringUtils.isEmpty(name))
{
continue;
}
if ("\u4ea7\u54c1\u4ee3\u7801".equals(rawCode) || "\u4ea7\u54c1\u540d\u79f0".equals(name))
{
continue;
}
String code = normalizeCode(rawCode);
if (StringUtils.isEmpty(code))
{
continue;
}
BmsProduct p = new BmsProduct();
p.setProductCode(code);
p.setProductName(StringUtils.isNotEmpty(name) ? name : code);
if (colLevel >= 0)
{
p.setServiceLevel(cellString(row.getCell(colLevel)));
}
if (colCargo >= 0)
{
p.setCargoAttr(cellString(row.getCell(colCargo)));
}
if (colDest >= 0)
{
p.setDestRegion(cellString(row.getCell(colDest)));
}
if (colLead >= 0)
{
p.setUpdateNote(cellString(row.getCell(colLead)));
}
list.add(p);
}
return list;
}
/** Keep dots in codes such as GB.PEXP / DE.PEXP. */
private static String normalizeCode(String raw)
{
if (StringUtils.isEmpty(raw))
{
return null;
}
String s = raw.trim().replaceAll("[\\(\\uFF08][^\\)\\uFF09]*[\\)\\uFF09]", "").trim();
if (StringUtils.isEmpty(s))
{
return null;
}
Matcher m = Pattern.compile("(?i)^([A-Z0-9._-]+)").matcher(s);
if (!m.find())
{
return null;
}
return m.group(1).toUpperCase(Locale.ROOT);
}
private List<BmsProduct> dedupeByCode(List<BmsProduct> products)
{
Map<String, BmsProduct> map = new LinkedHashMap<String, BmsProduct>();
for (BmsProduct p : products)
{
if (p == null || StringUtils.isEmpty(p.getProductCode()))
{
continue;
}
String key = p.getProductCode().trim().toUpperCase(Locale.ROOT);
if (!map.containsKey(key))
{
map.put(key, p);
}
}
return new ArrayList<BmsProduct>(map.values());
}
private Sheet findSheetForProduct(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 && !shouldSkipSheet(exact.getSheetName()))
{
return exact;
}
}
String nameKey = normalizeKey(wantName);
String codeKey = normalizeKey(wantCode);
String nameBase = sheetNameBase(nameKey);
Sheet best = null;
int bestScore = 0;
for (int i = 0; i < workbook.getNumberOfSheets(); i++)
{
String sheetName = workbook.getSheetName(i);
if (shouldSkipSheet(sheetName))
{
continue;
}
String key = normalizeKey(sheetName);
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(nameBase) && sheetNameBase(key).equals(nameBase)
&& nameBase.length() >= 2)
{
// e.g. catalog "DE express-general" -> sheet "DE express"
score = 70;
}
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 normalizeKey(String s)
{
if (s == null)
{
return "";
}
String out = s.trim().toLowerCase(Locale.ROOT);
out = out.replace('\u2013', '-').replace('\u2014', '-').replace('\uFF0D', '-');
out = out.replace(" ", "").replace("\u3000", "");
return out;
}
private static String sheetNameBase(String normalizedKey)
{
if (StringUtils.isEmpty(normalizedKey))
{
return "";
}
int idx = normalizedKey.lastIndexOf('-');
if (idx <= 0)
{
return normalizedKey;
}
return normalizedKey.substring(0, idx);
}
private List<BmsProductCountry> parsePriceSheet(Sheet sheet, BmsProduct product,
Map<String, BmsRegion> regionByNameCn, List<BmsRegion> euRegions,
Map<String, PrcZone> zoneByCountryAndCode)
{
List<BmsProductCountry> list = new ArrayList<BmsProductCountry>();
if (sheet == null)
{
return list;
}
int[] destHeader = findHeaderCell(sheet, "\u76ee\u7684\u5730");
if (destHeader == null)
{
return list;
}
int headerRowIdx = destHeader[0];
Row headerRow = sheet.getRow(headerRowIdx);
int colDest = destHeader[1];
int colWeight = findColByHeader(headerRow, "\u91cd\u91cf", colDest + 1);
int colFreight = findColByHeader(headerRow, "\u8fd0\u8d39", colDest + 1);
int colHandle = findColByHeader(headerRow, "\u64cd\u4f5c\u8d39", colDest + 1);
if (colHandle < 0)
{
colHandle = findColByHeader(headerRow, "\u6302\u53f7", colDest + 1);
}
int colLead = findColByHeader(headerRow, "\u65f6\u6548", colDest + 1);
int colFeature = findColByHeader(headerRow, "\u4ea7\u54c1\u7279\u6027", colDest + 1);
int colRemark = findColByHeader(headerRow, "\u5907\u6ce8", colDest + 1);
if (colWeight < 0)
{
colWeight = colDest + 2;
}
if (colFreight < 0)
{
colFreight = colDest + 3;
}
if (colHandle < 0)
{
colHandle = colDest + 4;
}
String currentDest = null;
String currentLead = null;
String currentRemark = null;
int lastRow = sheet.getLastRowNum();
for (int i = headerRowIdx + 1; i <= lastRow; i++)
{
Row row = sheet.getRow(i);
if (row == null)
{
continue;
}
String destCell = cellString(row.getCell(colDest));
String weightLabel = cellString(row.getCell(colWeight));
if ("\u76ee\u7684\u5730".equals(destCell) || (weightLabel != null && weightLabel.contains("\u91cd\u91cf\u6bb5")))
{
continue;
}
if (StringUtils.isNotEmpty(destCell))
{
currentDest = destCell;
currentLead = colLead >= 0 ? emptyToNull(cellString(row.getCell(colLead))) : null;
currentRemark = colRemark >= 0 ? emptyToNull(cellString(row.getCell(colRemark))) : null;
}
if (StringUtils.isEmpty(currentDest))
{
continue;
}
if (currentDest.startsWith("\u4ea7\u54c1\u5b9a\u4e49") || currentDest.startsWith("\u4e00\u3001")
|| currentDest.startsWith("\u4e8c\u3001") || currentDest.contains("\u670d\u52a1\u6761\u4ef6"))
{
break;
}
BigDecimal[] range = parseWeightRange(weightLabel);
if (range == null)
{
continue;
}
BigDecimal freight = cellDecimal(row.getCell(colFreight));
BigDecimal handling = cellDecimal(row.getCell(colHandle));
ZoneCodeAbbrevs.CountryZoneParts parts = ZoneCodeAbbrevs.splitCountryZone(currentDest);
if (parts == null || StringUtils.isEmpty(parts.countryName))
{
continue;
}
String baseCountry = parts.countryName;
String zoneLabel = parts.zoneLabel;
String feature = colFeature >= 0 ? emptyToNull(cellString(row.getCell(colFeature))) : null;
String sizeOrRemark = feature != null ? feature : currentRemark;
BmsRegion region = resolveRegion(baseCountry, regionByNameCn);
if (region != null)
{
list.add(buildRow(product, region.getCountryNameCn(), region.getCountryCode2(), zoneLabel,
zoneByCountryAndCode, range, freight, handling, sizeOrRemark, currentRemark, currentLead));
continue;
}
if (isEuName(baseCountry) && StringUtils.isEmpty(zoneLabel))
{
if (euRegions == null || euRegions.isEmpty())
{
throw new ServiceException("\u4e13\u5c5e\u62a5\u4ef7\u89e3\u6790\u5230\u300c\u6b27\u76df\u300d\u4f46\u533a\u57df\u5e93\u65e0\u6b27\u76df\u56fd\u5bb6");
}
for (BmsRegion eu : euRegions)
{
list.add(buildRow(product, eu.getCountryNameCn(), eu.getCountryCode2(), null,
zoneByCountryAndCode, range, freight, handling, sizeOrRemark, currentRemark, currentLead));
}
continue;
}
list.add(buildRow(product, baseCountry, null, zoneLabel, zoneByCountryAndCode, range, freight, handling,
sizeOrRemark, currentRemark, currentLead));
}
return list;
}
private BmsProductCountry buildRow(BmsProduct product, String countryName, String countryCode, String zoneLabel,
Map<String, PrcZone> zoneByCountryAndCode, BigDecimal[] range, BigDecimal freight, BigDecimal handling,
String sizeLimit, String lastMile, String leadTime)
{
BmsProductCountry c = new BmsProductCountry();
if (product != null)
{
c.setProductCode(product.getProductCode());
c.setProductName(product.getProductName());
}
c.setCountryName(countryName);
c.setCountryCode(countryCode);
applyZone(c, countryCode, zoneLabel, zoneByCountryAndCode);
c.setWeightMin(range[0]);
c.setWeightMax(range[1]);
c.setFreightPrice(freight);
c.setRegistrationFee(handling);
c.setSizeLimit(sizeLimit);
c.setLastMile(lastMile);
c.setLeadTime(leadTime);
return c;
}
private void applyZone(BmsProductCountry c, String countryCode, String zoneLabel,
Map<String, PrcZone> zoneByCountryAndCode)
{
if (StringUtils.isEmpty(zoneLabel))
{
c.setZoneId(null);
c.setZoneCode(null);
c.setZoneName(null);
return;
}
String zoneCode = ZoneCodeAbbrevs.toAbbrev(zoneLabel);
c.setZoneCode(zoneCode);
PrcZone zone = null;
if (StringUtils.isNotEmpty(countryCode) && zoneByCountryAndCode != null)
{
zone = zoneByCountryAndCode.get(countryCode.trim().toUpperCase(Locale.ROOT) + "|"
+ zoneCode.trim().toUpperCase(Locale.ROOT));
}
if (zone != null)
{
c.setZoneId(zone.getZoneId());
c.setZoneCode(zone.getZoneCode());
c.setZoneName(zone.getZoneName());
}
else
{
c.setZoneId(null);
c.setZoneName(StringUtils.isNotEmpty(c.getCountryName())
? c.getCountryName() + "(" + zoneCode + ")"
: zoneCode);
}
}
private BigDecimal[] parseWeightRange(String label)
{
if (StringUtils.isEmpty(label))
{
return null;
}
String compact = label.replace(" ", "").replace("\u3000", "");
Matcher m = WEIGHT_W_PATTERN.matcher(compact);
if (!m.find())
{
m = WEIGHT_DASH_PATTERN.matcher(compact);
if (!m.find())
{
return null;
}
}
try
{
BigDecimal min = new BigDecimal(m.group(1));
BigDecimal max = new BigDecimal(m.group(2));
if (min.compareTo(max) > 0)
{
BigDecimal tmp = min;
min = max;
max = tmp;
}
return new BigDecimal[] { min, max };
}
catch (Exception e)
{
return null;
}
}
private String dupCountryWeightTip(BmsProduct product, List<BmsProductCountry> rows)
{
if (rows == null || rows.isEmpty())
{
return null;
}
Set<String> seen = new HashSet<String>();
for (BmsProductCountry r : rows)
{
if (r == null)
{
continue;
}
String key = String.valueOf(r.getCountryCode()) + "|" + String.valueOf(r.getCountryName())
+ "|" + String.valueOf(r.getZoneCode()) + "|"
+ (r.getWeightMin() == null ? "" : r.getWeightMin().toPlainString()) + "-"
+ (r.getWeightMax() == null ? "" : r.getWeightMax().toPlainString());
if (!seen.add(key))
{
String name = product == null ? "" : product.getProductName();
String code = product == null ? "" : product.getProductCode();
return (StringUtils.isNotEmpty(name) ? name : code) + " \u91cd\u590d\u56fd\u5bb6+\u91cd\u91cf\u6bb5";
}
}
return null;
}
private Map<String, BmsRegion> loadRegionByNameCn()
{
BmsRegion query = new BmsRegion();
query.setStatus("0");
List<BmsRegion> regions = regionMapper.selectRegionList(query);
Map<String, BmsRegion> map = new HashMap<String, BmsRegion>();
if (regions == null)
{
return map;
}
for (BmsRegion region : regions)
{
if (region == null || StringUtils.isEmpty(region.getCountryNameCn()))
{
continue;
}
map.put(region.getCountryNameCn().trim(), region);
}
return map;
}
private List<BmsRegion> loadEuRegions(Map<String, BmsRegion> regionByNameCn)
{
List<BmsRegion> list = new ArrayList<BmsRegion>();
if (regionByNameCn == null)
{
return list;
}
Set<String> seen = new HashSet<String>();
for (BmsRegion region : regionByNameCn.values())
{
if (region == null || !"1".equals(region.getIsEu()))
{
continue;
}
if (StringUtils.isEmpty(region.getCountryCode2()))
{
continue;
}
String code = region.getCountryCode2().toUpperCase(Locale.ROOT);
if (seen.add(code))
{
list.add(region);
}
}
return list;
}
private Map<String, PrcZone> loadZoneByCountryAndCode()
{
Map<String, PrcZone> map = new HashMap<String, PrcZone>();
List<PrcZone> list = zoneMapper.selectPrcZoneList(new PrcZone());
if (list == null)
{
return map;
}
for (PrcZone zone : list)
{
if (zone == null || StringUtils.isEmpty(zone.getZoneCode()) || StringUtils.isEmpty(zone.getCountryCode()))
{
continue;
}
map.put(zone.getCountryCode().trim().toUpperCase(Locale.ROOT) + "|"
+ zone.getZoneCode().trim().toUpperCase(Locale.ROOT), zone);
}
return map;
}
private BmsRegion resolveRegion(String excelName, Map<String, BmsRegion> regionByNameCn)
{
if (StringUtils.isEmpty(excelName) || regionByNameCn == null || regionByNameCn.isEmpty())
{
return null;
}
String name = excelName.trim();
BmsRegion exact = regionByNameCn.get(name);
if (exact != null)
{
return exact;
}
List<BmsRegion> candidates = new ArrayList<BmsRegion>();
for (Map.Entry<String, BmsRegion> e : regionByNameCn.entrySet())
{
String cn = e.getKey();
if (cn == null)
{
continue;
}
if (cn.contains(name) || name.contains(cn))
{
candidates.add(e.getValue());
}
}
if (candidates.isEmpty())
{
return null;
}
if (candidates.size() == 1)
{
return candidates.get(0);
}
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;
}
private static boolean isEuName(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) || "\u4e1c\u6b27".equals(n);
}
private static int[] findHeaderCell(Sheet sheet, String headerText)
{
if (sheet == null || StringUtils.isEmpty(headerText))
{
return null;
}
int lastRow = Math.min(sheet.getLastRowNum(), 30);
int[] containsHit = null;
for (int r = 0; r <= lastRow; r++)
{
Row row = sheet.getRow(r);
if (row == null)
{
continue;
}
int maxCol = Math.min(Math.max(row.getLastCellNum(), 12), 20);
for (int c = 0; c < maxCol; c++)
{
String v = cellString(row.getCell(c));
if (StringUtils.isEmpty(v))
{
continue;
}
if (headerText.equals(v))
{
return new int[] { r, c };
}
if (containsHit == null && v.contains(headerText))
{
containsHit = new int[] { r, c };
}
}
}
return containsHit;
}
private static int findColByHeader(Row headerRow, String keyword, int fromCol)
{
if (headerRow == null || StringUtils.isEmpty(keyword))
{
return -1;
}
int maxCol = Math.min(Math.max(headerRow.getLastCellNum(), fromCol + 8), 20);
for (int c = Math.max(fromCol, 0); c < maxCol; c++)
{
String v = cellString(headerRow.getCell(c));
if (StringUtils.isNotEmpty(v) && v.contains(keyword))
{
return c;
}
}
return -1;
}
private static String cellString(Cell cell)
{
if (cell == null)
{
return "";
}
CellType type = cell.getCellType();
if (type == CellType.FORMULA)
{
type = cell.getCachedFormulaResultType();
}
switch (type)
{
case STRING:
return cell.getStringCellValue() == null ? "" : cell.getStringCellValue().trim();
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell))
{
Date d = cell.getDateCellValue();
return d == null ? "" : new SimpleDateFormat("yyyy-MM-dd").format(d);
}
double num = cell.getNumericCellValue();
if (Math.floor(num) == num)
{
return String.valueOf((long) num);
}
return String.valueOf(num);
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
case BLANK:
return "";
default:
return "";
}
}
private static BigDecimal cellDecimal(Cell cell)
{
if (cell == null)
{
return null;
}
CellType type = cell.getCellType();
if (type == CellType.FORMULA)
{
type = cell.getCachedFormulaResultType();
}
try
{
if (type == CellType.NUMERIC)
{
return BigDecimal.valueOf(cell.getNumericCellValue());
}
String s = cellString(cell);
if (StringUtils.isEmpty(s))
{
return null;
}
s = s.replace(",", "").replace("\uFFE5", "").replace("CNY", "").trim();
if (StringUtils.isEmpty(s))
{
return null;
}
return new BigDecimal(s);
}
catch (Exception e)
{
return null;
}
}
private static String emptyToNull(String s)
{
return StringUtils.isEmpty(s) ? null : s;
}
}
@@ -10,6 +10,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="priceSetName" column="price_set_name" />
<result property="priceSetNote" column="price_set_note" />
<result property="setType" column="set_type" />
<result property="setCategory" column="set_category" />
<result property="defaultDiscount" column="default_discount" />
<result property="defaultType" column="default_type" />
<result property="calculateSign" column="calculate_sign" />
@@ -26,7 +27,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectPrcPriceSetVo">
select s.price_set_id, s.price_set_code, s.price_set_name, s.price_set_note,
s.set_type, s.default_discount, s.default_type, s.calculate_sign,
s.set_type, ifnull(s.set_category, 'PACKAGE') as set_category,
s.default_discount, s.default_type, s.calculate_sign,
s.status, s.tms_id,
s.created_by, s.create_time, s.updated_by, s.update_time,
(select count(distinct p.product_code)
@@ -52,7 +54,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectPrcPriceSetHeaderById" parameterType="Long" resultMap="PrcPriceSetResult">
select s.price_set_id, s.price_set_code, s.price_set_name, s.price_set_note,
s.set_type, s.default_discount, s.default_type, s.calculate_sign,
s.set_type, ifnull(s.set_category, 'PACKAGE') as set_category,
s.default_discount, s.default_type, s.calculate_sign,
s.status, s.tms_id,
s.created_by, s.create_time, s.updated_by, s.update_time,
null as product_count, null as customer_count, null as bound_customers
@@ -66,6 +69,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="setType != null and setType != ''">
AND s.set_type = #{setType}
</if>
<if test="setCategory != null and setCategory != ''">
AND ifnull(s.set_category, 'PACKAGE') = #{setCategory}
</if>
<if test="status != null and status != ''">
AND s.status = #{status}
</if>
@@ -126,6 +132,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="tmsId != null">
AND tms_id = #{tmsId}
</if>
<if test="setCategory != null and setCategory != ''">
AND ifnull(set_category, 'PACKAGE') = #{setCategory}
</if>
</select>
<insert id="insertPrcPriceSet" parameterType="PrcPriceSet" useGeneratedKeys="true" keyProperty="priceSetId">
@@ -134,6 +143,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priceSetName != null and priceSetName != ''">price_set_name,</if>
<if test="priceSetNote != null">price_set_note,</if>
<if test="setType != null and setType != ''">set_type,</if>
<if test="setCategory != null and setCategory != ''">set_category,</if>
<if test="defaultDiscount != null">default_discount,</if>
<if test="defaultType != null">default_type,</if>
<if test="calculateSign != null">calculate_sign,</if>
@@ -146,6 +156,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priceSetName != null and priceSetName != ''">#{priceSetName},</if>
<if test="priceSetNote != null">#{priceSetNote},</if>
<if test="setType != null and setType != ''">#{setType},</if>
<if test="setCategory != null and setCategory != ''">#{setCategory},</if>
<if test="defaultDiscount != null">#{defaultDiscount},</if>
<if test="defaultType != null">#{defaultType},</if>
<if test="calculateSign != null">#{calculateSign},</if>
@@ -163,6 +174,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priceSetName != null and priceSetName != ''">price_set_name = #{priceSetName},</if>
<if test="priceSetNote != null">price_set_note = #{priceSetNote},</if>
<if test="setType != null and setType != ''">set_type = #{setType},</if>
<if test="setCategory != null and setCategory != ''">set_category = #{setCategory},</if>
default_discount = #{defaultDiscount},
<if test="defaultType != null">default_type = #{defaultType},</if>
<if test="calculateSign != null">calculate_sign = #{calculateSign},</if>
+2
View File
@@ -1220,6 +1220,7 @@ CREATE TABLE `prc_price_set` (
`price_set_name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价名称',
`price_set_note` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`set_type` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'FIXED' COMMENT '计价类型 FIXED=固定价 DISCOUNT=折扣',
`set_category` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PACKAGE' COMMENT '分类 PACKAGE=普通套餐 EXCLUSIVE=专属报价',
`default_discount` decimal(5,2) DEFAULT NULL COMMENT '默认折扣百分数 1-99,仅 DISCOUNT',
`default_type` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '默认套餐 Y=默认',
`calculate_sign` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '试算标记 Y=外部试算套餐',
@@ -1233,6 +1234,7 @@ CREATE TABLE `prc_price_set` (
UNIQUE KEY `uk_price_set_code` (`price_set_code`),
KEY `idx_ps_status` (`status`),
KEY `idx_ps_type` (`set_type`),
KEY `idx_ps_category` (`set_category`),
KEY `idx_ps_tms` (`tms_id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='价格方案表-报价基本资料';
@@ -0,0 +1,32 @@
-- Exclusive quote: share prc_price_set via set_category
SET NAMES utf8mb4;
SET @col_exists := (
SELECT COUNT(1) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'prc_price_set'
AND column_name = 'set_category'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE prc_price_set ADD COLUMN set_category varchar(20) NOT NULL DEFAULT ''PACKAGE'' COMMENT ''分类 PACKAGE=普通套餐 EXCLUSIVE=专属报价'' AFTER set_type',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE prc_price_set
SET set_category = 'PACKAGE'
WHERE set_category IS NULL OR set_category = '';
SET @idx_exists := (
SELECT COUNT(1) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'prc_price_set'
AND index_name = 'idx_ps_category'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE prc_price_set ADD KEY idx_ps_category (set_category)',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,59 @@
-- Exclusive quote module menus
SET NAMES utf8mb4;
-- Top-level directory
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2600, N'专属报价', 0, 55, 'exclusive', NULL, '', '', 1, 0, 'M', '0', '0', '', 'money', 'admin', sysdate(), N'老板客户专属报价'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2600 OR (path = 'exclusive' AND parent_id = 0 AND menu_type = 'M'));
SET @exParentId := (SELECT menu_id FROM sys_menu WHERE menu_id = 2600 LIMIT 1);
SET @exParentId := IFNULL(@exParentId, (
SELECT menu_id FROM sys_menu WHERE path = 'exclusive' AND menu_type = 'M' AND parent_id = 0 ORDER BY menu_id ASC LIMIT 1
));
-- List page
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2601, N'报价列表', @exParentId, 1, 'quote', 'price/exclusive/exclusiveQuote', '', 'ExclusiveQuote', 1, 0, 'C', '0', '0', 'price:exclusive:list', 'list', 'admin', sysdate(), N'专属报价列表'
FROM DUAL WHERE @exParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2601 OR perms = 'price:exclusive:list');
-- Hidden create / detail
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2609, N'报价新建', @exParentId, 2, 'quoteCreate', 'price/exclusive/create', '', 'ExclusiveQuoteCreate', 1, 0, 'C', '1', '0', 'price:exclusive:add', '#', 'admin', sysdate(), N'专属报价新建/编辑'
FROM DUAL WHERE @exParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2609 OR path = 'quoteCreate');
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2610, N'报价详情', @exParentId, 3, 'quoteDetail', 'price/exclusive/detail', '', 'ExclusiveQuoteDetail', 1, 0, 'C', '1', '0', 'price:exclusive:query', '#', 'admin', sysdate(), N'专属报价详情'
FROM DUAL WHERE @exParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2610 OR path = 'quoteDetail');
-- Buttons under list
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2602, N'查询', 2601, 1, '', NULL, 1, 0, 'F', '0', '0', 'price:exclusive:query', '#', 'admin', sysdate(), N''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2602 OR (parent_id = 2601 AND perms = 'price:exclusive:query' AND menu_type = 'F'));
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2603, N'新增', 2601, 2, '', NULL, 1, 0, 'F', '0', '0', 'price:exclusive:add', '#', 'admin', sysdate(), N''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2603 OR (parent_id = 2601 AND perms = 'price:exclusive:add' AND menu_type = 'F'));
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2604, N'修改', 2601, 3, '', NULL, 1, 0, 'F', '0', '0', 'price:exclusive:edit', '#', 'admin', sysdate(), N''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2604 OR (parent_id = 2601 AND perms = 'price:exclusive:edit' AND menu_type = 'F'));
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2605, N'删除', 2601, 4, '', NULL, 1, 0, 'F', '0', '0', 'price:exclusive:remove', '#', 'admin', sysdate(), N''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2605 OR (parent_id = 2601 AND perms = 'price:exclusive:remove' AND menu_type = 'F'));
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2606, N'启停/审核', 2601, 5, '', NULL, 1, 0, 'F', '0', '0', 'price:exclusive:audit', '#', 'admin', sysdate(), N''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2606 OR (parent_id = 2601 AND perms = 'price:exclusive:audit' AND menu_type = 'F'));
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2607, N'强制改价', 2601, 6, '', NULL, 1, 0, 'F', '0', '0', 'price:exclusive:forceEdit', '#', 'admin', sysdate(), N''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2607 OR (parent_id = 2601 AND perms = 'price:exclusive:forceEdit' AND menu_type = 'F'));
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT 1, m.menu_id FROM (
SELECT 2600 AS menu_id UNION SELECT 2601 UNION SELECT 2602 UNION SELECT 2603
UNION SELECT 2604 UNION SELECT 2605 UNION SELECT 2606 UNION SELECT 2607
UNION SELECT 2609 UNION SELECT 2610
) m
WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id);
@@ -0,0 +1,484 @@
import request from '@/utils/request'
/** Build price-set style API against a controller base path (e.g. /price/set, /price/exclusive). */
export function createPriceSetApi(basePath) {
const base = String(basePath || '/price/set').replace(/\/$/, '')
function listPriceSet(query) {
return request({
url: base + '/list',
method: 'get',
params: query
})
}
function getPriceSet(priceSetId) {
return request({
url: base + '/' + priceSetId,
method: 'get'
})
}
function savePriceSet(data) {
return request({
url: base + '',
method: 'post',
data: data
})
}
function getPriceSetSaveTask(taskId) {
return request({
url: base + '/save/task/' + taskId,
method: 'get',
timeout: 10 * 60 * 1000
})
}
/**
* Submit save async and poll Redis progress until SUCCESS/FAIL.
* @returns {Promise<{ priceSetId: number|string, task: object }>}
*/
async function savePriceSetAsync(data, onProgress) {
const submitRes = await savePriceSet(data)
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing save taskId')
}
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
const res = await getPriceSetSaveTask(taskId)
const task = res.data || {}
if (typeof onProgress === 'function') {
onProgress(task)
}
if (task.status === 'SUCCESS') {
return { priceSetId: task.priceSetId, task }
}
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'save failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('save timeout')
}
/** Accept data.taskId / data string / legacy msg UUID (Bms String overload). */
function pickSaveTaskId(res) {
if (!res) return null
const d = res.data
if (d && typeof d === 'object' && d.taskId) return String(d.taskId)
if (typeof d === 'string' && d.trim()) return d.trim()
if (typeof res.msg === 'string') {
const m = res.msg.trim()
if (/^[a-f0-9]{32}$/i.test(m)) return m
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(m)) {
return m.replace(/-/g, '')
}
}
return null
}
function updatePriceSet(data) {
return request({
url: base + '',
method: 'put',
data: data
})
}
/** Update package name and note only. */
function updatePriceSetBasic(data) {
return request({
url: base + '/basic',
method: 'put',
data: data
})
}
function delPriceSet(priceSetIds) {
return request({
url: base + '/' + priceSetIds,
method: 'delete'
})
}
function submitPriceSet(priceSetId) {
return request({
url: base + '/submit/' + priceSetId,
method: 'put'
})
}
function approvePriceSet(priceSetId) {
return request({
url: base + '/approve/' + priceSetId,
method: 'put'
})
}
function rejectPriceSet(priceSetId) {
return request({
url: base + '/reject/' + priceSetId,
method: 'put'
})
}
function activatePriceSetVersion(psvId, data) {
return request({
url: base + '/version/activate/' + psvId,
method: 'put',
data: data || {}
})
}
function copyPriceSetVersion(psvId) {
return request({
url: base + '/version/copy/' + psvId,
method: 'post'
})
}
/** Create empty draft version under package */
function createPriceSetVersion(priceSetId) {
return request({
url: base + '/version/create/' + priceSetId,
method: 'post'
})
}
/** Import excel attachments for a price-set version (via prc_price_set_product.psv_id). */
function listPriceSetVersionExcelFiles(psvId) {
return request({
url: base + '/version/' + psvId + '/excelFiles',
method: 'get'
})
}
function deletePriceSetVersion(psvId) {
return request({
url: base + '/version/' + psvId,
method: 'delete'
})
}
function disablePriceSet(priceSetId) {
return request({
url: base + '/disable/' + priceSetId,
method: 'put'
})
}
function enablePriceSet(priceSetId) {
return request({
url: base + '/enable/' + priceSetId,
method: 'put'
})
}
function copyPriceSet(priceSetId) {
return request({
url: base + '/copy/' + priceSetId,
method: 'post'
})
}
function validatePriceSet(data) {
return request({
url: base + '/validate',
method: 'post',
data: data
})
}
function listSetDetail(query) {
return request({
url: base + '/detail/list',
method: 'get',
params: query
})
}
/** All detail rows for one product under a version (no pagination). */
function listSetDetailByProduct(psvId, productCode) {
return request({
url: base + '/detail/byProduct',
method: 'get',
params: { psvId, productCode }
})
}
/** All detail rows for a version (no pagination); one call instead of N byProduct. */
function listSetDetailByVersion(psvId) {
return request({
url: base + '/detail/byVersion',
method: 'get',
params: { psvId }
})
}
/** 版本明细搜索下拉选项:按版本去重后的 产品/国家/分区 */
function listSetDetailOptions(psvId) {
return request({
url: base + '/detail/options',
method: 'get',
params: { psvId }
})
}
function addSetDetail(data) {
return request({
url: base + '/detail',
method: 'post',
data: data
})
}
/**
* Async import quote Excel into FIXED package detail prices; poll until done.
* @param {File|Blob} file
* @param {{ priceSetId: string|number, psvId?: string|number, updateOnly?: boolean, replaceByProductCountry?: boolean, countryCode?: string }} query
* @param {(task: object) => void} [onProgress]
* @returns {Promise<{ task: object }>}
*/
function appendUploadFiles(formData, file) {
const list = Array.isArray(file) ? file : [file]
for (const item of list) {
const raw = item && (item.raw || item)
if (raw) formData.append('files', raw)
}
}
async function importSetDetailPricesAsync(file, query, onProgress) {
const formData = new FormData()
appendUploadFiles(formData, file)
const params = new URLSearchParams()
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.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({
url: base + '/detail/importPrices?' + params.toString(),
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }
})
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing import taskId')
}
return pollPriceSetSaveTask(taskId, onProgress)
}
/**
* Preview pull product library (no DB write). Returns { products, details, message }.
*/
function previewPullFromLibrary() {
return request({
url: base + '/detail/previewPullFromLibrary',
method: 'post'
})
}
/**
* Preview Excel import (no DB write).
* @param {File|Blob} file
* @param {{ updateOnly?: boolean, replaceByProductCountry?: boolean, countryCode?: string, details?: Array }} [opts]
*/
function previewImportDetailPrices(file, opts = {}) {
const formData = new FormData()
appendUploadFiles(formData, file)
if (opts.updateOnly) formData.append('updateOnly', 'true')
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))
}
return request({
url: base + '/detail/previewImportPrices',
method: 'post',
data: formData,
// FormData must not go through repeat-submit JSON.stringify (collapses to "{}")
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false },
timeout: 10 * 60 * 1000
})
}
/**
* Async pull product library into package version details; poll until done.
* @param {{ priceSetId: string|number, psvId?: string|number, force?: boolean }} query
* @param {(task: object) => void} [onProgress]
*/
async function pullSetDetailsFromLibraryAsync(query, onProgress) {
const params = new URLSearchParams()
if (query && query.priceSetId != null) params.set('priceSetId', query.priceSetId)
if (query && query.psvId != null && query.psvId !== '') params.set('psvId', query.psvId)
params.set('force', query && query.force ? 'true' : 'false')
const submitRes = await request({
url: base + '/detail/pullFromLibrary?' + params.toString(),
method: 'post'
})
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing pull taskId')
}
return pollPriceSetSaveTask(taskId, onProgress)
}
async function pollPriceSetSaveTask(taskId, onProgress) {
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
const res = await getPriceSetSaveTask(taskId)
const task = res.data || {}
if (typeof onProgress === 'function') {
onProgress(task)
}
if (task.status === 'SUCCESS') {
return { task }
}
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'task failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('task timeout')
}
function updateSetDetail(data) {
return request({
url: base + '/detail',
method: 'put',
data: data
})
}
/**
* Submit async batch write of detail surcharge snapshots and poll until done.
* @returns {Promise<{ task: object }>}
*/
async function updateSetDetailSurchargeAsync(details, onProgress) {
const submitRes = await request({
url: base + '/detail/surcharge/async',
method: 'post',
data: details || []
})
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing surcharge taskId')
}
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
const res = await getPriceSetSaveTask(taskId)
const task = res.data || {}
if (typeof onProgress === 'function') {
onProgress(task)
}
if (task.status === 'SUCCESS') {
return { task }
}
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'surcharge save failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('surcharge save timeout')
}
function delSetDetail(psdIds) {
return request({
url: base + '/detail/' + psdIds,
method: 'delete'
})
}
function listSetCustomer(query) {
return request({
url: base + '/customer/list',
method: 'get',
params: query
})
}
function addSetCustomer(data) {
return request({
url: base + '/customer',
method: 'post',
data: data
})
}
function batchAddSetCustomer(data) {
return request({
url: base + '/customer/batch',
method: 'post',
data: data
})
}
function updateSetCustomer(data) {
return request({
url: base + '/customer',
method: 'put',
data: data
})
}
function delSetCustomer(ids) {
return request({
url: base + '/customer/' + ids,
method: 'delete'
})
}
return {
listPriceSet,
getPriceSet,
savePriceSet,
getPriceSetSaveTask,
savePriceSetAsync,
updatePriceSet,
updatePriceSetBasic,
delPriceSet,
submitPriceSet,
approvePriceSet,
rejectPriceSet,
activatePriceSetVersion,
copyPriceSetVersion,
createPriceSetVersion,
listPriceSetVersionExcelFiles,
deletePriceSetVersion,
disablePriceSet,
enablePriceSet,
copyPriceSet,
validatePriceSet,
listSetDetail,
listSetDetailByProduct,
listSetDetailByVersion,
listSetDetailOptions,
addSetDetail,
importSetDetailPricesAsync,
previewPullFromLibrary,
previewImportDetailPrices,
pullSetDetailsFromLibraryAsync,
updateSetDetail,
updateSetDetailSurchargeAsync,
delSetDetail,
listSetCustomer,
addSetCustomer,
batchAddSetCustomer,
updateSetCustomer,
delSetCustomer
}
}
+43
View File
@@ -0,0 +1,43 @@
import { createPriceSetApi } from './createPriceSetApi'
export const exclusiveQuoteApi = createPriceSetApi('/price/exclusive')
export const {
listPriceSet,
getPriceSet,
savePriceSet,
getPriceSetSaveTask,
savePriceSetAsync,
updatePriceSet,
updatePriceSetBasic,
delPriceSet,
submitPriceSet,
approvePriceSet,
rejectPriceSet,
activatePriceSetVersion,
copyPriceSetVersion,
createPriceSetVersion,
listPriceSetVersionExcelFiles,
deletePriceSetVersion,
disablePriceSet,
enablePriceSet,
copyPriceSet,
validatePriceSet,
listSetDetail,
listSetDetailByProduct,
listSetDetailByVersion,
listSetDetailOptions,
addSetDetail,
importSetDetailPricesAsync,
previewPullFromLibrary,
previewImportDetailPrices,
pullSetDetailsFromLibraryAsync,
updateSetDetail,
updateSetDetailSurchargeAsync,
delSetDetail,
listSetCustomer,
addSetCustomer,
batchAddSetCustomer,
updateSetCustomer,
delSetCustomer
} = exclusiveQuoteApi
+41 -438
View File
@@ -1,440 +1,43 @@
import request from '@/utils/request'
import { createPriceSetApi } from './createPriceSetApi'
export function listPriceSet(query) {
return request({
url: '/price/set/list',
method: 'get',
params: query
})
}
export const priceSetApi = createPriceSetApi('/price/set')
export function getPriceSet(priceSetId) {
return request({
url: '/price/set/' + priceSetId,
method: 'get'
})
}
export function savePriceSet(data) {
return request({
url: '/price/set',
method: 'post',
data: data
})
}
export function getPriceSetSaveTask(taskId) {
return request({
url: '/price/set/save/task/' + taskId,
method: 'get',
timeout: 10 * 60 * 1000
})
}
/**
* Submit save async and poll Redis progress until SUCCESS/FAIL.
* @returns {Promise<{ priceSetId: number|string, task: object }>}
*/
export async function savePriceSetAsync(data, onProgress) {
const submitRes = await savePriceSet(data)
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing save taskId')
}
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
const res = await getPriceSetSaveTask(taskId)
const task = res.data || {}
if (typeof onProgress === 'function') {
onProgress(task)
}
if (task.status === 'SUCCESS') {
return { priceSetId: task.priceSetId, task }
}
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'save failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('save timeout')
}
/** Accept data.taskId / data string / legacy msg UUID (Bms String overload). */
function pickSaveTaskId(res) {
if (!res) return null
const d = res.data
if (d && typeof d === 'object' && d.taskId) return String(d.taskId)
if (typeof d === 'string' && d.trim()) return d.trim()
if (typeof res.msg === 'string') {
const m = res.msg.trim()
if (/^[a-f0-9]{32}$/i.test(m)) return m
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(m)) {
return m.replace(/-/g, '')
}
}
return null
}
export function updatePriceSet(data) {
return request({
url: '/price/set',
method: 'put',
data: data
})
}
/** Update package name and note only. */
export function updatePriceSetBasic(data) {
return request({
url: '/price/set/basic',
method: 'put',
data: data
})
}
export function delPriceSet(priceSetIds) {
return request({
url: '/price/set/' + priceSetIds,
method: 'delete'
})
}
export function submitPriceSet(priceSetId) {
return request({
url: '/price/set/submit/' + priceSetId,
method: 'put'
})
}
export function approvePriceSet(priceSetId) {
return request({
url: '/price/set/approve/' + priceSetId,
method: 'put'
})
}
export function rejectPriceSet(priceSetId) {
return request({
url: '/price/set/reject/' + priceSetId,
method: 'put'
})
}
export function activatePriceSetVersion(psvId, data) {
return request({
url: '/price/set/version/activate/' + psvId,
method: 'put',
data: data || {}
})
}
export function copyPriceSetVersion(psvId) {
return request({
url: '/price/set/version/copy/' + psvId,
method: 'post'
})
}
/** Create empty draft version under package */
export function createPriceSetVersion(priceSetId) {
return request({
url: '/price/set/version/create/' + priceSetId,
method: 'post'
})
}
/** Import excel attachments for a price-set version (via prc_price_set_product.psv_id). */
export function listPriceSetVersionExcelFiles(psvId) {
return request({
url: '/price/set/version/' + psvId + '/excelFiles',
method: 'get'
})
}
export function deletePriceSetVersion(psvId) {
return request({
url: '/price/set/version/' + psvId,
method: 'delete'
})
}
export function disablePriceSet(priceSetId) {
return request({
url: '/price/set/disable/' + priceSetId,
method: 'put'
})
}
export function enablePriceSet(priceSetId) {
return request({
url: '/price/set/enable/' + priceSetId,
method: 'put'
})
}
export function copyPriceSet(priceSetId) {
return request({
url: '/price/set/copy/' + priceSetId,
method: 'post'
})
}
export function validatePriceSet(data) {
return request({
url: '/price/set/validate',
method: 'post',
data: data
})
}
export function listSetDetail(query) {
return request({
url: '/price/set/detail/list',
method: 'get',
params: query
})
}
/** All detail rows for one product under a version (no pagination). */
export function listSetDetailByProduct(psvId, productCode) {
return request({
url: '/price/set/detail/byProduct',
method: 'get',
params: { psvId, productCode }
})
}
/** All detail rows for a version (no pagination); one call instead of N byProduct. */
export function listSetDetailByVersion(psvId) {
return request({
url: '/price/set/detail/byVersion',
method: 'get',
params: { psvId }
})
}
/** 版本明细搜索下拉选项:按版本去重后的 产品/国家/分区 */
export function listSetDetailOptions(psvId) {
return request({
url: '/price/set/detail/options',
method: 'get',
params: { psvId }
})
}
export function addSetDetail(data) {
return request({
url: '/price/set/detail',
method: 'post',
data: data
})
}
/**
* Async import quote Excel into FIXED package detail prices; poll until done.
* @param {File|Blob} file
* @param {{ priceSetId: string|number, psvId?: string|number, updateOnly?: boolean, replaceByProductCountry?: boolean, countryCode?: string }} query
* @param {(task: object) => void} [onProgress]
* @returns {Promise<{ task: object }>}
*/
function appendUploadFiles(formData, file) {
const list = Array.isArray(file) ? file : [file]
for (const item of list) {
const raw = item && (item.raw || item)
if (raw) formData.append('files', raw)
}
}
export async function importSetDetailPricesAsync(file, query, onProgress) {
const formData = new FormData()
appendUploadFiles(formData, file)
const params = new URLSearchParams()
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.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({
url: '/price/set/detail/importPrices?' + params.toString(),
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }
})
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing import taskId')
}
return pollPriceSetSaveTask(taskId, onProgress)
}
/**
* Preview pull product library (no DB write). Returns { products, details, message }.
*/
export function previewPullFromLibrary() {
return request({
url: '/price/set/detail/previewPullFromLibrary',
method: 'post'
})
}
/**
* Preview Excel import (no DB write).
* @param {File|Blob} file
* @param {{ updateOnly?: boolean, replaceByProductCountry?: boolean, countryCode?: string, details?: Array }} [opts]
*/
export function previewImportDetailPrices(file, opts = {}) {
const formData = new FormData()
appendUploadFiles(formData, file)
if (opts.updateOnly) formData.append('updateOnly', 'true')
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))
}
return request({
url: '/price/set/detail/previewImportPrices',
method: 'post',
data: formData,
// FormData must not go through repeat-submit JSON.stringify (collapses to "{}")
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false },
timeout: 10 * 60 * 1000
})
}
/**
* Async pull product library into package version details; poll until done.
* @param {{ priceSetId: string|number, psvId?: string|number, force?: boolean }} query
* @param {(task: object) => void} [onProgress]
*/
export async function pullSetDetailsFromLibraryAsync(query, onProgress) {
const params = new URLSearchParams()
if (query && query.priceSetId != null) params.set('priceSetId', query.priceSetId)
if (query && query.psvId != null && query.psvId !== '') params.set('psvId', query.psvId)
params.set('force', query && query.force ? 'true' : 'false')
const submitRes = await request({
url: '/price/set/detail/pullFromLibrary?' + params.toString(),
method: 'post'
})
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing pull taskId')
}
return pollPriceSetSaveTask(taskId, onProgress)
}
async function pollPriceSetSaveTask(taskId, onProgress) {
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
const res = await getPriceSetSaveTask(taskId)
const task = res.data || {}
if (typeof onProgress === 'function') {
onProgress(task)
}
if (task.status === 'SUCCESS') {
return { task }
}
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'task failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('task timeout')
}
export function updateSetDetail(data) {
return request({
url: '/price/set/detail',
method: 'put',
data: data
})
}
/**
* Submit async batch write of detail surcharge snapshots and poll until done.
* @returns {Promise<{ task: object }>}
*/
export async function updateSetDetailSurchargeAsync(details, onProgress) {
const submitRes = await request({
url: '/price/set/detail/surcharge/async',
method: 'post',
data: details || []
})
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing surcharge taskId')
}
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
const res = await getPriceSetSaveTask(taskId)
const task = res.data || {}
if (typeof onProgress === 'function') {
onProgress(task)
}
if (task.status === 'SUCCESS') {
return { task }
}
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'surcharge save failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('surcharge save timeout')
}
export function delSetDetail(psdIds) {
return request({
url: '/price/set/detail/' + psdIds,
method: 'delete'
})
}
export function listSetCustomer(query) {
return request({
url: '/price/set/customer/list',
method: 'get',
params: query
})
}
export function addSetCustomer(data) {
return request({
url: '/price/set/customer',
method: 'post',
data: data
})
}
export function batchAddSetCustomer(data) {
return request({
url: '/price/set/customer/batch',
method: 'post',
data: data
})
}
export function updateSetCustomer(data) {
return request({
url: '/price/set/customer',
method: 'put',
data: data
})
}
export function delSetCustomer(ids) {
return request({
url: '/price/set/customer/' + ids,
method: 'delete'
})
}
export const {
listPriceSet,
getPriceSet,
savePriceSet,
getPriceSetSaveTask,
savePriceSetAsync,
updatePriceSet,
updatePriceSetBasic,
delPriceSet,
submitPriceSet,
approvePriceSet,
rejectPriceSet,
activatePriceSetVersion,
copyPriceSetVersion,
createPriceSetVersion,
listPriceSetVersionExcelFiles,
deletePriceSetVersion,
disablePriceSet,
enablePriceSet,
copyPriceSet,
validatePriceSet,
listSetDetail,
listSetDetailByProduct,
listSetDetailByVersion,
listSetDetailOptions,
addSetDetail,
importSetDetailPricesAsync,
previewPullFromLibrary,
previewImportDetailPrices,
pullSetDetailsFromLibraryAsync,
updateSetDetail,
updateSetDetailSurchargeAsync,
delSetDetail,
listSetCustomer,
addSetCustomer,
batchAddSetCustomer,
updateSetCustomer,
delSetCustomer
} = priceSetApi
@@ -0,0 +1,565 @@
<template>
<div class="app-container">
<div v-if="showBasic" class="create-section">
<step-basic-info ref="basicRef" v-model="form.basic" />
</div>
<div class="create-section">
<step-details
ref="detailsRef"
:details="form.details"
:products="form.products"
:price-set-id="form.priceSetId"
:psv-id="form.version.psvId"
@update:psv-id="onPsvIdUpdate"
:set-type="form.basic.setType"
:price-build-mode="form.version.priceBuildMode"
@update:price-build-mode="onPriceBuildModeUpdate"
@update:pef-id="onPefIdUpdate"
:version-no="form.version.versionNo"
:version-status="form.version.status"
:ref-data="refData"
:live-persist="livePersist"
:build-mode-dirty="buildModeDirty"
:build-mode-ready="pageReady"
:show-import-inherit="false"
:header-title="detailsHeaderTitle"
/>
</div>
<div class="wizard-actions">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" :loading="creating" @click="finishEdit">完成</el-button>
</div>
<el-dialog
v-model="progressVisible"
:title="mod.labels.saveProgressTitle"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
append-to-body
>
<div class="save-progress">
<el-progress :percentage="progressPercent" :status="progressStatus" />
<p class="save-progress__msg">{{ progressMessage }}</p>
<p class="save-progress__hint">请勿关闭页面,大量明细保存可能需要一些时间</p>
</div>
</el-dialog>
</div>
</template>
<script setup name="ExclusiveQuoteCreate">
import StepBasicInfo from '../set/components/create/StepBasicInfo.vue'
import StepDetails from '../set/components/create/StepDetails.vue'
import { usePriceSetRefData } from '../set/composables/usePriceSetRefData'
import {
createEmptyBasicInfo,
isBuildModeLockedVersion,
nextVersionNo,
toCustomerPayload,
toDetailPayload
} from '../set/utils/priceSet'
import { exclusiveQuoteApi } from '@/api/price/exclusive'
import { providePriceSetModule, createExclusiveModule } from '../set/composables/usePriceSetModule'
// Same-component inject cannot see provide(); use return value for this page, provide for children.
const mod = providePriceSetModule(createExclusiveModule(exclusiveQuoteApi))
const { proxy } = getCurrentInstance()
const route = useRoute()
const refData = usePriceSetRefData()
const creating = ref(false)
const progressVisible = ref(false)
const progressPercent = ref(0)
const progressMessage = ref('')
const progressStatus = ref(undefined)
const basicRef = ref()
const detailsRef = ref()
const copyMode = computed(() => String(route.query.copyVersion || '') === '1')
const newVersionMode = computed(() => String(route.query.newVersion || '') === '1')
/** copy / newVersion: defer DB write until finish */
const deferPersist = computed(() => copyMode.value || newVersionMode.value)
/** Continue-config / copy / new-version: hide basic (package already exists) */
const showBasic = computed(() => !deferPersist.value && String(route.query.step || '') !== '2')
/** Edit existing version: live PUT/pull/import. New create / copy / newVersion: defer until finish. */
const livePersist = computed(() => !!form.priceSetId && !deferPersist.value)
/** Brand-new package has no prior version — hide IMPORT inherit card. */
const showImportInherit = computed(() => newVersionMode.value || copyMode.value)
const detailsHeaderTitle = '编辑产品价格'
const form = reactive({
priceSetId: undefined,
basic: createEmptyBasicInfo(),
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined, status: undefined, priceBuildMode: 'IMPORT_FRESH' },
details: [],
products: [],
customers: []
})
/** Draft IMPORT Excel attachment id; stamped onto prc_price_set_product on finish */
const draftPefId = ref(undefined)
const draftPefIds = ref([])
const savedPriceBuildMode = ref(undefined)
const savedProductsSnapshot = ref([])
const buildModeDirty = computed(() =>
String(form.version.priceBuildMode || '') !== String(savedPriceBuildMode.value || '')
)
/** Gate build-mode cards until package payload applied (avoids first click being wiped). */
const pageReady = ref(!route.query.priceSetId)
function mapSaveProgressMessage(message) {
const m = message || ''
if (!m) return ''
if (m === 'queued') return '排队中'
if (m === 'pulling library') return '拉取产品库'
if (m === 'details exist, skip pull') return '明细已存在,跳过拉取'
if (m === 'saving basic') return '保存基本信息'
if (m === 'saving version') return '保存版本'
if (m.startsWith('saving details ')) return '保存明细 ' + m.slice('saving details '.length)
if (m === 'saving surcharges') return '保存附加费'
if (m === 'details unchanged') return '明细未变更'
if (m === 'saving customers') return '保存客户'
if (m === 'finishing') return '收尾中'
if (m === 'done') return '完成'
if (m === 'failed') return '失败'
return m
}
async function runSaveWithProgress(payload) {
progressVisible.value = true
progressPercent.value = 0
progressMessage.value = ''
progressStatus.value = undefined
try {
const result = await mod.api.savePriceSetAsync(payload, (task) => {
progressPercent.value = Math.min(100, Number(task.percent) || 0)
progressMessage.value = mapSaveProgressMessage(task.message)
if (task.status === 'FAIL') {
progressStatus.value = 'exception'
} else if (task.status === 'SUCCESS') {
progressStatus.value = 'success'
} else {
progressStatus.value = undefined
}
})
progressPercent.value = 100
progressStatus.value = 'success'
return result.priceSetId
} catch (e) {
progressStatus.value = 'exception'
const msg = (e && e.task && e.task.error) || (e && e.message) || '保存失败'
progressMessage.value = msg
if (proxy && proxy.$modal) {
proxy.$modal.msgError(msg)
}
await new Promise(resolve => setTimeout(resolve, 800))
throw e
} finally {
progressVisible.value = false
}
}
/** Finish: create/copy async save all; edit already live-saved. */
async function finishEdit() {
if (livePersist.value) {
if (!form.priceSetId) {
goBack()
return
}
if (buildModeDirty.value) {
if (!form.version.priceBuildMode) {
proxy.$modal.msgWarning("请先选择创建价格方式")
return
}
creating.value = true
try {
const payload = buildPayload()
delete payload.pullFromLibrary
delete payload.forcePullLibrary
delete payload.details
await runSaveWithProgress(payload)
savedPriceBuildMode.value = form.version.priceBuildMode
await nextTick()
if (form.version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
await detailsRef.value.pullAllFromLibrary({ silent: true })
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存")
} else if (form.version.priceBuildMode === 'IMPORT') {
// Backend copied prior version into this draft; refresh product list from current psv
await refreshProductsFromCurrentVersion()
proxy.$modal.msgSuccess("建价方式已保存,已带出上一版产品")
} else if (form.version.priceBuildMode === 'IMPORT_FRESH') {
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存,可直接导入 Excel 建价(不延用以前的产品价格)")
} else {
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存")
}
} catch (e) {
return
} finally {
creating.value = false
}
} else {
proxy.$modal.msgSuccess("修改已实时落库")
}
const obj = {
path: mod.routes.detail,
query: { priceSetId: form.priceSetId }
}
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
proxy.$router.push(obj)
}
return
}
if (showBasic.value) {
const validBasic = await basicRef.value.validate()
if (!validBasic) return
}
if (!form.version.priceBuildMode) {
proxy.$modal.msgWarning('请先选择创建价格方式')
return
}
const detailRows = (form.details || []).filter(d => d && d.productCode)
if (!detailRows.length) {
proxy.$modal.msgWarning('请先拉取产品或导入 Excel 建价')
return
}
creating.value = true
try {
if (newVersionMode.value && form.version.psvId == null && form.priceSetId) {
const verRes = await mod.api.createPriceSetVersion(form.priceSetId)
if (verRes.data != null) {
form.version.psvId = verRes.data
}
}
const payload = buildPayload({ forceDetails: true })
const priceSetId = await runSaveWithProgress(payload)
form.priceSetId = priceSetId
proxy.$modal.msgSuccess(mod.labels.savedSuccess)
const obj = {
path: mod.routes.detail,
query: { priceSetId }
}
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
proxy.$router.push(obj)
}
} catch (e) {
// surfaced
} finally {
creating.value = false
}
}
function buildPayload(opts = {}) {
const { basic, version } = form
const detailRows = form.details.filter(d => d && d.productCode)
// Edit live-save: omit details when all rows already have psdId.
// Create finish: forceDetails sends all rows (psdId cleared) for insert.
const hasUnsavedDetail = detailRows.some(d => d.psdId == null)
const payload = {
priceSetId: form.priceSetId,
priceSetName: basic.priceSetName,
setType: 'FIXED',
defaultDiscount: undefined,
priceSetNote: basic.priceSetNote,
version: {
psvId: (copyMode.value || (newVersionMode.value && version.psvId == null)) ? undefined : version.psvId,
versionNo: version.versionNo,
priceBuildMode: version.priceBuildMode || undefined
}
}
if (draftPefIds.value.length) {
payload.pefIds = draftPefIds.value.slice()
} else if (draftPefId.value != null) {
payload.pefId = draftPefId.value
}
if (opts.forceDetails || hasUnsavedDetail) {
payload.details = detailRows.map(d => {
const p = toDetailPayload(d)
if (opts.forceDetails) {
delete p.psdId
delete p.pspId
delete p.priceSetId
delete p.psvId
}
return p
})
}
const customers = form.customers.filter(c => c && c.customerCode).map(toCustomerPayload)
if (customers.length) {
payload.customers = customers
}
return payload
}
/**
* New-version flow creates an empty draft as soon as user clicks "新建价格版本".
* If user cancels without finishing (no build mode / no details on server), remove that orphan draft.
*/
async function tryDeleteEmptyNewVersionDraft() {
if (!newVersionMode.value || !form.priceSetId || form.version.psvId == null) return
const psvId = form.version.psvId
try {
const res = await mod.api.getPriceSet(form.priceSetId)
const versions = (res.data || {}).versions || []
const v = versions.find(x => x && String(x.psvId) === String(psvId))
if (!v || String(v.status) !== '0') return
const noMode = !v.priceBuildMode
const noDetails = !(Number(v.detailCount) > 0) && !(v.details || []).length
if (noMode && noDetails) {
await mod.api.deletePriceSetVersion(psvId)
}
} catch (e) {
// ignore cleanup errors; still navigate back
}
}
async function goBack() {
await tryDeleteEmptyNewVersionDraft()
const obj = (newVersionMode.value || copyMode.value) && form.priceSetId
? { path: mod.routes.detail, query: { priceSetId: form.priceSetId } }
: { path: mod.routes.list }
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
proxy.$router.push(obj)
}
}
function onPsvIdUpdate(psvId) {
if (psvId != null && psvId !== '') form.version.psvId = psvId
}
function onPefIdUpdate(pefId) {
draftPefId.value = pefId
if (pefId == null || pefId === '') return
const id = String(pefId)
if (!draftPefIds.value.some(x => String(x) === id)) {
draftPefIds.value.push(pefId)
}
}
/** Step2 chose PULL / IMPORT / IMPORT_FRESH: edit live-saves mode; create only local + optional preview pull. */
function onPriceBuildModeUpdate(mode) {
if (!mode) return
if (isBuildModeLockedVersion(form.version.status)) return
if (mode === form.version.priceBuildMode) return
const leavingSaved = livePersist.value
&& String(form.version.priceBuildMode || '') === String(savedPriceBuildMode.value || '')
if (leavingSaved) {
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
}
form.version.priceBuildMode = mode
if (mode !== 'IMPORT' && mode !== 'IMPORT_FRESH') {
draftPefId.value = undefined
draftPefIds.value = []
}
form.details.splice(0, form.details.length)
if (detailsRef.value && detailsRef.value.clearLocalDraft) {
detailsRef.value.clearLocalDraft()
}
if (livePersist.value) {
const backToSaved = String(mode || '') === String(savedPriceBuildMode.value || '')
if (backToSaved) {
form.products.splice(0, form.products.length, ...(savedProductsSnapshot.value || []).map(p => ({ ...p })))
if (!form.products.length && form.priceSetId) {
reloadSavedProductsForCreate()
} else {
proxy.$modal.msgSuccess("已恢复原建价方式及产品列表(未保存,库数据未变)")
}
return
}
form.products.splice(0, form.products.length)
proxy.$modal.msgSuccess("已切换建价方式(本地),请点「完成」保存后再拉取或导入;切回原方式可恢复列表")
return
}
form.products.splice(0, form.products.length)
if (mode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
nextTick(() => {
detailsRef.value.pullAllFromLibrary({ silent: true }).then(() => {
proxy.$modal.msgSuccess("已拉取产品列表,可继续编辑价格")
}).catch(() => {})
})
} else if (mode === 'IMPORT' && showImportInherit.value) {
nextTick(async () => {
// newVersion/copy: wait until loadExisting fills priceSetId
const qid = route.query.priceSetId
if (!form.priceSetId && qid) {
for (let i = 0; i < 50 && !form.priceSetId; i++) {
await new Promise(r => setTimeout(r, 100))
}
}
const loader = detailsRef.value && detailsRef.value.loadLatestPriorVersionDraft
if (!loader) {
proxy.$modal.msgSuccess("请导入 Excel 建价")
return
}
if (!form.priceSetId) {
proxy.$modal.msgSuccess("请直接导入 Excel 建价")
return
}
try {
const ok = await loader()
proxy.$modal.msgSuccess(ok
? "已带出上一版全部产品,导入 Excel 按产品+国家覆盖或新增"
: "未找到含产品的上一版,请导入 Excel 建价")
} catch (e) {
proxy.$modal.msgSuccess("请导入 Excel 建价")
}
})
} else if (mode === 'IMPORT_FRESH') {
proxy.$modal.msgSuccess("请直接导入 Excel 建价(不延用以前的产品价格)")
}
}
function reloadSavedProductsForCreate() {
mod.api.getPriceSet(form.priceSetId).then(res => {
const data = res.data || {}
const versions = data.versions || []
const qPsv = form.version.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
const list = (current.products || []).map(p => ({ ...p }))
form.products.splice(0, form.products.length, ...list)
savedProductsSnapshot.value = list.map(p => ({ ...p }))
proxy.$modal.msgSuccess("已恢复原建价方式及产品列表(未保存,库数据未变)")
})
}
async function refreshProductsFromCurrentVersion() {
if (!form.priceSetId) return
const res = await mod.api.getPriceSet(form.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const qPsv = form.version.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
const list = (current.products || []).map(p => ({ ...p }))
form.products.splice(0, form.products.length, ...list)
form.details.splice(0, form.details.length)
savedProductsSnapshot.value = list.map(p => ({ ...p }))
}
function applySetData(data, asCopy, asNewVersion) {
form.priceSetId = data.priceSetId
Object.assign(form.basic, {
priceSetName: data.priceSetName,
setType: 'FIXED',
defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
priceSetNote: data.priceSetNote
})
const versions = data.versions || []
const qPsv = route.query.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const fromData = (data.version && data.version.psvId) ? data.version : null
const current = byQuery || fromData || versions[0] || {}
if (asCopy) {
const keptMode = form.version.priceBuildMode
const keptProducts = keptMode ? form.products.slice() : []
const keptDetails = keptMode ? form.details.slice() : []
form.version.psvId = undefined
form.version.versionNo = nextVersionNo(versions)
form.version.effectiveTime = undefined
form.version.expireTime = undefined
form.version.status = undefined
form.version.priceBuildMode = keptMode || undefined
draftPefId.value = undefined
draftPefIds.value = []
savedPriceBuildMode.value = undefined
if (keptMode) {
form.products.splice(0, form.products.length, ...keptProducts)
form.details.splice(0, form.details.length, ...keptDetails)
} else {
form.details = []
form.products = []
}
} else if (asNewVersion) {
// Draft is created on finish only. Resume only when query has psvId (legacy empty draft).
// Keep mode/products if user already chose a build mode before this response landed.
const keptMode = form.version.priceBuildMode
const keptProducts = keptMode ? form.products.slice() : []
const keptDetails = keptMode ? form.details.slice() : []
const resume = qPsv != null ? byQuery : null
form.version.psvId = resume ? resume.psvId : undefined
form.version.versionNo = (resume && resume.versionNo) || nextVersionNo(versions)
form.version.effectiveTime = undefined
form.version.expireTime = undefined
form.version.status = (resume && resume.status) || '0'
form.version.priceBuildMode = keptMode || undefined
draftPefId.value = undefined
draftPefIds.value = []
savedPriceBuildMode.value = undefined
if (keptMode) {
form.products.splice(0, form.products.length, ...keptProducts)
form.details.splice(0, form.details.length, ...keptDetails)
} else {
form.details = []
form.products = []
}
} else {
form.version.psvId = current.psvId
form.version.versionNo = current.versionNo || nextVersionNo(versions)
form.version.effectiveTime = current.effectiveTime
form.version.expireTime = current.expireTime
form.version.status = current.status
form.version.priceBuildMode = current.priceBuildMode || undefined
draftPefId.value = undefined
draftPefIds.value = []
savedPriceBuildMode.value = current.priceBuildMode || undefined
form.details = []
form.products = (current.products || []).map(p => ({ ...p }))
savedProductsSnapshot.value = form.products.map(p => ({ ...p }))
}
form.customers = (data.customers || []).map(c => ({ ...c }))
}
function loadExisting(id, asCopy, asNewVersion) {
pageReady.value = false
return mod.api.getPriceSet(id).then(res => {
applySetData(res.data || {}, asCopy, asNewVersion)
}).finally(() => {
pageReady.value = true
})
}
onMounted(() => {
refData.loadProducts().finally(() => {
const id = route.query.priceSetId
if (id) {
loadExisting(id, copyMode.value, newVersionMode.value)
} else {
pageReady.value = true
}
})
})
</script>
<style scoped>
.create-section {
margin-bottom: 16px;
}
.wizard-actions {
text-align: center;
padding: 8px 0 24px;
}
.save-progress__msg {
margin: 12px 0 4px;
color: #606266;
font-size: 13px;
word-break: break-all;
}
.save-progress__hint {
margin: 0;
color: #909399;
font-size: 12px;
}
</style>
@@ -0,0 +1,538 @@
<template>
<div class="app-container" v-loading="loading">
<!-- 编辑模式:整屏切换,等同独立页面 -->
<price-edit-panel
v-if="editVisible"
:price-set-id="set.priceSetId"
:psv-id="editPsvId"
@close="closeEdit"
/>
<template v-else>
<set-detail-header
:set="set"
@back="goList"
@disable="onDisable"
@enable="onEnable"
/>
<set-basic-info-card :set="set" />
<set-customer-table :set="set" @refresh="load" />
<set-version-table
v-model="currentPsvId"
:versions="set.versions || []"
:removing-psv-id="removingPsvId"
@create-version="onCreateVersion"
@open-details="onOpenVersionDetails"
@view-import-attach="onViewImportAttach"
@activate="onActivateVersion"
@reschedule="onRescheduleVersion"
@edit="onEditVersion"
@remove="onRemoveVersion"
/>
<set-version-detail-drawer
v-model="detailDrawerVisible"
:version="detailVersion"
:set-type="set.setType"
/>
<set-version-excel-file-drawer ref="versionExcelDrawerRef" />
<el-dialog
v-model="activateVisible"
:title="activateDialogTitle"
width="460px"
append-to-body
@closed="onActivateClosed"
>
<el-form ref="activateFormRef" :model="activateForm" :rules="activateRules" label-width="100px">
<el-form-item label="生效时间" prop="effectiveTime">
<el-date-picker
v-model="activateForm.effectiveTime"
type="datetime"
format="YYYY-MM-DD HH:00"
value-format="YYYY-MM-DD HH:00:00"
popper-class="activate-hour-picker"
:placeholder="activatePh"
:disabled-date="disabledActivateDate"
:disabled-hours="disabledActivateHours"
style="width: 100%"
@change="onActivateTimeChange"
@visible-change="onActivatePickerVisible"
/>
</el-form-item>
<div class="activate-hint">{{ activateDialogHint }}</div>
</el-form>
<template #footer>
<el-button @click="activateVisible = false">取消</el-button>
<el-button type="primary" :loading="activateSubmitting" @click="submitActivate">{{ activateConfirmText }}</el-button>
</template>
</el-dialog>
</template>
</div>
</template>
<script setup name="ExclusiveQuoteDetail">
import SetDetailHeader from '../set/components/detail/SetDetailHeader.vue'
import SetBasicInfoCard from '../set/components/detail/SetBasicInfoCard.vue'
import SetCustomerTable from '../set/components/detail/SetCustomerTable.vue'
import SetVersionTable from '../set/components/detail/SetVersionTable.vue'
import SetVersionDetailDrawer from '../set/components/detail/SetVersionDetailDrawer.vue'
import SetVersionExcelFileDrawer from '../set/components/detail/SetVersionExcelFileDrawer.vue'
import PriceEditPanel from '../set/components/create/PriceEditPanel.vue'
import { usePriceSetRefData } from '../set/composables/usePriceSetRefData'
import { exclusiveQuoteApi } from '@/api/price/exclusive'
import { providePriceSetModule, createExclusiveModule } from '../set/composables/usePriceSetModule'
import { isDraftVersion, isPendingVersion, isEffectVersion, isImportFreshBuildMode } from '../set/utils/priceSet'
// Same-component inject cannot see provide(); use return value for this page, provide for children.
const mod = providePriceSetModule(createExclusiveModule(exclusiveQuoteApi))
const { proxy } = getCurrentInstance()
const route = useRoute()
const router = useRouter()
const refData = usePriceSetRefData()
const loading = ref(false)
const removingPsvId = ref(null)
const set = ref({})
const currentPsvId = ref()
const detailDrawerVisible = ref(false)
const detailVersion = ref(null)
const versionExcelDrawerRef = ref()
const editVisible = ref(false)
const editPsvId = ref(undefined)
/** 已加载的套餐ID;页面被 keep-alive 缓存复用时据此判断是否需要重新加载 */
const loadedId = ref(null)
/** 从详情页跳到新建/编辑套餐版本页后,返回时套餐版本数据可能已变化,需强制刷新 */
const pendingRefresh = ref(false)
/** 切换到另一个套餐时,重置所有临时面板状态,避免残留上一套餐的数据 */
function resetTransientState() {
set.value = {}
currentPsvId.value = undefined
detailDrawerVisible.value = false
detailVersion.value = null
editVisible.value = false
editPsvId.value = undefined
activateVisible.value = false
activateSubmitting.value = false
activateMode.value = 'activate'
activateForm.psvId = undefined
activateForm.versionNo = ''
activateForm.effectiveTime = undefined
activatePrevEffectiveTime.value = undefined
}
function onOpenVersionDetails(row) {
detailVersion.value = row || null
detailDrawerVisible.value = true
}
function onViewImportAttach(row) {
if (!row || row.psvId == null) return
versionExcelDrawerRef.value?.open?.(row)
}
function load() {
const id = route.query.priceSetId
if (!id) return
// 立即标记,首次挂载后紧随的 activated 不会重复请求
loadedId.value = String(id)
loading.value = true
mod.api.getPriceSet(id).then(res => {
set.value = res.data || {}
const versions = set.value.versions || []
const cur = set.value.version || versions[0]
currentPsvId.value = cur ? cur.psvId : undefined
}).finally(() => {
loading.value = false
})
}
/**
* keep-alive 复用同一组件实例时同步数据:
* - priceSetId 变化:加载新套餐
* - 从新建/编辑版本页返回(force):即使套餐相同也强制重载,保证版本列表最新
*/
function syncFromRoute(force) {
const id = route.query.priceSetId
if (!id) return
pendingRefresh.value = false
if (!force && String(id) === String(loadedId.value)) return
resetTransientState()
load()
}
function confirmAction(text, request, successText, done) {
proxy.$modal.confirm(text).then(() => request()).then(() => {
proxy.$modal.msgSuccess(successText)
done ? done() : load()
}).catch(() => {})
}
function onDisable() {
confirmAction(mod.labels.disableConfirm.replace('{name}', set.value.priceSetName || ''), () => mod.api.disablePriceSet(set.value.priceSetId), '已停用')
}
function onEnable() {
confirmAction(mod.labels.enableConfirm.replace('{name}', set.value.priceSetName || ''), () => mod.api.enablePriceSet(set.value.priceSetId), '已启用')
}
/** Effective time precision: hour only (mm:ss always 00) */
function pad2(n) {
return String(n).padStart(2, '0')
}
function formatHour(d) {
if (!d || Number.isNaN(d.getTime())) return undefined
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':00:00'
}
function normalizeToHour(val) {
if (val == null || val === '') return val
if (val instanceof Date) return formatHour(val)
const s = String(val).trim().replace('T', ' ')
const m = s.match(/^(\d{4}-\d{2}-\d{2})[ ](\d{2})/)
if (m) return m[1] + ' ' + m[2] + ':00:00'
return formatHour(new Date(s.replace(/-/g, '/'))) || val
}
function formatNow() {
const d = new Date()
d.setHours(9, 0, 0, 0)
return formatHour(d)
}
function onActivateTimeChange(val) {
activateForm.effectiveTime = normalizeToHour(val)
if (activateFormRef.value) {
activateFormRef.value.validateField('effectiveTime')
}
}
const activateVisible = ref(false)
const activateSubmitting = ref(false)
const activateFormRef = ref()
const activateMode = ref('activate')
const activatePh = '请选择生效时间'
const activateForm = reactive({
psvId: undefined,
versionNo: '',
effectiveTime: undefined
})
const activateDialogTitle = computed(() =>
activateMode.value === 'reschedule' ? '修改生效时间' : '启动版本'
)
const activateConfirmText = computed(() =>
activateMode.value === 'reschedule' ? '确认修改' : '确认启动'
)
const activatePrevEffectiveTime = ref(undefined)
const activateIsImportFresh = ref(false)
/** Other versions' effective/expire times (hour) — forbidden for IMPORT_FRESH */
const activateBoundaryTimes = ref([])
const activateEffBeforePrevMsg = '生效时间不能早于上一版本的生效时间'
const activateEffHitBoundaryMsg = '生效时间不能与已有版本的生效时间或失效时间相同'
const activateDialogHint = computed(() => {
if (activateIsImportFresh.value) {
const base = activateMode.value === 'reschedule'
? 'Excel 新建价:生效时间可落在历史版本区间内,将自动截断该版本失效时间并同步产品/明细。'
: 'Excel 新建价:生效时间可落在历史版本区间内;落在版本 B 区间时,B 失效时间改为本次启动时间,本版本失效时间取 B 原失效时间,并同步双方产品/明细。'
return base + '不可与已有版本的生效时间或失效时间相同。'
}
const base = activateMode.value === 'reschedule'
? '仅待生效版本可修改;若时间晚于当前仍为待生效,到时自动生效并使上一版本失效。'
: '若生效时间晚于当前,版本将进入「待生效」;到时自动生效并使上一版本失效。'
const prev = activatePrevEffectiveTime.value
if (!prev) return base
return base + '不得早于上一版:' + prev + '。'
})
const activateRules = computed(() => ({
effectiveTime: [
{ required: true, message: '请选择生效时间', trigger: 'change' },
{
validator: (_r, v, cb) => {
if (!v) return cb()
if (activateIsImportFresh.value) {
const ms = toTimeMs(v)
if (activateBoundaryTimes.value.some(b => toTimeMs(b) === ms)) {
return cb(new Error(activateEffHitBoundaryMsg))
}
return cb()
}
const prev = activatePrevEffectiveTime.value
if (prev && toTimeMs(v) < toTimeMs(prev)) return cb(new Error(activateEffBeforePrevMsg))
cb()
},
trigger: 'change'
}
]
}))
function toTimeMs(val) {
if (val == null || val === '') return NaN
const n = normalizeToHour(val)
if (!n) return NaN
return new Date(String(n).replace(/-/g, '/')).getTime()
}
function findPrevVersionEffectiveTime(row) {
if (!row) return undefined
const versions = set.value.versions || []
const curSeq = Number(row.versionSeq) || 0
let prev = null
for (const v of versions) {
if (!v || v.psvId == null || String(v.psvId) === String(row.psvId)) continue
const vs = Number(v.versionSeq) || 0
if (vs >= curSeq || !v.effectiveTime) continue
if (!prev || vs > (Number(prev.versionSeq) || 0)) prev = v
}
return prev ? normalizeToHour(prev.effectiveTime) : undefined
}
/** Collect other versions' effective/expire hour times (forbidden for IMPORT_FRESH). */
function collectActivateBoundaryTimes(excludePsvId) {
const out = []
const seen = new Set()
for (const v of set.value.versions || []) {
if (!v || v.psvId == null || String(v.psvId) === String(excludePsvId)) continue
;[v.effectiveTime, v.expireTime].forEach(t => {
const n = t ? normalizeToHour(t) : ''
if (!n || seen.has(n)) return
seen.add(n)
out.push(n)
})
}
return out
}
function pickActivateDefaultTime(prev, importFresh) {
const now = formatNow()
if (importFresh || !prev) return now
return toTimeMs(now) >= toTimeMs(prev) ? now : prev
}
function disabledActivateDate(date) {
if (activateIsImportFresh.value) return false
const prev = activatePrevEffectiveTime.value
if (!prev || !date) return false
const prevDay = new Date(String(prev).replace(/-/g, '/'))
prevDay.setHours(0, 0, 0, 0)
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d.getTime() < prevDay.getTime()
}
function disabledActivateHours() {
const cur = activateForm.effectiveTime
const curD = cur
? new Date(String(normalizeToHour(cur)).replace(/-/g, '/'))
: new Date()
if (activateIsImportFresh.value) {
const hours = new Set()
for (const b of activateBoundaryTimes.value) {
const bd = new Date(String(b).replace(/-/g, '/'))
if (
bd.getFullYear() === curD.getFullYear() &&
bd.getMonth() === curD.getMonth() &&
bd.getDate() === curD.getDate()
) {
hours.add(bd.getHours())
}
}
return [...hours]
}
const prev = activatePrevEffectiveTime.value
if (!prev) return []
const prevD = new Date(String(prev).replace(/-/g, '/'))
if (
prevD.getFullYear() !== curD.getFullYear() ||
prevD.getMonth() !== curD.getMonth() ||
prevD.getDate() !== curD.getDate()
) {
return []
}
const minH = prevD.getHours()
const hours = []
for (let h = 0; h < minH; h++) hours.push(h)
return hours
}
function onActivatePickerVisible(visible) {
if (visible && activateFormRef.value) {
activateFormRef.value.validateField('effectiveTime')
}
}
function openActivateDialog(row, mode) {
if (!row || row.psvId == null) return
activateMode.value = mode
activateForm.psvId = row.psvId
activateForm.versionNo = row.versionNo || ''
activateIsImportFresh.value = isImportFreshBuildMode(row.priceBuildMode)
activatePrevEffectiveTime.value = findPrevVersionEffectiveTime(row)
activateBoundaryTimes.value = activateIsImportFresh.value
? collectActivateBoundaryTimes(row.psvId)
: []
const prev = activatePrevEffectiveTime.value
if (mode === 'reschedule') {
const cur = normalizeToHour(row.effectiveTime)
if (activateIsImportFresh.value) {
activateForm.effectiveTime = cur || pickActivateDefaultTime(prev, true)
} else if (cur && (!prev || toTimeMs(cur) >= toTimeMs(prev))) {
activateForm.effectiveTime = cur
} else {
activateForm.effectiveTime = pickActivateDefaultTime(prev, false)
}
} else {
activateForm.effectiveTime = pickActivateDefaultTime(prev, activateIsImportFresh.value)
}
activateVisible.value = true
}
function onActivateVersion(row) {
openActivateDialog(row, 'activate')
}
function onRescheduleVersion(row) {
openActivateDialog(row, 'reschedule')
}
function onActivateClosed() {
activateMode.value = 'activate'
activateForm.psvId = undefined
activateForm.versionNo = ''
activateForm.effectiveTime = undefined
activatePrevEffectiveTime.value = undefined
activateIsImportFresh.value = false
activateBoundaryTimes.value = []
activateFormRef.value && activateFormRef.value.clearValidate()
}
function submitActivate() {
activateFormRef.value.validate(valid => {
if (!valid) return
activateForm.effectiveTime = normalizeToHour(activateForm.effectiveTime)
activateSubmitting.value = true
mod.api.activatePriceSetVersion(activateForm.psvId, { effectiveTime: activateForm.effectiveTime })
.then(() => {
const eff = activateForm.effectiveTime
const pending = eff && new Date(eff.replace(/-/g, '/')).getTime() > Date.now()
const okMsg = activateMode.value === 'reschedule'
? (pending ? '已更新为待生效' : '生效时间已更新,版本已生效')
: (pending ? '已设为待生效' : '版本已启动')
proxy.$modal.msgSuccess(okMsg)
activateVisible.value = false
load()
})
.finally(() => {
activateSubmitting.value = false
})
})
}
function onCreateVersion() {
const priceSetId = set.value.priceSetId
if (priceSetId == null) return
const hasDraft = (set.value.versions || []).some(v => v && String(v.status) === '0')
if (hasDraft) {
proxy.$modal.msgWarning('已有草稿版本,请先启动或删除后再新建')
return
}
// Do not insert draft until user clicks finish on the build-price page.
router.push({
path: mod.routes.create,
query: { priceSetId, step: '2', newVersion: '1' }
})
}
function onRemoveVersion(row) {
if (!row || row.psvId == null) return
const msg = isEffectVersion(row)
? "确认软删除已生效版本(生效时间:" + (row.effectiveTime || '未设置') + ")?版本及产品、明细将标记为已删除,上一版将自动恢复生效并清空失效时间。"
: "确认删除" + (isPendingVersion(row) ? "待生效" : "草稿") + "版本(生效时间:" + (row.effectiveTime || '未设置') + ")?其明细与附加费将一并删除。"
proxy.$modal.confirm(msg).then(() => {
removingPsvId.value = row.psvId
proxy.$modal.loading("正在删除,请稍候...")
return mod.api.deletePriceSetVersion(row.psvId).then(() => {
proxy.$modal.msgSuccess("版本已删除")
load()
}).finally(() => {
removingPsvId.value = null
proxy.$modal.closeLoading()
})
}).catch(() => {})
}
function onEditVersion(row) {
if (!row || row.psvId == null) return
const priceSetId = set.value.priceSetId
const noMode = !row.priceBuildMode
const noProducts = !(row.products || []).some(p => p && p.productCode)
// Empty unfinished draft (new version, not finished): resume create step2 with build-mode choice
if (isDraftVersion(row) && noMode && noProducts && priceSetId != null) {
router.push({
path: mod.routes.create,
query: { priceSetId, psvId: row.psvId, step: '2', newVersion: '1' }
})
return
}
editPsvId.value = row.psvId
editVisible.value = true
}
/** 关闭内嵌编辑面板,刷新版本列表(明细数、状态可能变化) */
function closeEdit() {
editVisible.value = false
load()
}
function goList() {
const obj = { path: mod.routes.list }
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
proxy.$router.push(obj)
}
}
onMounted(() => {
refData.loadProducts()
load()
})
// 从其他页签切回(新建完成跳转、列表点另一条详情)时同步数据;
// 从新建/编辑版本页返回时即使套餐ID相同也强制刷新版本列表
onActivated(() => {
syncFromRoute(pendingRefresh.value)
})
// 组件已处于激活状态时路由 query 变化的兜底
watch(() => route.query.priceSetId, () => {
syncFromRoute(false)
})
// 离开详情页去新建/编辑套餐版本,返回后需要重新加载
onBeforeRouteLeave((to) => {
if (to && to.path === mod.routes.create) {
pendingRefresh.value = true
}
})
</script>
<style scoped>
.activate-hint {
margin: -4px 0 0 100px;
color: #909399;
font-size: 12px;
line-height: 1.5;
}
</style>
<style>
/* Hour-only: hide minute/second columns in activate datetime popper */
.activate-hour-picker .el-time-spinner__wrapper:nth-child(2),
.activate-hour-picker .el-time-spinner__wrapper:nth-child(3) {
display: none;
}
.activate-hour-picker .el-time-spinner__wrapper:nth-child(1) {
width: 100%;
}
</style>
@@ -0,0 +1,297 @@
<template>
<div class="app-container">
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
row-key="priceSetId"
>
<template #toolbar>
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="Plus"
@click="handleCreate"
v-hasPermi="addPerm"
>{{ mod.labels.createButton }}</el-button>
</el-col>
</template>
<template #search-customerId="{ model }">
<customer-select
v-model="model.customerId"
option-select
remote-search
:page-size="20"
placeholder="客户名称 / 代码"
style="width: 240px"
/>
</template>
<template #col-setType="{ row }">
<el-tag :type="row.setType === 'FIXED' ? 'warning' : 'primary'" size="small" effect="plain">
{{ row.setType === 'FIXED' ? 'FIXED 固定价' : 'DISCOUNT 折扣' }}
</el-tag>
</template>
<template #col-status="{ row }">
<el-tag :type="statusTag(row)" size="small">{{ statusLabel(row) }}</el-tag>
</template>
<template #col-boundCustomers="{ row }">
<span v-if="row.boundCustomers">{{ row.boundCustomers }}</span>
<span v-else class="text-muted">-</span>
<el-tag v-if="row.customerCount > 0" size="small" type="info" effect="plain" class="cust-count">{{ row.customerCount }}</el-tag>
</template>
<template #col-activeDetail="{ row }">
<el-button
link
type="primary"
:loading="activeLoadingId === row.priceSetId"
@click="handleActiveDetail(row)"
v-hasPermi="queryPerm"
>查看明细</el-button>
</template>
<template #col-action="{ row }">
<el-button link type="primary" @click="handleDetail(row)" v-hasPermi="queryPerm">详情</el-button>
<el-button link type="primary" @click="openBasicEdit(row)" v-hasPermi="editPerm">修改</el-button>
<el-button
v-if="row.status === 'E'"
link
type="warning"
@click="handleDisable(row)"
v-hasPermi="auditPerm"
>停用</el-button>
<el-button
v-if="row.status === 'D' || row.status === 'S'"
link
type="success"
@click="handleEnable(row)"
v-hasPermi="auditPerm"
>启用</el-button>
</template>
</basic-table>
<el-dialog v-model="basicDialog.visible" :title="basicDialogTitle" width="520px" append-to-body destroy-on-close>
<el-form ref="basicFormRef" :model="basicForm" :rules="basicRules" label-width="96px">
<el-form-item :label="mod.labels.entityCode">
<el-input :model-value="basicForm.priceSetCode" disabled />
</el-form-item>
<el-form-item :label="mod.labels.entityName" prop="priceSetName">
<el-input v-model="basicForm.priceSetName" maxlength="50" show-word-limit :placeholder="'请输入' + mod.labels.entityName" />
</el-form-item>
<el-form-item label="说明备注" prop="priceSetNote">
<el-input v-model="basicForm.priceSetNote" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="basicDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="basicDialog.saving" @click="submitBasicEdit">确定</el-button>
</template>
</el-dialog>
<set-version-detail-drawer
v-model="drawerVisible"
:version="activeVersion"
:set-type="drawerSetType"
/>
</div>
</template>
<script setup name="ExclusiveQuote">
import SetVersionDetailDrawer from '../set/components/detail/SetVersionDetailDrawer.vue'
import CustomerSelect from '@/components/CustomerSelect/index.vue'
import { setStatusLabel, setStatusTagType } from '../set/utils/priceSet'
import { exclusiveQuoteApi } from '@/api/price/exclusive'
import { providePriceSetModule, createExclusiveModule } from '../set/composables/usePriceSetModule'
// Same-component inject cannot see provide(); use return value for this page, provide for children.
const mod = providePriceSetModule(createExclusiveModule(exclusiveQuoteApi))
const addPerm = [`${mod.permPrefix}:add`]
const queryPerm = [`${mod.permPrefix}:query`]
const editPerm = [`${mod.permPrefix}:edit`]
const auditPerm = [`${mod.permPrefix}:audit`]
const { proxy } = getCurrentInstance()
const tableRef = ref()
const isFirstActivate = ref(true)
const searchColumns = computed(() => [
{
label: '状态',
prop: 'status',
component: 'select',
options: [
{ label: '待启用', value: 'D' },
{ label: '生效', value: 'E' },
{ label: '已停用', value: 'S' }
],
placeholder: '全部状态',
width: '140px'
},
{ label: '关键字', prop: 'keyword', component: 'input', placeholder: mod.labels.keywordPlaceholder, width: '200px' },
{ label: '客户', prop: 'customerId', placeholder: '客户名称 / 代码', width: '240px' }
])
const columns = computed(() => [
{ label: mod.labels.entityCode, prop: 'priceSetCode', width: 110 },
{ label: mod.labels.entityName, prop: 'priceSetName', minWidth: 160 },
{ label: '类型', prop: 'setType', width: 140 },
{ label: '绑定客户', prop: 'boundCustomers', minWidth: 220 },
{ label: '说明', prop: 'priceSetNote', minWidth: 140 },
{ label: '状态', prop: 'status', width: 90 },
{ label: '生效明细', prop: 'activeDetail', width: 100 },
{ label: '操作', prop: 'action', width: 200, showInToolbar: false }
])
function queryList(params) {
return mod.api.listPriceSet(params)
}
function statusLabel(row) {
return setStatusLabel(row)
}
function statusTag(row) {
return setStatusTagType(row)
}
function handleCreate() {
proxy.$router.push({ path: mod.routes.create })
}
function handleDetail(row) {
proxy.$router.push({ path: mod.routes.detail, query: { priceSetId: row.priceSetId } })
}
const basicDialog = reactive({ visible: false, saving: false })
const basicFormRef = ref()
const basicForm = reactive({
priceSetId: null,
priceSetCode: '',
priceSetName: '',
priceSetNote: ''
})
const basicRules = {
priceSetName: [{ required: true, message: '名称不能为空', trigger: 'blur' }]
}
const basicDialogTitle = computed(() => mod.labels.basicEditTitle)
function openBasicEdit(row) {
if (!row || row.priceSetId == null) return
basicForm.priceSetId = row.priceSetId
basicForm.priceSetCode = row.priceSetCode || ''
basicForm.priceSetName = row.priceSetName || ''
basicForm.priceSetNote = row.priceSetNote || ''
basicDialog.visible = true
nextTick(() => basicFormRef.value && basicFormRef.value.clearValidate())
}
function submitBasicEdit() {
basicFormRef.value.validate(valid => {
if (!valid) return
basicDialog.saving = true
mod.api.updatePriceSetBasic({
priceSetId: basicForm.priceSetId,
priceSetName: basicForm.priceSetName,
priceSetNote: basicForm.priceSetNote
}).then(() => {
proxy.$modal.msgSuccess('保存成功')
basicDialog.visible = false
refresh()
}).finally(() => {
basicDialog.saving = false
})
})
}
/** 生效明细:拉详情找生效版本(status=1)弹抽屉 */
const drawerVisible = ref(false)
const activeVersion = ref(null)
const drawerSetType = ref('FIXED')
const activeLoadingId = ref(null)
function handleActiveDetail(row) {
activeLoadingId.value = row.priceSetId
mod.api.getPriceSet(row.priceSetId).then(res => {
const data = res.data || {}
const versions = data.versions || []
const active = versions.find(v => String(v.status) === '1')
if (!active || active.psvId == null) {
proxy.$modal.msgWarning(mod.labels.noActiveVersion)
return
}
drawerSetType.value = row.setType
activeVersion.value = { psvId: active.psvId, versionNo: active.versionNo }
drawerVisible.value = true
}).finally(() => {
activeLoadingId.value = null
})
}
function refresh() {
tableRef.value?.refresh()
}
onActivated(() => {
if (isFirstActivate.value) {
isFirstActivate.value = false
return
}
tableRef.value?.reload()
})
function handleDisable(row) {
proxy.$modal.confirm(mod.labels.disableConfirm.replace('{name}', row.priceSetName)).then(() => {
return mod.api.disablePriceSet(row.priceSetId)
}).then(() => {
proxy.$modal.msgSuccess('已停用')
refresh()
}).catch(() => {})
}
function handleEnable(row) {
proxy.$modal.confirm(mod.labels.enableConfirm.replace('{name}', row.priceSetName)).then(() => {
return mod.api.enablePriceSet(row.priceSetId)
}).then(() => {
proxy.$modal.msgSuccess('已启用')
tableRef.value.refresh()
}).catch(() => {})
}
function handleCopy(row) {
mod.api.copyPriceSet(row.priceSetId).then(res => {
proxy.$modal.msgSuccess('已复制为草稿')
proxy.$router.push({ path: mod.routes.create, query: { priceSetId: res.data } })
})
}
</script>
<style scoped>
.text-muted { color: #909399; }
.cust-count { margin-left: 6px; vertical-align: middle; }
.hint-card {
margin-top: 16px;
}
.hint-body {
font-size: 13px;
color: #595959;
line-height: 1.7;
padding: 8px 12px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
}
.hint-body b {
color: #ff4d4f;
}
</style>
@@ -44,7 +44,9 @@ import { ArrowLeft } from '@element-plus/icons-vue'
import StepDetails from './StepDetails.vue'
import { usePriceSetRefData } from '../../composables/usePriceSetRefData'
import { isBuildModeLockedVersion } from '../../utils/priceSet'
import { getPriceSet, savePriceSetAsync } from '@/api/price/set'
import { usePriceSetModule } from '../../composables/usePriceSetModule'
const { api } = usePriceSetModule()
const props = defineProps({
priceSetId: { type: [Number, String], default: undefined },
@@ -82,7 +84,7 @@ function load() {
}
loading.value = true
loaded.value = false
getPriceSet(id).then(res => {
api.getPriceSet(id).then(res => {
const data = res.data || {}
setType.value = data.setType || 'FIXED'
priceSetName.value = data.priceSetName || ''
@@ -137,7 +139,7 @@ function reloadSavedProducts() {
const id = props.priceSetId
if (!id) return
loading.value = true
getPriceSet(id).then(res => {
api.getPriceSet(id).then(res => {
const data = res.data || {}
const versions = data.versions || []
const qPsv = props.psvId || version.psvId
@@ -154,7 +156,7 @@ function reloadSavedProducts() {
async function reloadProductsAfterImportModeSave() {
const id = props.priceSetId
if (!id) return
const res = await getPriceSet(id)
const res = await api.getPriceSet(id)
const data = res.data || {}
const versions = data.versions || []
const qPsv = props.psvId || version.psvId
@@ -190,7 +192,7 @@ async function finish() {
priceBuildMode: version.priceBuildMode
}
}
await savePriceSetAsync(payload)
await api.savePriceSetAsync(payload)
savedPriceBuildMode.value = version.priceBuildMode
await nextTick()
if (version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
@@ -14,7 +14,7 @@
@click="handlePullLibrary"
>拉取产品列表</el-button>
<el-dropdown
v-if="showImportAction && !isModifyImport"
v-if="showImportAction && !isModifyImport && showCountryImport"
trigger="click"
@command="handleImportCommand"
>
@@ -48,6 +48,7 @@
<p class="build-choice__tip">创建价格方式</p>
<div class="build-choice__cards">
<button
v-if="!onlyImportFresh"
type="button"
class="build-card build-card--pull"
:class="{ selected: isPullBuildMode(priceBuildMode) }"
@@ -58,7 +59,7 @@
<div class="build-card__desc">按产品库基础价生成套餐产品列表,可再导入 Excel 修改价格</div>
</button>
<button
v-if="showImportInherit"
v-if="showImportInherit && !onlyImportFresh"
type="button"
class="build-card build-card--import"
:class="{ selected: isImportBuildMode(priceBuildMode) }"
@@ -76,7 +77,7 @@
@click="chooseBuildMode('IMPORT_FRESH')"
>
<div class="build-card__title">Excel 新建价</div>
<div class="build-card__desc">不延用以前的产品价格,不带出上一版,上传 Excel 直接生成套餐销售价</div>
<div class="build-card__desc">{{ importFreshCardDesc }}</div>
</button>
</div>
</div>
@@ -227,9 +228,14 @@ import {
surchargeAppliesToRow,
toDetailPayload
} from '../../utils/priceSet'
import { getPriceSet, listSetDetailByProduct, listSetDetailByVersion, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, previewPullFromLibrary, previewImportDetailPrices, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
import { usePriceSetModule } from '../../composables/usePriceSetModule'
import { listRegionOptions } from '@/api/data/region'
const mod = usePriceSetModule()
const { api } = mod
const onlyImportFresh = computed(() => !!mod.onlyImportFresh)
const showCountryImport = computed(() => !mod.exclusiveQuote)
const props = defineProps({
details: { type: Array, required: true },
products: { type: Array, default: () => [] },
@@ -326,7 +332,18 @@ const importActionLabel = computed(() =>
isModifyImport.value ? '导入修改价格' : '导入 Excel 建价'
)
const importDialogTitle = computed(() => importActionLabel.value)
const importFreshCardDesc = computed(() => onlyImportFresh.value
? '仅支持大客折扣报价表(目的地/运费/操作费),导入后直接生成专属报价明细'
: '不延用以前的产品价格,不带出上一版,上传 Excel 直接生成套餐销售价'
)
const importDesc = computed(() => {
if (onlyImportFresh.value) {
return [
'\u4e13\u5c5e\u62a5\u4ef7\u4ec5\u652f\u6301\u5927\u5ba2\u6298\u6263\u62a5\u4ef7\u8868\uff08\u542b\u300c\u4ea7\u54c1\u76ee\u5f55\u300d\u4e0e\u5404\u56fd\u4ef7\u8868\uff09\u3002',
'\u8868\u5934\u4e3a\u76ee\u7684\u5730 / \u91cd\u91cf\u6bb5 / \u8fd0\u8d39(CNY/KG) / \u64cd\u4f5c\u8d39(CNY/PCS)\uff0c\u4e0e\u666e\u901a\u5957\u9910 Excel \u6a21\u677f\u4e0d\u540c\u3002',
'\u4ec5\u300cExcel \u65b0\u5efa\u4ef7\u300d\uff1a\u4e0d\u62c9\u53d6\u4ea7\u54c1\u5e93\u3001\u4e0d\u5ef6\u7528\u4e0a\u4e00\u7248\uff0c\u5bfc\u5165\u540e\u76f4\u63a5\u751f\u6210\u9500\u552e\u4ef7\u660e\u7ec6\u3002'
]
}
if (isModifyImport.value) {
return [
'适用威狮B2C小包价表(含「产品目录」)。',
@@ -377,7 +394,7 @@ async function resolvePsvId(force) {
return resolvedPsvId.value
}
if (!props.priceSetId) return null
const res = await getPriceSet(props.priceSetId)
const res = await api.getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const fromData = (data.version && data.version.psvId) ? data.version : null
@@ -420,7 +437,7 @@ async function loadLatestPriorVersionDraft() {
if (!props.priceSetId) return false
pulling.value = true
try {
const res = await getPriceSet(props.priceSetId)
const res = await api.getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
// Only exclude the draft being edited. Do NOT use resolvedPsvId — it may
@@ -437,7 +454,7 @@ async function loadLatestPriorVersionDraft() {
return row
})
if (!products.length) return false
const detailRes = await listSetDetailByVersion(prior.psvId)
const detailRes = await api.listSetDetailByVersion(prior.psvId)
applyLocalDraft(products, detailRes.data || [])
return true
} finally {
@@ -455,7 +472,7 @@ async function loadProductDetails(productCode, force) {
}
const psvId = await resolvePsvId(false)
if (!psvId) return []
const res = await listSetDetailByProduct(psvId, productCode)
const res = await api.listSetDetailByProduct(psvId, productCode)
const rows = (res.data || []).map(d => hydrateRow(d, props.setType))
detailByProduct[productCode] = rows
// keep parent details array in sync for any legacy callers
@@ -586,6 +603,18 @@ async function canOpenImport() {
return true
}
watch(
() => [onlyImportFresh.value, props.priceBuildMode, props.buildModeReady],
() => {
if (!onlyImportFresh.value || !props.buildModeReady) return
if (props.priceBuildMode) return
if (buildModeLocked.value) return
emit('update:priceBuildMode', 'IMPORT_FRESH')
},
{ immediate: true }
)
async function openImport() {
if (!(await canOpenImport())) return
importRef.value && importRef.value.open()
@@ -625,7 +654,7 @@ async function runImportAsync(uploadFile, countryCode) {
}
const replaceByProductCountry = !isModifyImport.value || !!countryCode
if (!props.livePersist) {
const res = await previewImportDetailPrices(files, {
const res = await api.previewImportDetailPrices(files, {
updateOnly: isModifyImport.value && !countryCode,
replaceByProductCountry,
countryCode: countryCode || undefined,
@@ -640,7 +669,7 @@ async function runImportAsync(uploadFile, countryCode) {
applyLocalDraft(products, data.details || [])
return data.message || '导入完成'
}
const { task } = await importSetDetailPricesAsync(
const { task } = await api.importSetDetailPricesAsync(
files,
{
priceSetId: props.priceSetId,
@@ -693,7 +722,7 @@ async function reloadDetailsFromServer() {
if (!props.priceSetId) return
try {
clearDetailCache()
const res = await getPriceSet(props.priceSetId)
const res = await api.getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const qPsv = resolvedPsvId.value || props.psvId
@@ -754,7 +783,7 @@ async function pullAllFromLibrary(opts = {}) {
if (!props.livePersist) {
importProgressMessage.value = '正在拉取产品库…'
importProgressPercent.value = 40
const res = await previewPullFromLibrary()
const res = await api.previewPullFromLibrary()
const data = res.data || {}
applyLocalDraft(data.products || [], data.details || [])
importProgressPercent.value = 100
@@ -764,7 +793,7 @@ async function pullAllFromLibrary(opts = {}) {
}
return { added: (props.products || []).length }
}
const { task } = await pullSetDetailsFromLibraryAsync(
const { task } = await api.pullSetDetailsFromLibraryAsync(
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false), force },
applyBuildProgress
)
@@ -864,7 +893,7 @@ async function persistDetail(row, withSurcharge) {
const payload = toDetailPayload(row)
payload.priceSetId = props.priceSetId
if (!withSurcharge) delete payload.surcharges
await updateSetDetail(payload)
await api.updateSetDetail(payload)
}
/** drawer hover remove surcharge -> write snapshot for this row */
@@ -1035,7 +1064,7 @@ function onSurchargeConfirm(picked) {
return
}
proxy.$modal.msgSuccess(okText + ',正在后台写入…')
updateSetDetailSurchargeAsync(payload).then(() => {
api.updateSetDetailSurchargeAsync(payload).then(() => {
proxy.$modal.msgSuccess(okText + ',已写入数据库')
}).catch(e => {
proxy.$modal.msgError((e && e.message) ? e.message : '附加费后台写入失败')
@@ -1048,7 +1077,7 @@ async function removeProduct(code) {
const rows = await loadProductDetails(code)
const ids = rows.filter(d => d && d.psdId).map(d => d.psdId)
if (ids.length) {
await delSetDetail(ids.join(','))
await api.delSetDetail(ids.join(','))
}
} catch (e) {
return
@@ -83,7 +83,9 @@
* 套餐绑定客户 · 新增(支持批量) / 编辑弹窗
*/
import { createEmptyCustomerRow } from '../../utils/priceSet'
import { batchAddSetCustomer, updateSetCustomer } from '@/api/price/set'
import { usePriceSetModule } from '../../composables/usePriceSetModule'
const { api } = usePriceSetModule()
const props = defineProps({
priceSetId: {
@@ -174,7 +176,7 @@ async function submit() {
try {
if (isEdit.value) {
const payload = { ...form.value, priceSetId: props.priceSetId }
await updateSetCustomer(payload)
await api.updateSetCustomer(payload)
proxy.$modal.msgSuccess('保存成功')
} else {
const customers = selectedCustomers.value.map(c => ({
@@ -182,7 +184,7 @@ async function submit() {
customerCode: c.customerCode,
customerName: c.customerName
}))
const res = await batchAddSetCustomer({
const res = await api.batchAddSetCustomer({
priceSetId: props.priceSetId,
bindPriority: form.value.bindPriority,
prBegindate: form.value.prBegindate,
@@ -12,7 +12,7 @@
size="small"
icon="Plus"
@click="dialogRef.open()"
v-hasPermi="['price:set:edit']"
v-hasPermi="editPerm"
>添加客户</el-button>
</template>
@@ -31,8 +31,8 @@
</el-table-column>
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
<el-button link type="danger" @click="remove(row)" v-hasPermi="['price:set:edit']">解绑</el-button>
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="editPerm">编辑</el-button>
<el-button link type="danger" @click="remove(row)" v-hasPermi="editPerm">解绑</el-button>
</template>
</el-table-column>
</el-table>
@@ -55,7 +55,10 @@
import SetSectionCard from './SetSectionCard.vue'
import SetCustomerDialog from './SetCustomerDialog.vue'
import { isChildEditable } from '../../utils/priceSet'
import { delSetCustomer } from '@/api/price/set'
import { usePriceSetModule, usePriceSetPerm } from '../../composables/usePriceSetModule'
const { api } = usePriceSetModule()
const editPerm = usePriceSetPerm('edit')
const props = defineProps({
/** 套餐详情对象 */
@@ -75,7 +78,7 @@ const customers = computed(() => props.set.customers || [])
const editable = computed(() => isChildEditable(props.set.status))
function remove(row) {
proxy.$modal.confirm('确认解绑该客户?').then(() => delSetCustomer(row.customerPricesetId)).then(() => {
proxy.$modal.confirm('确认解绑该客户?').then(() => api.delSetCustomer(row.customerPricesetId)).then(() => {
proxy.$modal.msgSuccess('已解绑')
emit('refresh')
}).catch(() => {})
@@ -164,7 +164,9 @@
import SurchargeTags from '../SurchargeTags.vue'
import { useDetailCascade } from '../../composables/usePriceSetRefData'
import { createEmptyDetailRow, isFixedType, toEditableDetailRow } from '../../utils/priceSet'
import { addSetDetail, updateSetDetail } from '@/api/price/set'
import { usePriceSetModule } from '../../composables/usePriceSetModule'
const { api } = usePriceSetModule()
const props = defineProps({
/** 所属套餐ID */
@@ -228,7 +230,7 @@ async function submit() {
const payload = { ...form.value, priceSetId: props.priceSetId }
delete payload._countryKey
submitting.value = true
const req = payload.psdId ? updateSetDetail(payload) : addSetDetail(payload)
const req = payload.psdId ? api.updateSetDetail(payload) : api.addSetDetail(payload)
req.then(() => {
proxy.$modal.msgSuccess('保存成功')
visible.value = false
@@ -2,7 +2,7 @@
<div class="set-header">
<div class="hd-main">
<div class="hd-line">
<h2 class="hd-name">{{ set.priceSetName || '套餐详情' }}</h2>
<h2 class="hd-name">{{ set.priceSetName || mod.labels.detailTitleFallback }}</h2>
<span v-if="set.priceSetCode" class="hd-code">{{ set.priceSetCode }}</span>
<el-tag :type="setStatusTagType(set)" size="small">{{ setStatusLabel(set) }}</el-tag>
</div>
@@ -20,14 +20,14 @@
type="danger"
plain
@click="emit('disable')"
v-hasPermi="['price:set:audit']"
v-hasPermi="auditPerm"
>停用</el-button>
<el-button
v-if="set.status !== 'E'"
type="success"
plain
@click="emit('enable')"
v-hasPermi="['price:set:audit']"
v-hasPermi="auditPerm"
>启用</el-button>
</div>
</div>
@@ -35,6 +35,10 @@
<script setup name="SetDetailHeader">
import { isFixedType, setTypeLabel, setStatusLabel, setStatusTagType } from '../../utils/priceSet'
import { usePriceSetModule, usePriceSetPerm } from '../../composables/usePriceSetModule'
const mod = usePriceSetModule()
const auditPerm = usePriceSetPerm('audit')
defineProps({
set: { type: Object, required: true }
@@ -12,7 +12,7 @@
size="small"
icon="Plus"
@click="dialogRef.open()"
v-hasPermi="['price:set:edit']"
v-hasPermi="editPerm"
>添加明细</el-button>
</template>
@@ -86,8 +86,8 @@
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
<el-button link type="danger" @click="remove(row)" v-hasPermi="['price:set:edit']">删除</el-button>
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="editPerm">编辑</el-button>
<el-button link type="danger" @click="remove(row)" v-hasPermi="editPerm">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -113,7 +113,10 @@ import SetSectionCard from './SetSectionCard.vue'
import SetDetailDialog from './SetDetailDialog.vue'
import SurchargeTags from '../SurchargeTags.vue'
import { isChildEditable, isFixedType } from '../../utils/priceSet'
import { delSetDetail } from '@/api/price/set'
import { usePriceSetModule, usePriceSetPerm } from '../../composables/usePriceSetModule'
const { api } = usePriceSetModule()
const editPerm = usePriceSetPerm('edit')
const props = defineProps({
/** 套餐详情对象 */
@@ -172,7 +175,7 @@ function roundModeLabel(mode) {
}
function remove(row) {
proxy.$modal.confirm('确认删除该明细?').then(() => delSetDetail(row.psdId)).then(() => {
proxy.$modal.confirm('确认删除该明细?').then(() => api.delSetDetail(row.psdId)).then(() => {
proxy.$modal.msgSuccess('已删除')
emit('refresh')
}).catch(() => {})
@@ -129,7 +129,9 @@
import { ArrowLeft } from '@element-plus/icons-vue'
import SurchargeTags from '../SurchargeTags.vue'
import { isFixedType } from '../../utils/priceSet'
import { listSetDetailByProduct, listSetDetailOptions } from '@/api/price/set'
import { usePriceSetModule } from '../../composables/usePriceSetModule'
const { api } = usePriceSetModule()
const props = defineProps({
modelValue: { type: Boolean, default: false },
@@ -318,7 +320,7 @@ async function loadProducts() {
products.value = fromVersion
return
}
const res = await listSetDetailOptions(psvId)
const res = await api.listSetDetailOptions(psvId)
const data = res.data || {}
products.value = normalizeProducts(data.products || [])
} finally {
@@ -335,7 +337,7 @@ async function openProductDetail(row) {
detailKeyword.value = ''
detailLoading.value = true
try {
const res = await listSetDetailByProduct(psvId, row.productCode)
const res = await api.listSetDetailByProduct(psvId, row.productCode)
detailRows.value = res.data || []
} catch (e) {
detailRows.value = []
@@ -18,7 +18,9 @@
</template>
<script setup>
import { listPriceSetVersionExcelFiles } from '@/api/price/set'
import { usePriceSetModule } from '../../composables/usePriceSetModule'
const { api } = usePriceSetModule()
const { proxy } = getCurrentInstance()
@@ -39,7 +41,7 @@ function open(row) {
function loadList() {
if (!version.value || version.value.psvId == null) return
loading.value = true
listPriceSetVersionExcelFiles(version.value.psvId).then(res => {
api.listPriceSetVersionExcelFiles(version.value.psvId).then(res => {
list.value = res.data || []
}).finally(() => {
loading.value = false
@@ -6,7 +6,7 @@
plain
size="small"
:disabled="hasDraft"
v-hasPermi="['price:set:add']"
v-hasPermi="addPerm"
@click="emit('create-version')"
>新建价格版本</el-button>
</template>
@@ -50,14 +50,14 @@
link
type="success"
@click="emit('activate', row)"
v-hasPermi="['price:set:edit']"
v-hasPermi="editPerm"
>启动</el-button>
<el-button
v-if="isPendingVersion(row)"
link
type="primary"
@click="emit('reschedule', row)"
v-hasPermi="['price:set:edit']"
v-hasPermi="editPerm"
>修改生效时间</el-button>
<el-button
v-if="isDraftVersion(row) || isPendingVersion(row) || isEffectVersion(row)"
@@ -66,7 +66,7 @@
:loading="isRemoving(row)"
:disabled="removingPsvId != null"
@click="emit('remove', row)"
v-hasPermi="['price:set:edit']"
v-hasPermi="editPerm"
>删除</el-button>
</template>
</el-table-column>
@@ -78,6 +78,11 @@
import SetSectionCard from './SetSectionCard.vue'
import { versionStatusLabel, versionStatusTag, isDraftVersion, isPendingVersion, isEffectVersion, isPriceEditableVersion } from '../../utils/priceSet'
import { checkPermi } from '@/utils/permission'
import { usePriceSetModule, usePriceSetPerm } from '../../composables/usePriceSetModule'
const mod = usePriceSetModule()
const addPerm = usePriceSetPerm('add')
const editPerm = usePriceSetPerm('edit')
const props = defineProps({
versions: { type: Array, default: () => [] },
@@ -164,8 +169,8 @@ function detailCount(row) {
}
function showEditButton(row) {
if (checkPermi(['price:set:forceEdit'])) return true
return isPriceEditableVersion(row) && checkPermi(['price:set:edit'])
if (checkPermi([mod.permPrefix + ':forceEdit'])) return true
return isPriceEditableVersion(row) && checkPermi([mod.permPrefix + ':edit'])
}
function isRemoving(row) {
@@ -0,0 +1,77 @@
import { inject, provide } from 'vue'
import { priceSetApi } from '@/api/price/set'
export const PRICE_SET_MODULE_KEY = 'priceSetModule'
const defaultModule = Object.freeze({
api: priceSetApi,
permPrefix: 'price:set',
exclusiveQuote: false,
onlyImportFresh: false,
routes: Object.freeze({
list: '/customer/set',
create: '/customer/setCreate',
detail: '/customer/setDetail'
}),
labels: Object.freeze({
entity: '套餐',
entityCode: '套餐编码',
entityName: '套餐名称',
createButton: '新建套餐',
saveProgressTitle: '正在保存套餐',
savedSuccess: '套餐已保存',
noActiveVersion: '该套餐暂无生效版本',
disableConfirm: '停用后该套餐不再参与计费命中,确认停用【{name}】?',
enableConfirm: '启用后该套餐将重新参与计费命中,确认启用【{name}】?',
basicEditTitle: '修改套餐基本信息',
keywordPlaceholder: '套餐编码 / 名称',
detailTitleFallback: '套餐详情'
})
})
export function createExclusiveModule(api) {
return Object.freeze({
api,
permPrefix: 'price:exclusive',
exclusiveQuote: true,
onlyImportFresh: true,
routes: Object.freeze({
list: '/exclusive/quote',
create: '/exclusive/quoteCreate',
detail: '/exclusive/quoteDetail'
}),
labels: Object.freeze({
entity: '专属报价',
entityCode: '报价编码',
entityName: '报价名称',
createButton: '新建报价',
saveProgressTitle: '正在保存专属报价',
savedSuccess: '专属报价已保存',
noActiveVersion: '该专属报价暂无生效版本',
disableConfirm: '停用后该专属报价不再参与计费命中,确认停用【{name}】?',
enableConfirm: '启用后该专属报价将重新参与计费命中,确认启用【{name}】?',
basicEditTitle: '修改专属报价基本信息',
keywordPlaceholder: '报价编码 / 名称',
detailTitleFallback: '专属报价详情'
})
})
}
/**
* Provide module for child inject, and return cfg for the providing page itself
* (same-component inject cannot see its own provide).
*/
export function providePriceSetModule(moduleConfig) {
const cfg = moduleConfig || defaultModule
provide(PRICE_SET_MODULE_KEY, cfg)
return cfg
}
export function usePriceSetModule() {
return inject(PRICE_SET_MODULE_KEY, defaultModule)
}
export function usePriceSetPerm(action) {
const mod = usePriceSetModule()
return [`${mod.permPrefix}:${action}`]
}
+16 -14
View File
@@ -34,7 +34,7 @@
<el-dialog
v-model="progressVisible"
:title="'正在保存套餐'"
:title="mod.labels.saveProgressTitle"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
@@ -61,7 +61,9 @@ import {
toCustomerPayload,
toDetailPayload
} from './utils/priceSet'
import { getPriceSet, savePriceSetAsync, createPriceSetVersion, deletePriceSetVersion } from '@/api/price/set'
import { usePriceSetModule } from './composables/usePriceSetModule'
const mod = usePriceSetModule()
const { proxy } = getCurrentInstance()
const route = useRoute()
@@ -131,7 +133,7 @@ async function runSaveWithProgress(payload) {
progressMessage.value = ''
progressStatus.value = undefined
try {
const result = await savePriceSetAsync(payload, (task) => {
const result = await mod.api.savePriceSetAsync(payload, (task) => {
progressPercent.value = Math.min(100, Number(task.percent) || 0)
progressMessage.value = mapSaveProgressMessage(task.message)
if (task.status === 'FAIL') {
@@ -204,7 +206,7 @@ async function finishEdit() {
proxy.$modal.msgSuccess("修改已实时落库")
}
const obj = {
path: '/customer/setDetail',
path: mod.routes.detail,
query: { priceSetId: form.priceSetId }
}
if (proxy.$tab) {
@@ -230,7 +232,7 @@ async function finishEdit() {
creating.value = true
try {
if (newVersionMode.value && form.version.psvId == null && form.priceSetId) {
const verRes = await createPriceSetVersion(form.priceSetId)
const verRes = await mod.api.createPriceSetVersion(form.priceSetId)
if (verRes.data != null) {
form.version.psvId = verRes.data
}
@@ -238,9 +240,9 @@ async function finishEdit() {
const payload = buildPayload({ forceDetails: true })
const priceSetId = await runSaveWithProgress(payload)
form.priceSetId = priceSetId
proxy.$modal.msgSuccess('套餐已保存')
proxy.$modal.msgSuccess(mod.labels.savedSuccess)
const obj = {
path: '/customer/setDetail',
path: mod.routes.detail,
query: { priceSetId }
}
if (proxy.$tab) {
@@ -305,14 +307,14 @@ async function tryDeleteEmptyNewVersionDraft() {
if (!newVersionMode.value || !form.priceSetId || form.version.psvId == null) return
const psvId = form.version.psvId
try {
const res = await getPriceSet(form.priceSetId)
const res = await mod.api.getPriceSet(form.priceSetId)
const versions = (res.data || {}).versions || []
const v = versions.find(x => x && String(x.psvId) === String(psvId))
if (!v || String(v.status) !== '0') return
const noMode = !v.priceBuildMode
const noDetails = !(Number(v.detailCount) > 0) && !(v.details || []).length
if (noMode && noDetails) {
await deletePriceSetVersion(psvId)
await mod.api.deletePriceSetVersion(psvId)
}
} catch (e) {
// ignore cleanup errors; still navigate back
@@ -322,8 +324,8 @@ async function tryDeleteEmptyNewVersionDraft() {
async function goBack() {
await tryDeleteEmptyNewVersionDraft()
const obj = (newVersionMode.value || copyMode.value) && form.priceSetId
? { path: '/customer/setDetail', query: { priceSetId: form.priceSetId } }
: { path: '/customer/set' }
? { path: mod.routes.detail, query: { priceSetId: form.priceSetId } }
: { path: mod.routes.list }
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
@@ -418,7 +420,7 @@ function onPriceBuildModeUpdate(mode) {
}
function reloadSavedProductsForCreate() {
getPriceSet(form.priceSetId).then(res => {
mod.api.getPriceSet(form.priceSetId).then(res => {
const data = res.data || {}
const versions = data.versions || []
const qPsv = form.version.psvId
@@ -433,7 +435,7 @@ function reloadSavedProductsForCreate() {
async function refreshProductsFromCurrentVersion() {
if (!form.priceSetId) return
const res = await getPriceSet(form.priceSetId)
const res = await mod.api.getPriceSet(form.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const qPsv = form.version.psvId
@@ -520,7 +522,7 @@ function applySetData(data, asCopy, asNewVersion) {
function loadExisting(id, asCopy, asNewVersion) {
pageReady.value = false
return getPriceSet(id).then(res => {
return mod.api.getPriceSet(id).then(res => {
applySetData(res.data || {}, asCopy, asNewVersion)
}).finally(() => {
pageReady.value = true
+12 -12
View File
@@ -83,11 +83,11 @@ import SetVersionDetailDrawer from './components/detail/SetVersionDetailDrawer.v
import SetVersionExcelFileDrawer from './components/detail/SetVersionExcelFileDrawer.vue'
import PriceEditPanel from './components/create/PriceEditPanel.vue'
import { usePriceSetRefData } from './composables/usePriceSetRefData'
import {
getPriceSet, disablePriceSet, enablePriceSet, activatePriceSetVersion, deletePriceSetVersion
} from '@/api/price/set'
import { usePriceSetModule } from './composables/usePriceSetModule'
import { isDraftVersion, isPendingVersion, isEffectVersion, isImportFreshBuildMode } from './utils/priceSet'
const mod = usePriceSetModule()
const { proxy } = getCurrentInstance()
const route = useRoute()
const router = useRouter()
@@ -140,7 +140,7 @@ function load() {
// 立即标记,首次挂载后紧随的 activated 不会重复请求
loadedId.value = String(id)
loading.value = true
getPriceSet(id).then(res => {
mod.api.getPriceSet(id).then(res => {
set.value = res.data || {}
const versions = set.value.versions || []
const cur = set.value.version || versions[0]
@@ -172,10 +172,10 @@ function confirmAction(text, request, successText, done) {
}
function onDisable() {
confirmAction('停用后不再参与计费命中,确认停用?', () => disablePriceSet(set.value.priceSetId), '已停用')
confirmAction(mod.labels.disableConfirm.replace('{name}', set.value.priceSetName || ''), () => mod.api.disablePriceSet(set.value.priceSetId), '已停用')
}
function onEnable() {
confirmAction('启用后套餐将重新参与计费命中,确认启用?', () => enablePriceSet(set.value.priceSetId), '已启用')
confirmAction(mod.labels.enableConfirm.replace('{name}', set.value.priceSetName || ''), () => mod.api.enablePriceSet(set.value.priceSetId), '已启用')
}
/** Effective time precision: hour only (mm:ss always 00) */
function pad2(n) {
@@ -413,7 +413,7 @@ function submitActivate() {
if (!valid) return
activateForm.effectiveTime = normalizeToHour(activateForm.effectiveTime)
activateSubmitting.value = true
activatePriceSetVersion(activateForm.psvId, { effectiveTime: activateForm.effectiveTime })
mod.api.activatePriceSetVersion(activateForm.psvId, { effectiveTime: activateForm.effectiveTime })
.then(() => {
const eff = activateForm.effectiveTime
const pending = eff && new Date(eff.replace(/-/g, '/')).getTime() > Date.now()
@@ -439,7 +439,7 @@ function onCreateVersion() {
}
// Do not insert draft until user clicks finish on the build-price page.
router.push({
path: '/customer/setCreate',
path: mod.routes.create,
query: { priceSetId, step: '2', newVersion: '1' }
})
}
@@ -451,7 +451,7 @@ function onRemoveVersion(row) {
proxy.$modal.confirm(msg).then(() => {
removingPsvId.value = row.psvId
proxy.$modal.loading("正在删除,请稍候...")
return deletePriceSetVersion(row.psvId).then(() => {
return mod.api.deletePriceSetVersion(row.psvId).then(() => {
proxy.$modal.msgSuccess("版本已删除")
load()
}).finally(() => {
@@ -468,7 +468,7 @@ function onEditVersion(row) {
// Empty unfinished draft (new version, not finished): resume create step2 with build-mode choice
if (isDraftVersion(row) && noMode && noProducts && priceSetId != null) {
router.push({
path: '/customer/setCreate',
path: mod.routes.create,
query: { priceSetId, psvId: row.psvId, step: '2', newVersion: '1' }
})
return
@@ -483,7 +483,7 @@ function closeEdit() {
load()
}
function goList() {
const obj = { path: '/customer/set' }
const obj = { path: mod.routes.list }
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
@@ -509,7 +509,7 @@ watch(() => route.query.priceSetId, () => {
// 离开详情页去新建/编辑套餐版本,返回后需要重新加载
onBeforeRouteLeave((to) => {
if (to && to.path === '/customer/setCreate') {
if (to && to.path === mod.routes.create) {
pendingRefresh.value = true
}
})
+37 -34
View File
@@ -14,8 +14,8 @@
plain
icon="Plus"
@click="handleCreate"
v-hasPermi="['price:set:add']"
>新建套餐</el-button>
v-hasPermi="addPerm"
>{{ mod.labels.createButton }}</el-button>
</el-col>
</template>
@@ -52,37 +52,37 @@
type="primary"
:loading="activeLoadingId === row.priceSetId"
@click="handleActiveDetail(row)"
v-hasPermi="['price:set:query']"
v-hasPermi="queryPerm"
>查看明细</el-button>
</template>
<template #col-action="{ row }">
<el-button link type="primary" @click="handleDetail(row)" v-hasPermi="['price:set:query']">详情</el-button>
<el-button link type="primary" @click="openBasicEdit(row)" v-hasPermi="['price:set:edit']">修改</el-button>
<el-button link type="primary" @click="handleDetail(row)" v-hasPermi="queryPerm">详情</el-button>
<el-button link type="primary" @click="openBasicEdit(row)" v-hasPermi="editPerm">修改</el-button>
<el-button
v-if="row.status === 'E'"
link
type="warning"
@click="handleDisable(row)"
v-hasPermi="['price:set:audit']"
v-hasPermi="auditPerm"
>停用</el-button>
<el-button
v-if="row.status === 'D' || row.status === 'S'"
link
type="success"
@click="handleEnable(row)"
v-hasPermi="['price:set:audit']"
v-hasPermi="auditPerm"
>启用</el-button>
</template>
</basic-table>
<el-dialog v-model="basicDialog.visible" :title="basicDialogTitle" width="520px" append-to-body destroy-on-close>
<el-form ref="basicFormRef" :model="basicForm" :rules="basicRules" label-width="96px">
<el-form-item label="套餐编码">
<el-form-item :label="mod.labels.entityCode">
<el-input :model-value="basicForm.priceSetCode" disabled />
</el-form-item>
<el-form-item label="套餐名称" prop="priceSetName">
<el-input v-model="basicForm.priceSetName" maxlength="50" show-word-limit placeholder="请输入套餐名称" />
<el-form-item :label="mod.labels.entityName" prop="priceSetName">
<el-input v-model="basicForm.priceSetName" maxlength="50" show-word-limit :placeholder="'请输入' + mod.labels.entityName" />
</el-form-item>
<el-form-item label="说明备注" prop="priceSetNote">
<el-input v-model="basicForm.priceSetNote" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="可选" />
@@ -104,18 +104,21 @@
</template>
<script setup name="PriceSet">
import {
listPriceSet, disablePriceSet, enablePriceSet, copyPriceSet, getPriceSet, updatePriceSetBasic
} from '@/api/price/set'
import SetVersionDetailDrawer from './components/detail/SetVersionDetailDrawer.vue'
import CustomerSelect from '@/components/CustomerSelect/index.vue'
import { setStatusLabel, setStatusTagType } from './utils/priceSet'
import { usePriceSetModule, usePriceSetPerm } from './composables/usePriceSetModule'
const { proxy } = getCurrentInstance()
const mod = usePriceSetModule()
const addPerm = usePriceSetPerm('add')
const queryPerm = usePriceSetPerm('query')
const editPerm = usePriceSetPerm('edit')
const auditPerm = usePriceSetPerm('audit')
const tableRef = ref()
const isFirstActivate = ref(true)
const searchColumns = [
const searchColumns = computed(() => [
{
label: '状态',
prop: 'status',
@@ -128,23 +131,23 @@ const searchColumns = [
placeholder: '全部状态',
width: '140px'
},
{ label: '关键字', prop: 'keyword', component: 'input', placeholder: '套餐编码 / 名称', width: '200px' },
{ label: '关键字', prop: 'keyword', component: 'input', placeholder: mod.labels.keywordPlaceholder, width: '200px' },
{ label: '客户', prop: 'customerId', placeholder: '客户名称 / 代码', width: '240px' }
]
])
const columns = [
{ label: '套餐编码', prop: 'priceSetCode', width: 110 },
{ label: '套餐名称', prop: 'priceSetName', minWidth: 160 },
const columns = computed(() => [
{ label: mod.labels.entityCode, prop: 'priceSetCode', width: 110 },
{ label: mod.labels.entityName, prop: 'priceSetName', minWidth: 160 },
{ label: '类型', prop: 'setType', width: 140 },
{ label: '绑定客户', prop: 'boundCustomers', minWidth: 220 },
{ label: '说明', prop: 'priceSetNote', minWidth: 140 },
{ label: '状态', prop: 'status', width: 90 },
{ label: '生效明细', prop: 'activeDetail', width: 100 },
{ label: '操作', prop: 'action', width: 200, showInToolbar: false }
]
])
function queryList(params) {
return listPriceSet(params)
return mod.api.listPriceSet(params)
}
function statusLabel(row) {
@@ -156,11 +159,11 @@ function statusTag(row) {
}
function handleCreate() {
proxy.$router.push({ path: '/customer/setCreate' })
proxy.$router.push({ path: mod.routes.create })
}
function handleDetail(row) {
proxy.$router.push({ path: '/customer/setDetail', query: { priceSetId: row.priceSetId } })
proxy.$router.push({ path: mod.routes.detail, query: { priceSetId: row.priceSetId } })
}
@@ -173,9 +176,9 @@ const basicForm = reactive({
priceSetNote: ''
})
const basicRules = {
priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }]
priceSetName: [{ required: true, message: '名称不能为空', trigger: 'blur' }]
}
const basicDialogTitle = computed(() => '修改套餐基本信息')
const basicDialogTitle = computed(() => mod.labels.basicEditTitle)
function openBasicEdit(row) {
if (!row || row.priceSetId == null) return
@@ -191,7 +194,7 @@ function submitBasicEdit() {
basicFormRef.value.validate(valid => {
if (!valid) return
basicDialog.saving = true
updatePriceSetBasic({
mod.api.updatePriceSetBasic({
priceSetId: basicForm.priceSetId,
priceSetName: basicForm.priceSetName,
priceSetNote: basicForm.priceSetNote
@@ -214,12 +217,12 @@ const activeLoadingId = ref(null)
function handleActiveDetail(row) {
activeLoadingId.value = row.priceSetId
getPriceSet(row.priceSetId).then(res => {
mod.api.getPriceSet(row.priceSetId).then(res => {
const data = res.data || {}
const versions = data.versions || []
const active = versions.find(v => String(v.status) === '1')
if (!active || active.psvId == null) {
proxy.$modal.msgWarning('该套餐暂无生效版本')
proxy.$modal.msgWarning(mod.labels.noActiveVersion)
return
}
drawerSetType.value = row.setType
@@ -245,8 +248,8 @@ onActivated(() => {
function handleDisable(row) {
proxy.$modal.confirm('停用后该套餐不再参与计费命中,确认停用【' + row.priceSetName + '】?').then(() => {
return disablePriceSet(row.priceSetId)
proxy.$modal.confirm(mod.labels.disableConfirm.replace('{name}', row.priceSetName)).then(() => {
return mod.api.disablePriceSet(row.priceSetId)
}).then(() => {
proxy.$modal.msgSuccess('已停用')
refresh()
@@ -254,8 +257,8 @@ function handleDisable(row) {
}
function handleEnable(row) {
proxy.$modal.confirm('启用后该套餐将重新参与计费命中,确认启用【' + row.priceSetName + '】?').then(() => {
return enablePriceSet(row.priceSetId)
proxy.$modal.confirm(mod.labels.enableConfirm.replace('{name}', row.priceSetName)).then(() => {
return mod.api.enablePriceSet(row.priceSetId)
}).then(() => {
proxy.$modal.msgSuccess('已启用')
tableRef.value.refresh()
@@ -263,9 +266,9 @@ function handleEnable(row) {
}
function handleCopy(row) {
copyPriceSet(row.priceSetId).then(res => {
mod.api.copyPriceSet(row.priceSetId).then(res => {
proxy.$modal.msgSuccess('已复制为草稿')
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: res.data } })
proxy.$router.push({ path: mod.routes.create, query: { priceSetId: res.data } })
})
}
</script>