Enhance product import validation by adding checks for duplicate country and weight band entries. Update related error messages in properties file. Introduce new method to delete auto fees while retaining manual entries during rebill operations. Update front-end components to manage build mode changes and ensure proper handling of product lists during price set creation.

This commit is contained in:
wuweihua
2026-10-09 15:51:21 +08:00
parent 94e7a18e0b
commit 853187ad58
11 changed files with 519 additions and 54 deletions
@@ -128,6 +128,8 @@ price.product.import.success.detail2=范围:产品目录 + 重量段 + 运费/
price.product.import.noSheet=未找到产品明细sheet(需与产品名称一致):{0}
price.product.import.noCountry=产品明细sheet无有效国家/重量段数据:{0}
price.product.import.euEmpty=国家表无欧盟成员(is_eu=1),无法展开「欧盟」
price.product.import.dupCountry.item=产品{0}({1})重复国家+重量段:{2}
price.product.import.dupCountry=导入失败:以下产品明细中存在重复的国家+重量段,请合并后重试:{0}
price.product.import.success.unmatched=未匹配国家表(country_code 为空):{0}
price.product.import.countryRequired=请选择目的国家
price.product.import.countryNotFound=国家不存在或已停用:{0}
@@ -47,4 +47,7 @@ public interface PrcIncomeBillFeeMapper
public int deleteByFeeId(String feeId);
public int deleteByBillId(String billId);
/** Rebill: delete auto fees only; keep manual_flag=1 rows. */
public int deleteAutoFeesByBillId(String billId);
}
@@ -309,6 +309,9 @@ public class BmsProductServiceImpl implements IBmsProductService
}
List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode();
// Pre-check: same product cannot have duplicate country + weight band in Excel
assertImportProductsNoDupCountryWeight(workbook, products, regionByNameCn, euRegions,
zoneByCountryAndCode);
int successNum = 0;
int skipNum = 0;
@@ -460,6 +463,7 @@ public class BmsProductServiceImpl implements IBmsProductService
product.setTaxMode(resolveTaxMode(product.getProductName()));
}
List<BmsProductCountry> countries = stampSelectedCountry(block.rows, region, forcedEff);
assertNoDuplicateCountryWeightRows(product, countries);
int[] nums = persistImportedProduct(product, countries, operName);
transactionManager.commit(tx);
successNum += nums[0];
@@ -597,6 +601,7 @@ public class BmsProductServiceImpl implements IBmsProductService
{
continue;
}
assertNoDuplicateCountryWeightRows(product, rows);
String code = product.getProductCode().trim().toUpperCase(Locale.ROOT);
out.put(code, rows);
}
@@ -632,6 +637,7 @@ public class BmsProductServiceImpl implements IBmsProductService
throw new ServiceException(MessageUtils.message("price.product.import.noCountry",
productSheet.getSheetName()));
}
assertNoDuplicateCountryWeightRows(product, countries);
// UI-selected effective date wins over Excel; previous open product/country rows expire at this date
Date newEff = requireImportEffectiveDate(forcedEffectiveDate != null ? forcedEffectiveDate
: product.getEffectiveDate());
@@ -1988,6 +1994,134 @@ public class BmsProductServiceImpl implements IBmsProductService
}
}
/**
* Workbook pre-check for full import: reject before any write when a product sheet
* has duplicate country + weight band (same zone) rows.
*/
private void assertImportProductsNoDupCountryWeight(Workbook workbook, List<BmsProduct> products,
Map<String, BmsRegion> regionByNameCn, List<BmsRegion> euRegions,
Map<String, PrcZone> zoneByCountryAndCode)
{
if (workbook == null || products == null || products.isEmpty())
{
return;
}
List<String> tips = new ArrayList<String>();
Set<String> unmatchedCountries = new HashSet<String>();
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);
String tip = buildDuplicateCountryWeightTip(product, rows);
if (StringUtils.isNotEmpty(tip))
{
tips.add(tip);
}
}
if (!tips.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.dupCountry",
String.join("<br/>", tips)));
}
}
/** Fail when Excel rows for one product contain duplicate country + weight band. */
private void assertNoDuplicateCountryWeightRows(BmsProduct product, List<BmsProductCountry> rows)
{
String tip = buildDuplicateCountryWeightTip(product, rows);
if (StringUtils.isNotEmpty(tip))
{
throw new ServiceException(tip);
}
}
private String buildDuplicateCountryWeightTip(BmsProduct product, List<BmsProductCountry> rows)
{
List<String> dups = findDuplicateCountryWeightLabels(rows);
if (dups.isEmpty())
{
return null;
}
String code = product == null || StringUtils.isEmpty(product.getProductCode())
? "-" : product.getProductCode();
String name = product == null || StringUtils.isEmpty(product.getProductName())
? "-" : product.getProductName();
return MessageUtils.message("price.product.import.dupCountry.item",
code, name, String.join("\u3001", dups));
}
private List<String> findDuplicateCountryWeightLabels(List<BmsProductCountry> rows)
{
List<String> dups = new ArrayList<String>();
if (rows == null || rows.isEmpty())
{
return dups;
}
Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
Map<String, String> labels = new LinkedHashMap<String, String>();
for (BmsProductCountry c : rows)
{
if (c == null)
{
continue;
}
String key = countryWeightDupKey(c);
Integer n = counts.get(key);
counts.put(key, n == null ? 1 : n.intValue() + 1);
if (!labels.containsKey(key))
{
labels.put(key, formatCountryWeightTip(c));
}
}
for (Map.Entry<String, Integer> e : counts.entrySet())
{
if (e.getValue() != null && e.getValue().intValue() > 1)
{
dups.add(labels.get(e.getKey()));
}
}
return dups;
}
/** Align with uk_product_country_zone_weight_eff (country + zone + weight min/max). */
private String countryWeightDupKey(BmsProductCountry c)
{
String country = StringUtils.isNotEmpty(c.getCountryCode())
? c.getCountryCode().trim().toUpperCase(Locale.ROOT)
: (c.getCountryName() == null ? "" : c.getCountryName().trim());
String zone = c.getZoneCode() == null ? "" : c.getZoneCode().trim().toUpperCase(Locale.ROOT);
return country + "|" + zone + "|" + decimalKey(c.getWeightMin()) + "|" + decimalKey(c.getWeightMax());
}
private String formatCountryWeightTip(BmsProductCountry c)
{
String name = StringUtils.isNotEmpty(c.getCountryName()) ? c.getCountryName()
: (StringUtils.isNotEmpty(c.getCountryCode()) ? c.getCountryCode() : "-");
if (StringUtils.isNotEmpty(c.getZoneCode()))
{
name = name + "(" + c.getZoneCode() + ")";
}
return name + " " + decimalKey(c.getWeightMin()) + "~" + decimalKey(c.getWeightMax());
}
private String decimalKey(BigDecimal v)
{
if (v == null)
{
return "";
}
return v.stripTrailingZeros().toPlainString();
}
/** Stamp product + all country detail rows with the same effective date. */
private void applyForcedEffectiveDate(BmsProduct product, List<BmsProductCountry> countries, Date newEff)
{
@@ -8,8 +8,10 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
@@ -1115,18 +1117,39 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
else
{
orderBillMapper.updatePrcOrderBill(bill);
incomeBillFeeMapper.deleteByBillId(bill.getBillId());
// Keep manually added fees (manual_flag=1) across rebill
incomeBillFeeMapper.deleteAutoFeesByBillId(bill.getBillId());
// Payable not in scope yet: skip prc_pay_bill_fee cleanup/write.
// payBillFeeMapper.deleteByBillId(bill.getBillId());
}
PrcIncomeBill incomeBill = persistIncomeBill(bill, result);
List<PrcIncomeBillFee> fees = toFeeRows(bill.getBillId(),
incomeBill == null ? null : incomeBill.getIncomeBillId(), result);
if (existed != null)
{
fees = filterAutoFeesAgainstManual(bill.getBillId(), fees);
}
if (!fees.isEmpty())
{
incomeBillFeeMapper.insertBatch(fees);
}
orderBillMapper.refreshArBillStatusByBillIds(Arrays.asList(bill.getBillId()));
if (existed != null)
{
// Quote totals were written above; recalc so retained manual fees join income/credit
TmsPriceQuoteResult refreshed = refreshBillIncomeTotals(bill);
if (refreshed != null)
{
result.setSurchargeAmount(refreshed.getSurchargeAmount());
result.setIncomeTotal(refreshed.getIncomeTotal());
result.setPayableTotal(refreshed.getPayableTotal());
result.setCreditArrearsStatus(refreshed.getCreditArrearsStatus());
result.setFeeDetails(refreshed.getFeeDetails());
}
}
else
{
orderBillMapper.refreshArBillStatusByBillIds(Arrays.asList(bill.getBillId()));
}
// Payable not in scope yet: do not write prc_pay_bill / prc_pay_bill_fee.
// PrcPayBill payBill = persistPayBill(bill, result);
// List<PrcPayBillFee> payFees = toPayFeeRows(bill.getBillId(),
@@ -1141,6 +1164,53 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
billSearchStore.save(EsLogType.BILL, EsBizLogSupport.billBizId(request), requestJson, toResponseJson(result));
}
/**
* Drop auto fee lines that collide with retained manual rows on uk_bill_fee_item
* (bill_id, fee_type, sc_id). Manual fees win on rebill.
*/
private List<PrcIncomeBillFee> filterAutoFeesAgainstManual(String billId, List<PrcIncomeBillFee> autoFees)
{
if (autoFees == null || autoFees.isEmpty() || StringUtils.isEmpty(billId))
{
return autoFees == null ? new ArrayList<PrcIncomeBillFee>() : autoFees;
}
List<PrcIncomeBillFee> existing = incomeBillFeeMapper.selectByBillId(billId);
if (existing == null || existing.isEmpty())
{
return autoFees;
}
Set<String> manualKeys = new HashSet<String>();
for (PrcIncomeBillFee row : existing)
{
if (row != null && "1".equals(row.getManualFlag()))
{
manualKeys.add(feeUniqueKey(row.getFeeType(), row.getScId()));
}
}
if (manualKeys.isEmpty())
{
return autoFees;
}
List<PrcIncomeBillFee> out = new ArrayList<PrcIncomeBillFee>();
for (PrcIncomeBillFee fee : autoFees)
{
if (fee == null)
{
continue;
}
if (!manualKeys.contains(feeUniqueKey(fee.getFeeType(), fee.getScId())))
{
out.add(fee);
}
}
return out;
}
private String feeUniqueKey(String feeType, Long scId)
{
return StringUtils.nvl(feeType, "") + "|" + (scId == null ? "0" : String.valueOf(scId));
}
/**
* Occupy/release credit by income total delta (bizType=ORDER).
* Missing/disabled customer: skip occupy, return NORMAL.
@@ -2733,6 +2733,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
version.setExpireTime(db.getExpireTime());
}
// Draft + build mode changed: wipe old product/detail/surcharge before mode switch
clearDraftVersionChildrenIfBuildModeChanged(db, version);
versionMapper.updatePrcPriceSetVersion(version);
}
set.setVersion(version);
@@ -2749,6 +2751,36 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
refreshVersionStatus(set.getPriceSetId());
}
/**
* Draft only: when priceBuildMode changes (PULL/IMPORT), delete this version's
* product / detail / surcharge rows. Same mode or non-draft: no-op.
*/
private void clearDraftVersionChildrenIfBuildModeChanged(PrcPriceSetVersion db, PrcPriceSetVersion incoming)
{
if (db == null || incoming == null || db.getPsvId() == null)
{
return;
}
if (!VER_DRAFT.equals(db.getStatus()))
{
return;
}
String newMode = StringUtils.trim(incoming.getPriceBuildMode());
if (StringUtils.isEmpty(newMode))
{
return;
}
String oldMode = StringUtils.trim(db.getPriceBuildMode());
if (newMode.equalsIgnoreCase(StringUtils.nvl(oldMode, "")))
{
return;
}
Long psvId = db.getPsvId();
versionSurchargeMapper.deleteByPsvId(psvId);
detailMapper.deletePrcPriceSetDetailByPsvId(psvId);
setProductMapper.deleteByPsvId(psvId);
}
/** Prefer same effective_time, else draft, else first version of the set. */
private PrcPriceSetVersion findReusableVersion(Long priceSetId, Date effectiveTime)
{
@@ -180,4 +180,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<delete id="deleteByBillId" parameterType="String">
delete from prc_income_bill_fee where bill_id = #{billId}
</delete>
<delete id="deleteAutoFeesByBillId" parameterType="String">
delete from prc_income_bill_fee
where bill_id = #{billId}
and (manual_flag is null or manual_flag &lt;&gt; '1')
</delete>
</mapper>
@@ -0,0 +1,37 @@
-- ------------------------------------------------------------
-- 导入价表 Excel 附件记录(仅建表/字段,待确认后再接代码)
-- 用途:产品价/红线价/销售价 Excel 导入时落库文件元数据
-- 关联:product.pef_id、prc_price_set_version.pef_id -> prc_price_excel_file.pef_id
-- ------------------------------------------------------------
-- 1) 主表:导入价表 Excel 附件
CREATE TABLE IF NOT EXISTS prc_price_excel_file (
pef_id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '1 主键',
excel_type varchar(20) NOT NULL COMMENT '2 价表类型 PRODUCT=产品价 REDLINE=红线价 SALES=销售价',
effective_time datetime NOT NULL COMMENT '3 生效时间(导入时选择/解析)',
file_name varchar(255) NOT NULL COMMENT '4 文件原始名称',
file_path varchar(500) NOT NULL COMMENT '5 文件存储路径(相对路径或完整URL)',
file_size bigint(20) DEFAULT 0 COMMENT '6 文件大小(字节)',
content_type varchar(100) DEFAULT '' COMMENT '7 MIME类型,如 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
file_extension varchar(20) DEFAULT '' COMMENT '8 扩展名,如 xlsx/xls',
oss_id varchar(64) DEFAULT NULL COMMENT '9 OSS/对象存储ID(可空)',
sheet_names varchar(1000) DEFAULT NULL COMMENT '10 导入涉及的 sheet 名(多个逗号分隔,可空)',
remark varchar(500) DEFAULT NULL COMMENT '11 备注',
created_by varchar(64) DEFAULT '' COMMENT '12 创建人',
create_time datetime DEFAULT NULL COMMENT '13 创建时间',
updated_by varchar(64) DEFAULT '' COMMENT '14 更新人',
update_time datetime DEFAULT NULL COMMENT '15 更新时间',
PRIMARY KEY (pef_id),
KEY idx_pef_type_eff (excel_type, effective_time),
KEY idx_pef_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导入价表Excel附件';
-- 2) 产品主表:关联导入附件
ALTER TABLE product
ADD COLUMN pef_id bigint(20) DEFAULT NULL COMMENT '关联导入价表Excel附件 prc_price_excel_file.pef_id' AFTER source_sheet,
ADD KEY idx_product_pef_id (pef_id);
-- 3) 套餐版本表:关联导入附件(销售价 Excel 建价)
ALTER TABLE prc_price_set_version
ADD COLUMN pef_id bigint(20) DEFAULT NULL COMMENT '关联导入价表Excel附件 prc_price_excel_file.pef_id' AFTER price_build_mode,
ADD KEY idx_psv_pef_id (pef_id);
@@ -11,13 +11,14 @@
</div>
<div class="panel-actions">
<el-button @click="close">关闭</el-button>
<el-button type="primary" :disabled="!priceSetId" @click="close">完成</el-button>
<el-button type="primary" :loading="saving" :disabled="!priceSetId" @click="finish">完成</el-button>
</div>
</div>
<div class="panel-body">
<step-details
v-if="loaded"
ref="detailsRef"
:details="details"
:products="products"
:price-set-id="priceSetId"
@@ -25,9 +26,13 @@
@update:psv-id="v => { if (v != null && v !== '') version.psvId = v }"
:set-type="setType"
:price-build-mode="version.priceBuildMode"
@update:price-build-mode="onPriceBuildModeUpdate"
:effective-time="version.effectiveTime"
:version-no="version.versionNo"
:version-status="version.status"
:ref-data="refData"
:live-persist="true"
:build-mode-dirty="buildModeDirty"
header-title="编辑产品价格"
/>
</div>
@@ -38,7 +43,8 @@
import { ArrowLeft } from '@element-plus/icons-vue'
import StepDetails from './StepDetails.vue'
import { usePriceSetRefData } from '../../composables/usePriceSetRefData'
import { getPriceSet } from '@/api/price/set'
import { isBuildModeLockedVersion } from '../../utils/priceSet'
import { getPriceSet, savePriceSetAsync } from '@/api/price/set'
const props = defineProps({
priceSetId: { type: [Number, String], default: undefined },
@@ -53,9 +59,18 @@ const refData = usePriceSetRefData()
const loading = ref(false)
const loaded = ref(false)
const setType = ref('FIXED')
const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined, priceBuildMode: undefined })
const priceSetName = ref('')
const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined, status: undefined, priceBuildMode: undefined })
const savedPriceBuildMode = ref(undefined)
const saving = ref(false)
const details = ref([])
const products = ref([])
const detailsRef = ref()
/** Snapshot of products under the last saved build mode (restore if switch back before Complete) */
const savedProductsSnapshot = ref([])
const buildModeDirty = computed(() =>
String(version.priceBuildMode || '') !== String(savedPriceBuildMode.value || '')
)
const title = computed(() => '编辑产品价格 · ' + (version.versionNo || ''))
@@ -70,6 +85,7 @@ function load() {
getPriceSet(id).then(res => {
const data = res.data || {}
setType.value = data.setType || 'FIXED'
priceSetName.value = data.priceSetName || ''
const versions = data.versions || []
const qPsv = props.psvId
@@ -78,16 +94,107 @@ function load() {
version.psvId = current.psvId
version.versionNo = current.versionNo || 'V1'
version.effectiveTime = current.effectiveTime
version.status = current.status
version.priceBuildMode = current.priceBuildMode
savedPriceBuildMode.value = current.priceBuildMode
details.value = []
products.value = (current.products || []).map(p => ({ ...p }))
savedProductsSnapshot.value = products.value.map(p => ({ ...p }))
loaded.value = true
}).finally(() => {
loading.value = false
})
}
function onPriceBuildModeUpdate(mode) {
if (!mode || isBuildModeLockedVersion(version.status)) return
if (mode === version.priceBuildMode) return
const leavingSaved = String(version.priceBuildMode || '') === String(savedPriceBuildMode.value || '')
if (leavingSaved) {
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
}
version.priceBuildMode = mode
if (detailsRef.value && detailsRef.value.clearLocalDraft) {
detailsRef.value.clearLocalDraft()
}
details.value = []
const backToSaved = String(mode || '') === String(savedPriceBuildMode.value || '')
if (backToSaved) {
products.value = (savedProductsSnapshot.value || []).map(p => ({ ...p }))
if (!products.value.length) {
reloadSavedProducts()
} else {
proxy.$modal.msgSuccess("已恢复原建价方式及产品列表(未保存,库数据未变)")
}
return
}
products.value = []
proxy.$modal.msgSuccess("已切换建价方式(本地),请点「完成」保存后再拉取或导入;切回原方式可恢复列表")
}
function reloadSavedProducts() {
const id = props.priceSetId
if (!id) return
loading.value = true
getPriceSet(id).then(res => {
const data = res.data || {}
const versions = data.versions || []
const qPsv = props.psvId || version.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
products.value = (current.products || []).map(p => ({ ...p }))
savedProductsSnapshot.value = products.value.map(p => ({ ...p }))
proxy.$modal.msgSuccess("已恢复原建价方式及产品列表(未保存,库数据未变)")
}).finally(() => {
loading.value = false
})
}
async function finish() {
if (!props.priceSetId) {
close()
return
}
if (!buildModeDirty.value) {
close()
return
}
if (!version.priceBuildMode) {
proxy.$modal.msgWarning("请先选择创建价格方式")
return
}
saving.value = true
try {
const payload = {
priceSetId: props.priceSetId,
priceSetName: priceSetName.value,
setType: setType.value || 'FIXED',
version: {
psvId: version.psvId,
versionNo: version.versionNo,
priceBuildMode: version.priceBuildMode
}
}
await savePriceSetAsync(payload)
savedPriceBuildMode.value = version.priceBuildMode
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
await nextTick()
if (version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
await detailsRef.value.pullAllFromLibrary({ silent: true })
proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,已重新拉取产品列表")
} else {
proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可导入 Excel 建价")
}
close()
} catch (e) {
const msg = (e && e.task && e.task.error) || (e && e.message) || "保存失败"
proxy.$modal.msgError(msg)
} finally {
saving.value = false
}
}
function close() {
emit('close')
}
@@ -17,21 +17,21 @@
v-if="showImportAction"
type="primary"
size="small"
:disabled="(livePersist && !priceSetId) || pulling"
:disabled="(livePersist && !priceSetId) || pulling || buildModeDirty"
@click="openImport"
>{{ importActionLabel }}</el-button>
</div>
</div>
</template>
<div v-if="showBuildChoice" class="build-choice">
<div class="build-choice">
<p class="build-choice__tip">创建价格方式</p>
<div class="build-choice__cards">
<button
type="button"
class="build-card build-card--pull"
:class="{ selected: isPullBuildMode(priceBuildMode) }"
:disabled="choosingMode"
:disabled="buildModeLocked || choosingMode"
@click="chooseBuildMode('PULL')"
>
<div class="build-card__title">按产品价建价</div>
@@ -41,7 +41,7 @@
type="button"
class="build-card build-card--import"
:class="{ selected: isImportBuildMode(priceBuildMode) }"
:disabled="!isFixedType(setType) || choosingMode"
:disabled="buildModeLocked || !isFixedType(setType) || choosingMode"
@click="chooseBuildMode('IMPORT')"
>
<div class="build-card__title">Excel 导入建价</div>
@@ -152,6 +152,7 @@ import { useDetailCascade } from '../../composables/usePriceSetRefData'
import {
createEmptyDetailRow,
formatWeightLabel,
isBuildModeLockedVersion,
isDiscountType,
isFixedType,
isImportBuildMode,
@@ -174,7 +175,11 @@ const props = defineProps({
refData: { type: Object, required: true },
headerTitle: { type: String, default: '第 2 步 · 编辑产品价格' },
/** true=edit live DB; false=create local draft until finish */
livePersist: { type: Boolean, default: true }
livePersist: { type: Boolean, default: true },
/** Version status: draft can change build mode; started versions lock it */
versionStatus: { type: [String, Number], default: undefined },
/** True when build mode changed locally and not saved yet */
buildModeDirty: { type: Boolean, default: false }
})
const emit = defineEmits(['update:psvId', 'update:priceBuildMode'])
@@ -221,14 +226,11 @@ const importProgressStatus = ref(undefined)
const progressDialogTitle = ref('正在导入价格')
const hasBuildMode = computed(() => {
if (isPullBuildMode(props.priceBuildMode) || isImportBuildMode(props.priceBuildMode)) return true
// Legacy / already has products: skip choice cards
// Legacy / already has products: treat as build mode ready
return (props.products || []).some(p => p && p.productCode)
})
/** Create/newVersion only: show choice until a mode is picked */
const showBuildChoice = computed(() => {
if (props.livePersist) return false
return !isPullBuildMode(props.priceBuildMode) && !isImportBuildMode(props.priceBuildMode)
})
/** Started (non-draft) versions: show cards but disable switching */
const buildModeLocked = computed(() => isBuildModeLockedVersion(props.versionStatus))
const choosingMode = ref(false)
const isModifyImport = computed(() => {
if (isPullBuildMode(props.priceBuildMode)) return true
@@ -255,7 +257,6 @@ const importDesc = computed(() => {
})
const typeHint = computed(() => {
if (props.livePersist) return ''
if (isPullBuildMode(props.priceBuildMode)) return '· 按产品价建价'
if (isImportBuildMode(props.priceBuildMode)) return '· Excel 导入'
if (hasBuildMode.value) return '· 当前:' + setTypeLabel(props.setType)
@@ -367,7 +368,7 @@ const pulling = ref(false)
function noop() {}
async function chooseBuildMode(mode) {
if (!mode || choosingMode.value || props.livePersist) return
if (!mode || choosingMode.value || buildModeLocked.value) return
if (mode === props.priceBuildMode) return
choosingMode.value = true
try {
@@ -405,6 +406,10 @@ function applyLocalDraft(products, details) {
}
async function openImport() {
if (props.buildModeDirty) {
proxy.$modal.msgWarning("请先点「完成」保存建价方式")
return
}
if (props.livePersist && !props.priceSetId) {
proxy.$modal.msgWarning('缺少套餐ID')
return
+94 -36
View File
@@ -27,8 +27,10 @@
:price-build-mode="form.version.priceBuildMode"
@update:price-build-mode="onPriceBuildModeUpdate"
:version-no="form.version.versionNo"
:version-status="form.version.status"
:ref-data="refData"
:live-persist="livePersist"
:build-mode-dirty="buildModeDirty"
:header-title="detailsHeaderTitle"
/>
@@ -64,6 +66,7 @@ import StepDetails from './components/create/StepDetails.vue'
import { usePriceSetRefData } from './composables/usePriceSetRefData'
import {
createEmptyBasicInfo,
isBuildModeLockedVersion,
nextVersionNo,
toCustomerPayload,
toDetailPayload
@@ -102,11 +105,16 @@ const detailsHeaderTitle = computed(() =>
const form = reactive({
priceSetId: undefined,
basic: createEmptyBasicInfo(),
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined, priceBuildMode: undefined },
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined, status: undefined, priceBuildMode: undefined },
details: [],
products: [],
customers: []
})
const savedPriceBuildMode = ref(undefined)
const savedProductsSnapshot = ref([])
const buildModeDirty = computed(() =>
String(form.version.priceBuildMode || '') !== String(savedPriceBuildMode.value || '')
)
function mapSaveProgressMessage(message) {
const m = message || ''
@@ -178,7 +186,33 @@ async function finishEdit() {
goBack()
return
}
proxy.$modal.msgSuccess('修改已实时落库')
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
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
await nextTick()
if (form.version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
await detailsRef.value.pullAllFromLibrary({ silent: true })
}
proxy.$modal.msgSuccess("建价方式已保存")
} catch (e) {
return
} finally {
creating.value = false
}
} else {
proxy.$modal.msgSuccess("修改已实时落库")
}
const obj = {
path: '/customer/setDetail',
query: { priceSetId: form.priceSetId }
@@ -299,42 +333,59 @@ function onPsvIdUpdate(psvId) {
}
/** Step2 chose PULL / IMPORT: edit live-saves mode; create only local + optional preview pull. */
async function onPriceBuildModeUpdate(mode) {
function onPriceBuildModeUpdate(mode) {
if (!mode) return
const prev = form.version.priceBuildMode
if (prev === mode) return
form.version.priceBuildMode = mode
try {
// Create/copy: switching build mode clears the product table before reload/import.
if (!livePersist.value && prev) {
form.details.splice(0, form.details.length)
form.products.splice(0, form.products.length)
if (detailsRef.value && detailsRef.value.clearLocalDraft) {
detailsRef.value.clearLocalDraft()
}
}
if (livePersist.value) {
const payload = buildPayload()
delete payload.pullFromLibrary
delete payload.forcePullLibrary
delete payload.details
await runSaveWithProgress(payload)
}
await nextTick()
if (mode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
const hasProducts = (form.products || []).some(p => p && p.productCode)
if (!hasProducts) {
await detailsRef.value.pullAllFromLibrary({ silent: true })
proxy.$modal.msgSuccess('已拉取产品列表,可继续编辑价格')
} else if (prev !== mode) {
proxy.$modal.msgSuccess('已切换为按产品价建价,可导入 Excel 修改价格')
}
} else if (mode === 'IMPORT' && prev !== mode) {
proxy.$modal.msgSuccess('请导入 Excel 建价')
}
} catch (e) {
form.version.priceBuildMode = prev
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
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') {
proxy.$modal.msgSuccess("请导入 Excel 建价")
}
}
function reloadSavedProductsForCreate() {
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("已恢复原建价方式及产品列表(未保存,库数据未变)")
})
}
function applySetData(data, asCopy, asNewVersion) {
@@ -355,7 +406,9 @@ function applySetData(data, asCopy, asNewVersion) {
form.version.versionNo = nextVersionNo(versions)
form.version.effectiveTime = undefined
form.version.expireTime = undefined
form.version.status = undefined
form.version.priceBuildMode = undefined
savedPriceBuildMode.value = undefined
form.details = []
form.products = []
} else if (asNewVersion) {
@@ -364,7 +417,9 @@ function applySetData(data, asCopy, asNewVersion) {
form.version.versionNo = current.versionNo || nextVersionNo(versions)
form.version.effectiveTime = undefined
form.version.expireTime = undefined
form.version.status = current.status || '0'
form.version.priceBuildMode = undefined
savedPriceBuildMode.value = undefined
form.details = []
form.products = []
} else {
@@ -372,9 +427,12 @@ function applySetData(data, asCopy, asNewVersion) {
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
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 }))
}
@@ -91,6 +91,17 @@ export function isPriceEditableVersion(row) {
return isDraftVersion(row) || isPendingVersion(row)
}
/**
* Build-mode cards stay visible; locked for enabled/expired versions (已生效/已失效).
* Draft / pending / create wizard remain selectable.
*/
export function isBuildModeLockedVersion(rowOrStatus) {
const s = rowOrStatus && typeof rowOrStatus === 'object' ? rowOrStatus.status : rowOrStatus
if (s == null || s === '') return false
const status = String(s)
return status === VER_STATUS.EFFECT || status === VER_STATUS.EXPIRED
}
export function nextVersionNo(versions) {
const seqs = (versions || []).map(v => Number(v.versionSeq) || 0)
const max = seqs.length ? Math.max.apply(null, seqs) : 0