导入批量修改价格明细

This commit is contained in:
wuweihua
2026-09-28 18:01:26 +08:00
parent c278ed76f7
commit b247bc2fd2
19 changed files with 777 additions and 54 deletions
@@ -17,6 +17,7 @@ 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.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
@@ -218,6 +219,19 @@ public class PriceSetController extends BaseController
return toAjax(priceSetService.updateDetail(detail));
}
@PreAuthorize("@ss.hasPermi('price:set:edit') or @ss.hasPermi('price:set:forceEdit')")
@Log(title = "\u5957\u9910\u4ef7\u683c\u5bfc\u5165", businessType = BusinessType.IMPORT)
@PostMapping("/detail/importPrices")
public AjaxResult importDetailPrices(MultipartFile file,
@RequestParam("priceSetId") Long priceSetId,
@RequestParam(value = "psvId", required = false) Long psvId) throws Exception
{
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, file, 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}.
*/
@@ -75,6 +75,10 @@ price.set.detail.registration.empty=固定价明细必须填写挂号费
price.set.detail.zone.empty=固定价明细必须选择分区
price.set.detail.roundMode.invalid=取值方式仅支持 UP / DOWN / HALF_UP
price.set.detail.freight.belowBase=套餐运费不能低于导入基准价 {0}
price.set.import.fixedOnly=\u4ec5\u56fa\u5b9a\u4ef7\u5957\u9910\u652f\u6301\u5bfc\u5165\u4fee\u6539\u4ef7\u683c
price.set.import.noDetails=\u5957\u9910\u6682\u65e0\u4ef7\u683c\u660e\u7ec6\uff0c\u8bf7\u5148\u62c9\u53d6\u4ea7\u54c1\u4ef7
price.set.import.noPriceRows=\u4ef7\u8868\u672a\u89e3\u6790\u5230\u6709\u6548\u4ef7\u683c\u884c
price.set.import.success=\u5bfc\u5165\u5b8c\u6210\uff1a\u66f4\u65b0 {0} \u6761\uff0c\u672a\u5339\u914d {1} \u6761\uff0c\u5957\u9910\u65e0\u5bf9\u5e94\u4ea7\u54c1 {2} \u4e2a
price.set.customer.notExists=客户绑定不存在
price.set.customer.unbindAudit=审核中的套餐不允许解绑客户
price.set.customer.customerId.empty=客户ID不能为空
@@ -37,6 +37,10 @@ public class PrcPriceSetDetail implements Serializable
private Long productCountryId;
private BigDecimal fvValue;
private BigDecimal fvRegistrationFee;
/** product base freight from bms_product_country (display only) */
private BigDecimal baseFreightPrice;
/** product base registration from bms_product_country (display only) */
private BigDecimal baseRegistrationFee;
private BigDecimal discountValue;
/** UP / DOWN / HALF_UP */
private String roundMode;
@@ -1,5 +1,6 @@
package com.bms.price.mapper;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
@@ -75,4 +76,10 @@ public interface PrcPriceSetDetailMapper
@Param("priceSetId") Long priceSetId,
@Param("psvId") Long psvId,
@Param("productCode") String productCode);
/** Update package freight / registration only */
public int updateDetailPrices(
@Param("psdId") Long psdId,
@Param("fvValue") BigDecimal fvValue,
@Param("fvRegistrationFee") BigDecimal fvRegistrationFee);
}
@@ -58,4 +58,13 @@ public interface IBmsProductService
/** Distinct base-price version options (by effective_date desc) for package create */
public List<BmsProductPriceVersion> selectPriceVersionOptions();
/**
* Parse Weishi B2C quote Excel into productCode -> country/zone/weight price rows (no persist).
* Used by price-set detail price import.
*/
public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception;
/** Parse quote Excel from in-memory bytes (async import). */
public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception;
}
@@ -1,6 +1,7 @@
package com.bms.price.service;
import java.util.Date;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
import com.bms.price.domain.PrcCustomerPriceSet;
@@ -96,4 +97,19 @@ public interface IPrcPriceSetService
/** poll async save progress */
public PrcPriceSetSaveTask getSaveTask(String taskId);
/**
* Submit async import of Weishi B2C quote Excel into FIXED package detail prices.
* @return taskId for polling getSaveTask
*/
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName) throws Exception;
/**
* Sync worker: parse bytes and update FIXED detail prices matched by
* product + country + zone(if any) + weight band.
*/
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName) throws Exception;
/** Sync worker with optional taskId for progress reporting. */
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName, String taskId) throws Exception;
}
@@ -8,6 +8,7 @@ import java.util.Arrays;
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;
@@ -421,6 +422,73 @@ public class BmsProductServiceImpl implements IBmsProductService
}
}
@Override
public Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception
{
if (file == null || file.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
}
return parseQuoteExcelByProduct(file.getBytes(), file.getOriginalFilename());
}
@Override
public Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception
{
if (bytes == null || bytes.length == 0)
{
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
}
if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx")
|| filename.toLowerCase(Locale.ROOT).endsWith(".xls")))
{
throw new ServiceException(MessageUtils.message("price.product.import.fileType"));
}
try (InputStream is = new java.io.ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is))
{
Sheet catalog = workbook.getSheet(CATALOG_SHEET);
if (catalog == null)
{
throw new ServiceException(MessageUtils.message("price.product.import.sheetNotFound"));
}
List<BmsProduct> products = dedupeCatalogProducts(parseCatalog(catalog));
if (products.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.noData"));
}
Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn();
List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode();
Set<String> unmatchedCountries = new HashSet<>();
Map<String, List<BmsProductCountry>> out = new LinkedHashMap<>();
for (BmsProduct product : products)
{
if (product == null || StringUtils.isEmpty(product.getProductCode()))
{
continue;
}
Sheet productSheet = findProductSheet(workbook, product);
if (productSheet == null)
{
continue;
}
List<BmsProductCountry> rows = parseProductSheet(productSheet, product,
regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries);
if (rows == null || rows.isEmpty())
{
continue;
}
String code = product.getProductCode().trim().toUpperCase(Locale.ROOT);
out.put(code, rows);
}
if (out.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
}
return out;
}
}
@Override
public List<BmsProductPriceVersion> selectPriceVersionOptions()
{
@@ -9,12 +9,14 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.Set;
import java.util.zip.CRC32;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.DateUtils;
import com.bms.common.utils.MessageUtils;
@@ -38,6 +40,7 @@ import com.bms.price.mapper.PrcPriceSetVersionMapper;
import com.bms.price.mapper.PrcPriceSetVersionSurchargeMapper;
import com.bms.price.mapper.PrcSurchargeMapper;
import com.bms.price.mapper.PrcSurchargeRelMapper;
import com.bms.price.service.IBmsProductService;
import com.bms.price.service.IPrcPriceSetService;
import com.bms.price.task.PrcPriceSetSaveAsyncRunner;
@@ -87,6 +90,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Autowired
private BmsProductCountryMapper productCountryMapper;
@Lazy
@Autowired
private IBmsProductService productService;
@Lazy
@Autowired
private PrcPriceSetSaveAsyncRunner saveAsyncRunner;
@@ -797,6 +804,270 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return rows;
}
@Override
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName) throws Exception
{
PrcPriceSet db = requireSet(priceSetId);
assertChildEditable(db);
if (!TYPE_FIXED.equals(db.getSetType()))
{
throw new ServiceException(MessageUtils.message("price.set.import.fixedOnly"));
}
if (file == null || file.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
}
String filename = file.getOriginalFilename();
if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx")
|| filename.toLowerCase(Locale.ROOT).endsWith(".xls")))
{
throw new ServiceException(MessageUtils.message("price.product.import.fileType"));
}
byte[] bytes = file.getBytes();
if (psvId == null)
{
psvId = resolveCurrentPsvId(db);
}
String taskId = IdUtils.fastSimpleUUID();
saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_PENDING, 0, "queued", priceSetId, null);
saveAsyncRunner.runImportPrices(taskId, priceSetId, psvId, bytes, filename, operName);
return taskId;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName) throws Exception
{
return importDetailPrices(priceSetId, psvId, bytes, filename, operName, null);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName, String taskId) throws Exception
{
PrcPriceSet db = requireSet(priceSetId);
assertChildEditable(db);
if (!TYPE_FIXED.equals(db.getSetType()))
{
throw new ServiceException(MessageUtils.message("price.set.import.fixedOnly"));
}
if (psvId == null)
{
psvId = resolveCurrentPsvId(db);
}
reportImportProgress(taskId, priceSetId, 20, "parsing excel");
Map<String, List<BmsProductCountry>> byProduct = productService.parseQuoteExcelByProduct(bytes, filename);
reportImportProgress(taskId, priceSetId, 50, "matching details");
PrcPriceSetDetail q = new PrcPriceSetDetail();
q.setPriceSetId(priceSetId);
if (psvId != null)
{
q.setPsvId(psvId);
}
List<PrcPriceSetDetail> details = detailMapper.selectPrcPriceSetDetailList(q);
if (details == null || details.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.import.noDetails"));
}
Map<String, List<PrcPriceSetDetail>> detailIndex = new HashMap<String, List<PrcPriceSetDetail>>();
for (PrcPriceSetDetail d : details)
{
if (d == null || StringUtils.isEmpty(d.getProductCode()))
{
continue;
}
String pc = d.getProductCode().trim().toUpperCase(Locale.ROOT);
List<PrcPriceSetDetail> list = detailIndex.get(pc);
if (list == null)
{
list = new ArrayList<PrcPriceSetDetail>();
detailIndex.put(pc, list);
}
list.add(d);
}
int updated = 0;
int unmatched = 0;
int skippedProduct = 0;
Set<Long> touched = new HashSet<Long>();
for (Map.Entry<String, List<BmsProductCountry>> e : byProduct.entrySet())
{
String productCode = e.getKey();
List<PrcPriceSetDetail> candidates = detailIndex.get(productCode);
if (candidates == null || candidates.isEmpty())
{
skippedProduct++;
continue;
}
for (BmsProductCountry row : e.getValue())
{
if (row == null)
{
continue;
}
List<PrcPriceSetDetail> hits = matchImportDetails(candidates, row);
if (hits.isEmpty())
{
unmatched++;
continue;
}
for (PrcPriceSetDetail hit : hits)
{
if (hit.getPsdId() == null || touched.contains(hit.getPsdId()))
{
continue;
}
detailMapper.updateDetailPrices(hit.getPsdId(), row.getFreightPrice(), row.getRegistrationFee());
hit.setFvValue(row.getFreightPrice());
hit.setFvRegistrationFee(row.getRegistrationFee());
touched.add(hit.getPsdId());
updated++;
}
}
}
reportImportProgress(taskId, priceSetId, 95, "finishing");
return MessageUtils.message("price.set.import.success",
Integer.valueOf(updated), Integer.valueOf(unmatched), Integer.valueOf(skippedProduct));
}
private void reportImportProgress(String taskId, Long priceSetId, int percent, String message)
{
if (StringUtils.isEmpty(taskId) || saveAsyncRunner == null)
{
return;
}
saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, percent, message, priceSetId, null);
}
/** Resolve editable version id: draft > pending > effect > first version. */
private Long resolveCurrentPsvId(PrcPriceSet set)
{
if (set == null)
{
return null;
}
if (set.getVersion() != null && set.getVersion().getPsvId() != null)
{
return set.getVersion().getPsvId();
}
List<PrcPriceSetVersion> versions = set.getVersions();
if (versions == null || versions.isEmpty())
{
PrcPriceSetVersion q = new PrcPriceSetVersion();
q.setPriceSetId(set.getPriceSetId());
versions = versionMapper.selectPrcPriceSetVersionList(q);
}
if (versions == null || versions.isEmpty())
{
return null;
}
PrcPriceSetVersion draft = null;
PrcPriceSetVersion pending = null;
PrcPriceSetVersion effect = null;
for (PrcPriceSetVersion v : versions)
{
if (v == null)
{
continue;
}
if (VER_DRAFT.equals(v.getStatus()) && draft == null)
{
draft = v;
}
else if (VER_PENDING.equals(v.getStatus()) && pending == null)
{
pending = v;
}
else if (VER_EFFECT.equals(v.getStatus()) && effect == null)
{
effect = v;
}
}
PrcPriceSetVersion pick = draft != null ? draft : (pending != null ? pending : (effect != null ? effect : versions.get(0)));
return pick == null ? null : pick.getPsvId();
}
private List<PrcPriceSetDetail> matchImportDetails(List<PrcPriceSetDetail> candidates, BmsProductCountry row)
{
List<PrcPriceSetDetail> hits = new ArrayList<PrcPriceSetDetail>();
if (candidates == null || row == null)
{
return hits;
}
String importZone = normZone(row.getZoneCode());
for (PrcPriceSetDetail d : candidates)
{
if (!sameCountry(d, row))
{
continue;
}
if (!sameWeight(d.getWeightMin(), d.getWeightMax(), row.getWeightMin(), row.getWeightMax()))
{
continue;
}
String detailZone = normZone(d.getZoneCode());
if (StringUtils.isNotEmpty(importZone))
{
if (!importZone.equals(detailZone))
{
continue;
}
}
else if (StringUtils.isNotEmpty(detailZone))
{
// Excel has no zone: only match non-zone details
continue;
}
hits.add(d);
}
return hits;
}
private boolean sameCountry(PrcPriceSetDetail d, BmsProductCountry row)
{
if (d == null || row == null)
{
return false;
}
if (StringUtils.isNotEmpty(d.getCountryCode()) && StringUtils.isNotEmpty(row.getCountryCode()))
{
return d.getCountryCode().trim().equalsIgnoreCase(row.getCountryCode().trim());
}
if (StringUtils.isNotEmpty(d.getCountryName()) && StringUtils.isNotEmpty(row.getCountryName()))
{
return d.getCountryName().trim().equals(row.getCountryName().trim());
}
return false;
}
private boolean sameWeight(BigDecimal aMin, BigDecimal aMax, BigDecimal bMin, BigDecimal bMax)
{
return decimalEq(aMin, bMin) && decimalEq(aMax, bMax);
}
private boolean decimalEq(BigDecimal a, BigDecimal b)
{
if (a == null && b == null)
{
return true;
}
if (a == null || b == null)
{
return false;
}
return a.compareTo(b) == 0;
}
private String normZone(String zone)
{
if (StringUtils.isEmpty(zone))
{
return "";
}
return zone.trim().toUpperCase(Locale.ROOT);
}
@Override
public int updateDetail(PrcPriceSetDetail detail)
{
@@ -76,6 +76,30 @@ public class PrcPriceSetSaveAsyncRunner
}
}
/** Background import of quote Excel into package detail prices. */
@Async("threadPoolTaskExecutor")
public void runImportPrices(String taskId, Long priceSetId, Long psvId, byte[] bytes, String filename, String operName)
{
try
{
put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, 10, "parsing excel", priceSetId, null);
String message = priceSetService.importDetailPrices(priceSetId, psvId, bytes, filename, operName, taskId);
put(taskId, PrcPriceSetSaveTask.STATUS_SUCCESS, 100, message, priceSetId, null);
log.info("async import prices done, taskId={}, priceSetId={}", taskId, priceSetId);
}
catch (Exception e)
{
log.error("async import prices failed, taskId={}, priceSetId={}", taskId, priceSetId, e);
String msg = e.getMessage();
if (StringUtils.isEmpty(msg))
{
msg = e.getClass().getSimpleName();
}
put(taskId, PrcPriceSetSaveTask.STATUS_FAIL, 100, "failed", priceSetId, msg);
}
}
public void put(String taskId, String status, int percent, String message, Long priceSetId, String error)
{
PrcPriceSetSaveTask task = new PrcPriceSetSaveTask();
@@ -23,6 +23,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="productCountryId" column="product_country_id" />
<result property="fvValue" column="fv_value" />
<result property="fvRegistrationFee" column="fv_registration_fee" />
<result property="baseFreightPrice" column="base_freight_price" />
<result property="baseRegistrationFee" column="base_registration_fee" />
<result property="discountValue" column="discount_value" />
<result property="roundMode" column="round_mode" />
<result property="prId" column="pr_id" />
@@ -46,54 +48,62 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select>
<select id="selectPrcPriceSetDetailList" parameterType="PrcPriceSetDetail" resultMap="PrcPriceSetDetailResult">
<include refid="selectPrcPriceSetDetailVo"/>
select d.psd_id, d.price_set_id, d.psv_id, d.product_code, d.product_name, d.country_code, d.country_name,
d.zone_id, d.zone_code, d.zone_name,
d.zd_id, d.wsd_id, d.weight_label, d.weight_min, d.weight_max, d.product_country_id,
d.fv_value, d.fv_registration_fee,
c.freight_price as base_freight_price,
c.registration_fee as base_registration_fee,
d.discount_value, d.round_mode, d.pr_id, d.effective_date, d.expire_date, d.create_time, d.update_time
from prc_price_set_detail d
left join bms_product_country c on c.detail_id = d.product_country_id
<where>
<if test="priceSetId != null">
AND price_set_id = #{priceSetId}
AND d.price_set_id = #{priceSetId}
</if>
<if test="psvId != null">
AND psv_id = #{psvId}
AND d.psv_id = #{psvId}
</if>
<if test="productCode != null and productCode != ''">
AND product_code = #{productCode}
AND d.product_code = #{productCode}
</if>
<if test="countryCode != null and countryCode != ''">
AND country_code = #{countryCode}
AND d.country_code = #{countryCode}
</if>
<if test="zoneCode != null and zoneCode != ''">
AND zone_code = #{zoneCode}
AND d.zone_code = #{zoneCode}
</if>
<if test="productKeyword != null and productKeyword != ''">
AND (
product_code like concat('%', #{productKeyword}, '%')
or product_name like concat('%', #{productKeyword}, '%')
d.product_code like concat('%', #{productKeyword}, '%')
or d.product_name like concat('%', #{productKeyword}, '%')
)
</if>
<if test="countryKeyword != null and countryKeyword != ''">
AND (
country_code like concat('%', #{countryKeyword}, '%')
or country_name like concat('%', #{countryKeyword}, '%')
d.country_code like concat('%', #{countryKeyword}, '%')
or d.country_name like concat('%', #{countryKeyword}, '%')
)
</if>
<if test="zoneKeyword != null and zoneKeyword != ''">
AND (
zone_code like concat('%', #{zoneKeyword}, '%')
or zone_name like concat('%', #{zoneKeyword}, '%')
d.zone_code like concat('%', #{zoneKeyword}, '%')
or d.zone_name like concat('%', #{zoneKeyword}, '%')
)
</if>
<if test="keyword != null and keyword != ''">
AND (
product_code like concat('%', #{keyword}, '%')
or product_name like concat('%', #{keyword}, '%')
or country_code like concat('%', #{keyword}, '%')
or country_name like concat('%', #{keyword}, '%')
or zone_code like concat('%', #{keyword}, '%')
or zone_name like concat('%', #{keyword}, '%')
or weight_label like concat('%', #{keyword}, '%')
d.product_code like concat('%', #{keyword}, '%')
or d.product_name like concat('%', #{keyword}, '%')
or d.country_code like concat('%', #{keyword}, '%')
or d.country_name like concat('%', #{keyword}, '%')
or d.zone_code like concat('%', #{keyword}, '%')
or d.zone_name like concat('%', #{keyword}, '%')
or d.weight_label like concat('%', #{keyword}, '%')
)
</if>
</where>
order by psd_id asc
order by d.psd_id asc
</select>
<select id="countProductByPriceSetId" parameterType="Long" resultType="int">
@@ -271,6 +281,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
)
</insert>
<update id="updateDetailPrices">
update prc_price_set_detail
set fv_value = #{fvValue},
fv_registration_fee = #{fvRegistrationFee},
update_time = sysdate()
where psd_id = #{psdId}
</update>
<update id="updatePrcPriceSetDetail" parameterType="PrcPriceSetDetail">
update prc_price_set_detail
<set>
+45
View File
@@ -189,6 +189,51 @@ export function addSetDetail(data) {
})
}
/**
* Async import quote Excel into FIXED package detail prices; poll until done.
* @param {File|Blob} file
* @param {{ priceSetId: string|number, psvId?: string|number }} query
* @param {(task: object) => void} [onProgress]
* @returns {Promise<{ task: object }>}
*/
export async function importSetDetailPricesAsync(file, query, onProgress) {
const formData = new FormData()
formData.append('file', 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)
const submitRes = await request({
url: '/price/set/detail/importPrices?' + params.toString(),
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing import 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 || 'import failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 800))
}
throw new Error('import timeout')
}
export function updateSetDetail(data) {
return request({
url: '/price/set/detail',
@@ -95,7 +95,7 @@ const props = defineProps({
// 上传接口地址(必传)
action: {
type: String,
required: true
default: ''
},
// 模板下载接口地址(后端接口,走 proxy.download),不传则不显示下载模板链接
templateAction: {
@@ -156,6 +156,11 @@ const props = defineProps({
type: Function,
default: null
},
// Custom async submit: (uploadFile) => Promise<string|void>
submitHandler: {
type: Function,
default: null
},
// 确定按钮文字
submitText: {
type: String,
@@ -291,7 +296,7 @@ function handleSuccess(response) {
}
// 提交上传
function handleSubmit() {
async function handleSubmit() {
if (typeof props.beforeSubmit === 'function') {
const msg = props.beforeSubmit()
if (msg) {
@@ -305,6 +310,29 @@ function handleSubmit() {
proxy.$modal.msgError(err)
return
}
if (typeof props.submitHandler === 'function') {
isUploading.value = true
progressPercent.value = 0
try {
const resultMsg = await props.submitHandler(file)
visible.value = false
validateError.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
const html = resultMsg == null || resultMsg === ''
? '导入完成'
: String(resultMsg)
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + html + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
emit('success')
} catch (e) {
const tip = (e && e.message) ? e.message : '导入失败'
proxy.$modal.msgError(tip)
} finally {
isUploading.value = false
progressPercent.value = 0
}
return
}
uploadRef.value.submit()
}
@@ -27,6 +27,16 @@
<template #default="{ row }">{{ row.zoneName || row.zoneCode || '-' }}</template>
</el-table-column>
<el-table-column :label="colWeight" prop="weightLabel" min-width="120" />
<el-table-column :label="colBaseFv" width="130" align="center">
<template #default="{ row }">
<span class="price-text base-price">{{ formatNum(row._baseFreight, 4) }}</span>
</template>
</el-table-column>
<el-table-column :label="colBaseReg" width="130" align="center">
<template #default="{ row }">
<span class="price-text base-price">{{ formatNum(row._baseReg, 2) }}</span>
</template>
</el-table-column>
<el-table-column :label="colSetFv" width="160" align="center">
<template #default="{ row }">
<div class="price-cell">
@@ -111,11 +121,13 @@ const title = computed(() => {
const name = props.productName || ''
return code + ' · ' + name + ' · 国家服务价格'
})
const hint = '黄色行=已改价;点编辑图标后修改,失焦即刻写入数据库。单位:运费 RMB/KG,挂号费 RMB/票。'
const hint = '黄色行=已改价(与产品基础价不同);点编辑图标后修改,失焦即刻写入数据库。单位:运费 RMB/KG,挂号费 RMB/票。'
const phSearch = '搜索国家/分区'
const colCountry = '国家'
const colZone = '分区'
const colWeight = '重量段'
const colBaseFv = '产品基础运费'
const colBaseReg = '产品基础挂号费'
const colSetFv = '套餐运费'
const colSetReg = '套餐挂号费'
const colSurcharge = '附加费'
@@ -289,7 +301,7 @@ watch(() => props.modelValue, v => {
position: fixed;
top: 0;
right: 0;
width: min(960px, 96vw);
width: min(1180px, 98vw);
height: 100%;
background: #fff;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
@@ -333,6 +345,9 @@ watch(() => props.modelValue, v => {
min-width: 64px;
text-align: right;
}
.base-price {
color: #909399;
}
.edit-ico {
padding: 0 2px !important;
}
@@ -20,6 +20,7 @@
v-if="loaded"
:details="details"
:price-set-id="priceSetId"
:psv-id="version.psvId"
:set-type="setType"
:effective-time="version.effectiveTime"
:version-no="version.versionNo"
@@ -15,6 +15,7 @@
<el-option v-for="d in bms_tax_mode" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
<el-button size="small" @click="noop">查询</el-button>
<el-button size="small" type="primary" plain :disabled="!priceSetId || !isFixedType(setType)" @click="openImport">导入修改价格</el-button>
</div>
<el-table :data="filteredProducts" border empty-text="暂无产品价格明细">
@@ -74,10 +75,36 @@
@surcharge-change="onRowSurchargeChange"
@pick-surcharge="openSurchargeFromDrawer"
/>
<excel-import-dialog
ref="importRef"
title="导入修改套餐价格"
width="560px"
:show-update-support="false"
:import-desc="importDesc"
:submit-handler="runImportAsync"
@success="onImportSuccess"
/>
<el-dialog
v-model="importProgressVisible"
title="正在导入价格"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
append-to-body
>
<div class="import-progress">
<el-progress :percentage="importProgressPercent" :status="importProgressStatus" />
<div class="import-progress-msg">{{ importProgressMessage }}</div>
</div>
</el-dialog>
</el-card>
</template>
<script setup name="StepDetails">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import PriceEditDrawer from './PriceEditDrawer.vue'
import SurchargePickerDialog from './SurchargePickerDialog.vue'
import { useDetailCascade } from '../../composables/usePriceSetRefData'
@@ -90,11 +117,12 @@ import {
surchargeAppliesToRow,
toDetailPayload
} from '../../utils/priceSet'
import { updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
import { getPriceSet, importSetDetailPricesAsync, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
const props = defineProps({
details: { type: Array, required: true },
priceSetId: { type: [Number, String], default: undefined },
psvId: { type: [Number, String], default: undefined },
setType: { type: String, default: 'FIXED' },
effectiveTime: { type: String, default: undefined },
versionNo: { type: String, default: '' },
@@ -137,6 +165,16 @@ const discVisible = ref(false)
const discTitle = ref('编辑价格')
const discValue = ref(undefined)
const discCode = ref('')
const importRef = ref()
const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const importDesc = [
'适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家+分区(有则匹配)+重量段匹配已有套餐明细,仅更新运费/挂号费。',
'仅固定价套餐可导入;未匹配行不会新增明细。'
]
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
@@ -175,6 +213,85 @@ const pulling = ref(false)
function noop() {}
function openImport() {
if (!props.priceSetId) {
proxy.$modal.msgWarning('缺少套餐ID')
return
}
if (!isFixedType(props.setType)) {
proxy.$modal.msgWarning('仅固定价套餐支持导入')
return
}
importRef.value && importRef.value.open()
}
async function runImportAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '上传并解析中…'
importProgressStatus.value = undefined
try {
const { task } = await importSetDetailPricesAsync(
raw,
{ priceSetId: props.priceSetId, psvId: props.psvId },
(t) => {
importProgressPercent.value = Math.min(100, Number(t.percent) || 0)
if (t.status === 'PENDING') importProgressMessage.value = '排队中…'
else if (t.status === 'RUNNING') {
const m = t.message || ''
if (m === 'parsing excel') importProgressMessage.value = '正在解析价表…'
else if (m === 'matching details') importProgressMessage.value = '正在匹配并更新价格…'
else if (m === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
else importProgressMessage.value = m || '正在导入…'
} else if (t.status === 'SUCCESS') {
importProgressMessage.value = t.message || '导入完成'
importProgressStatus.value = 'success'
} else if (t.status === 'FAIL') {
importProgressMessage.value = t.error || t.message || '导入失败'
importProgressStatus.value = 'exception'
}
}
)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
return (task && task.message) ? task.message : '导入完成'
} catch (e) {
importProgressStatus.value = 'exception'
const tip = (e && e.task && e.task.error) || (e && e.message) || '导入失败'
importProgressMessage.value = tip
await new Promise(resolve => setTimeout(resolve, 600))
throw e
} finally {
importProgressVisible.value = false
}
}
async function onImportSuccess() {
if (!props.priceSetId) return
try {
const res = await getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const qPsv = props.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
const src = (current.details && current.details.length) ? current.details : (data.details || [])
props.details.splice(0, props.details.length, ...src.map(d => hydrateRow(d, props.setType)))
if (priceVisible.value && editCode.value) {
const group = productGroups.value.find(p => p.productCode === editCode.value)
editRows.value = group ? group.rows : []
editGroup.value = group || null
await stampPriceFloors(editRows.value, editCode.value)
}
} catch (e) {}
}
/** enter step 3: pull all library products + country prices (skip already added) */
async function pullAllFromLibrary(opts = {}) {
const silent = !!opts.silent
@@ -268,11 +385,7 @@ function openPrice(row) {
}
editCode.value = row.productCode
editName.value = row.productName || ''
editRows.value = (row.rows || []).map(r => {
if (r._baseFreight == null && r.fvValue != null) r._baseFreight = Number(r.fvValue)
if (r._baseReg == null && r.fvRegistrationFee != null) r._baseReg = Number(r.fvRegistrationFee)
return r
})
editRows.value = row.rows || []
editGroup.value = row
Promise.resolve(stampPriceFloors(editRows.value, row.productCode)).finally(() => {
priceVisible.value = true
@@ -326,8 +439,14 @@ async function onRowPriceChange(row) {
function applyImportedFloor(row, hit) {
if (!row || !hit) return
if (hit.freightPrice != null) row._floorFreight = Number(hit.freightPrice)
if (hit.registrationFee != null) row._floorReg = Number(hit.registrationFee)
if (hit.freightPrice != null) {
row._floorFreight = Number(hit.freightPrice)
row._baseFreight = Number(hit.freightPrice)
}
if (hit.registrationFee != null) {
row._floorReg = Number(hit.registrationFee)
row._baseReg = Number(hit.registrationFee)
}
}
function findImportedCountry(rows, row) {
@@ -341,6 +460,11 @@ function findImportedCountry(rows, row) {
&& String(row.countryCode).toUpperCase() === String(item.countryCode).toUpperCase())
|| (!row.countryCode && row.countryName && item.countryName && row.countryName === item.countryName)
if (!sameCountry) return false
const rz = String(row.zoneCode || '').toUpperCase()
const iz = String(item.zoneCode || '').toUpperCase()
if (rz || iz) {
if (rz !== iz) return false
}
if (row.weightMin != null && item.weightMin != null && Number(row.weightMin) !== Number(item.weightMin)) return false
if (row.weightMax != null && item.weightMax != null && Number(row.weightMax) !== Number(item.weightMax)) return false
return row.weightMin != null || row.weightMax != null
@@ -514,6 +638,15 @@ defineExpose({ pullAllFromLibrary })
font-size: 13px;
}
.mb12 { margin-bottom: 12px; }
.import-progress {
padding: 8px 4px 0;
}
.import-progress-msg {
margin-top: 12px;
color: #606266;
font-size: 13px;
word-break: break-all;
}
.filters {
display: flex;
align-items: center;
@@ -36,19 +36,33 @@
<el-table-column label="重量段" prop="weightLabel" min-width="130" />
<el-table-column label="运费单价" min-width="130" align="right">
<template #default="{ row }">
<template v-if="row.fvValue == null">
<span class="unit">未覆盖</span>
</template>
<template v-else>
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
</template>
<el-tooltip placement="top" :disabled="!hasBaseFreight(row)">
<template #content>
<span>产品基础运费:{{ formatBase(row.baseFreightPrice, 4) }} RMB/KG</span>
</template>
<span class="price-tip">
<template v-if="row.fvValue == null">
<span class="unit">未覆盖</span>
</template>
<template v-else>
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
</template>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="挂号费" min-width="120" align="right">
<template #default="{ row }">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
<el-tooltip placement="top" :disabled="!hasBaseReg(row)">
<template #content>
<span>产品基础挂号费:{{ formatBase(row.baseRegistrationFee, 2) }} RMB/票</span>
</template>
<span class="price-tip">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
</span>
</el-tooltip>
</template>
</el-table-column>
</template>
@@ -139,6 +153,19 @@ const hint = computed(() => isFixedType(props.set.setType)
? '运费单价单位 RMB/KG(留空表示不覆盖,按产品基础价计费),挂号费单位 RMB/票;附加费来自产品关联配置,此处只读展示。'
: '未配置明细时全部在售产品均适用套餐默认折扣。')
function hasBaseFreight(row) {
return row && row.baseFreightPrice != null && row.baseFreightPrice !== ''
}
function hasBaseReg(row) {
return row && row.baseRegistrationFee != null && row.baseRegistrationFee !== ''
}
function formatBase(v, precision) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toFixed(precision)
}
function roundModeLabel(mode) {
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
return hit ? hit.label : (mode || '四舍五入')
@@ -199,4 +226,11 @@ function remove(row) {
color: #909399;
line-height: 1.6;
}
.price-tip {
cursor: help;
border-bottom: 1px dashed transparent;
}
.price-tip:hover {
border-bottom-color: #c0c4cc;
}
</style>
@@ -73,19 +73,33 @@
<el-table-column :label="colWeight" prop="weightLabel" min-width="120" />
<el-table-column :label="colFv" min-width="130" align="right">
<template #default="{ row }">
<template v-if="row.fvValue == null">
<span class="unit">未覆盖</span>
</template>
<template v-else>
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
</template>
<el-tooltip placement="top" :disabled="!hasBaseFreight(row)">
<template #content>
<span>产品基础运费:{{ formatBase(row.baseFreightPrice, 4) }} RMB/KG</span>
</template>
<span class="price-tip">
<template v-if="row.fvValue == null">
<span class="unit">未覆盖</span>
</template>
<template v-else>
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
</template>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="colReg" min-width="120" align="right">
<template #default="{ row }">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
<el-tooltip placement="top" :disabled="!hasBaseReg(row)">
<template #content>
<span>产品基础挂号费:{{ formatBase(row.baseRegistrationFee, 2) }} RMB/票</span>
</template>
<span class="price-tip">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
</span>
</el-tooltip>
</template>
</el-table-column>
</template>
@@ -211,6 +225,19 @@ const title = computed(() => {
return (v.versionNo || '-') + ' · 版本明细'
})
function hasBaseFreight(row) {
return row && row.baseFreightPrice != null && row.baseFreightPrice !== ''
}
function hasBaseReg(row) {
return row && row.baseRegistrationFee != null && row.baseRegistrationFee !== ''
}
function formatBase(v, precision) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toFixed(precision)
}
function roundModeLabel(mode) {
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
return hit ? hit.label : (mode || '四舍五入')
@@ -313,4 +340,11 @@ watch(
.reg { color: #d48806; font-weight: 600; }
.pct { color: #1890ff; font-weight: 600; }
.unit { font-size: 12px; color: #909399; }
.price-tip {
cursor: help;
border-bottom: 1px dashed transparent;
}
.price-tip:hover {
border-bottom-color: #c0c4cc;
}
</style>
@@ -323,10 +323,7 @@ export function useDetailCascade(refData) {
row.weightLabel = formatWeightLabel(row.weightMin, row.weightMax)
}
row._countryKey = countryKeyOf(row)
// Snapshot from DB: treat current package price as baseline so drawer cells stay editable
// and yellow "changed" highlight still works after first edit.
if (row._baseFreight == null && row.fvValue != null) row._baseFreight = Number(row.fvValue)
if (row._baseReg == null && row.fvRegistrationFee != null) row._baseReg = Number(row.fvRegistrationFee)
// Keep package prices in fv*; product base is stamped later from bms_product_country.
return row
}
refData.ensureCountries(row.productCode).then(rows => {
+1
View File
@@ -14,6 +14,7 @@
ref="detailsRef"
:details="form.details"
:price-set-id="form.priceSetId"
:psv-id="form.version.psvId"
:set-type="form.basic.setType"
:version-no="form.version.versionNo"
:ref-data="refData"