diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsProductServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsProductServiceImpl.java index 98707e1c..885e0d5e 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsProductServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsProductServiceImpl.java @@ -349,7 +349,8 @@ public class BmsProductServiceImpl implements IBmsProductService } List euRegions = loadEuRegions(regionByNameCn); Map zoneByCountryAndCode = loadZoneByCountryAndCode(); - // Pre-check: same product cannot have duplicate country + weight band in Excel + // Pre-check: any price sheet with duplicate country+weight fails the whole import. + assertAllPriceSheetsNoDupCountryWeight(workbook, regionByNameCn, euRegions, zoneByCountryAndCode); assertImportProductsNoDupCountryWeight(workbook, products, regionByNameCn, euRegions, zoneByCountryAndCode); @@ -632,6 +633,8 @@ public class BmsProductServiceImpl implements IBmsProductService Map regionByNameCn = loadRegionByNameCn(); List euRegions = loadEuRegions(regionByNameCn); Map zoneByCountryAndCode = loadZoneByCountryAndCode(); + // Fail whole import when any price sheet has duplicate country+weight (incl. unmatched sheets). + assertAllPriceSheetsNoDupCountryWeight(workbook, regionByNameCn, euRegions, zoneByCountryAndCode); Set unmatchedCountries = new HashSet<>(); Map> out = new LinkedHashMap<>(); for (BmsProduct product : products) @@ -1166,6 +1169,12 @@ public class BmsProductServiceImpl implements IBmsProductService { score = 80; } + else if (StringUtils.isNotEmpty(nameKey) && sheetNameBase(nameKey).equals(sheetNameBase(key)) + && sheetNameBase(nameKey).length() >= 2) + { + // e.g. catalog name suffix differs from sheet (tax/cargo label) + score = 70; + } else if (StringUtils.isNotEmpty(codeKey) && key.contains(codeKey)) { score = 60; @@ -1192,6 +1201,21 @@ public class BmsProductServiceImpl implements IBmsProductService return out; } + /** Base name before last '-' (catalog/sheet suffix may differ). */ + 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 loadEuRegions(Map regionByNameCn) { List list = new ArrayList<>(); @@ -2119,6 +2143,51 @@ public class BmsProductServiceImpl implements IBmsProductService } } + /** + * Scan every price sheet (not only catalog-matched ones). + * Duplicate country+weight on any sheet fails the whole import + * (e.g. same country twice for different last-mile carriers). + */ + private void assertAllPriceSheetsNoDupCountryWeight(Workbook workbook, + Map regionByNameCn, List euRegions, + Map zoneByCountryAndCode) + { + if (workbook == null) + { + return; + } + List tips = new ArrayList(); + Set unmatchedCountries = new HashSet(); + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String sheetName = workbook.getSheetName(i); + if (shouldSkipCountryPriceSheet(sheetName)) + { + continue; + } + Sheet sheet = workbook.getSheetAt(i); + if (sheet == null || findHeaderCell(sheet, "\u56fd\u5bb6") == null) + { + continue; + } + BmsProduct stub = new BmsProduct(); + stub.setProductCode(sheetName); + stub.setProductName(sheetName); + List rows = parseProductSheet(sheet, stub, + regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries); + String tip = buildDuplicateCountryWeightTip(stub, rows); + if (StringUtils.isNotEmpty(tip)) + { + tips.add(tip); + } + } + if (!tips.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.dupCountry", + String.join("
", tips))); + } + } + /** * Workbook pre-check for full import: reject before any write when a product sheet * has duplicate country + weight band (same zone) rows. diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java index 47c3e5b9..5caea269 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java @@ -3018,6 +3018,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService /** * Draft only: when priceBuildMode changes (PULL/IMPORT), delete this version's * product / detail / SALES surcharge rel rows. Same mode or non-draft: no-op. + * IMPORT: after wipe, copy products+details from latest prior version (if any). */ private void clearDraftVersionChildrenIfBuildModeChanged(PrcPriceSetVersion db, PrcPriceSetVersion incoming) { @@ -3043,9 +3044,80 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService deleteSalesRelsByPsvId(psvId); detailMapper.deletePrcPriceSetDetailByPsvId(psvId); setProductMapper.deleteByPsvId(psvId); + if ("IMPORT".equalsIgnoreCase(newMode)) + { + copyLatestPriorVersionChildren(db.getPriceSetId(), psvId); + } } - /** Prefer same effective_time, else draft, else first version of the set. */ + /** + * Copy products + details from latest prior version into the draft (Excel build-price base). + * Prefer EFFECT, then PENDING, then highest versionSeq. + */ + private void copyLatestPriorVersionChildren(Long priceSetId, Long targetPsvId) + { + Long srcPsvId = findLatestPriorPsvId(priceSetId, targetPsvId); + if (srcPsvId == null || targetPsvId == null) + { + return; + } + setProductMapper.copyFromVersion(srcPsvId, targetPsvId); + int detailRows = detailMapper.copyDetailsFromVersion(srcPsvId, targetPsvId); + if (detailRows > 0) + { + setProductMapper.bindDetailPspByPsvId(targetPsvId); + } + } + + private Long findLatestPriorPsvId(Long priceSetId, Long excludePsvId) + { + if (priceSetId == null) + { + return null; + } + PrcPriceSetVersion q = new PrcPriceSetVersion(); + q.setPriceSetId(priceSetId); + List list = versionMapper.selectPrcPriceSetVersionList(q); + if (list == null || list.isEmpty()) + { + return null; + } + PrcPriceSetVersion effect = null; + PrcPriceSetVersion pending = null; + PrcPriceSetVersion best = null; + for (PrcPriceSetVersion v : list) + { + if (v == null || v.getPsvId() == null) + { + continue; + } + if (excludePsvId != null && excludePsvId.equals(v.getPsvId())) + { + continue; + } + if (VER_EFFECT.equals(v.getStatus()) && effect == null) + { + effect = v; + } + else if (VER_PENDING.equals(v.getStatus()) && pending == null) + { + pending = v; + } + if (best == null || (v.getVersionSeq() != null && best.getVersionSeq() != null + && v.getVersionSeq().intValue() > best.getVersionSeq().intValue()) + || (best.getVersionSeq() == null && v.getVersionSeq() != null)) + { + best = v; + } + } + PrcPriceSetVersion pick = effect != null ? effect : (pending != null ? pending : best); + return pick == null ? null : pick.getPsvId(); + } + + /** + * Prefer same effective_time, else an existing draft. + * Never fall back to EFFECT/EXPIRED — that would overwrite a live version when creating a new one. + */ private PrcPriceSetVersion findReusableVersion(Long priceSetId, Date effectiveTime) { PrcPriceSetVersion q = new PrcPriceSetVersion(); @@ -3072,7 +3144,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService return v; } } - return list.get(0); + return null; } /** diff --git a/bms-front/src/views/price/set/components/create/PriceEditPanel.vue b/bms-front/src/views/price/set/components/create/PriceEditPanel.vue index 6dfe8d2c..3692239d 100644 --- a/bms-front/src/views/price/set/components/create/PriceEditPanel.vue +++ b/bms-front/src/views/price/set/components/create/PriceEditPanel.vue @@ -151,6 +151,20 @@ function reloadSavedProducts() { }) } +async function reloadProductsAfterImportModeSave() { + const id = props.priceSetId + if (!id) return + const res = await getPriceSet(id) + 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 })) + details.value = [] +} + async function finish() { if (!props.priceSetId) { close() @@ -178,12 +192,17 @@ async function finish() { } 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 }) + savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p })) proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,已重新拉取产品列表") + } else if (version.priceBuildMode === 'IMPORT') { + // Backend copies latest prior version products/details into this draft + await reloadProductsAfterImportModeSave() + proxy.$modal.msgSuccess("建价方式已保存,已带出上一版产品,可导入 Excel 按产品+国家覆盖或新增") } else { + savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p })) proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可导入 Excel 建价") } close() diff --git a/bms-front/src/views/price/set/components/create/StepDetails.vue b/bms-front/src/views/price/set/components/create/StepDetails.vue index f44cd544..fc7ebfe3 100644 --- a/bms-front/src/views/price/set/components/create/StepDetails.vue +++ b/bms-front/src/views/price/set/components/create/StepDetails.vue @@ -203,6 +203,7 @@ import { isFixedType, isImportBuildMode, isPullBuildMode, + pickLatestPriorVersion, setTypeLabel, surchargeAppliesToRow, toDetailPayload @@ -306,8 +307,8 @@ const importDesc = computed(() => { } return [ '适用威狮B2C小包价表(含「产品目录」)。', - '按产品+国家覆盖:Excel 中出现的产品+国家会替换已有同范围明细,其它产品/国家保留。', - '仅固定价套餐「导入 Excel 建价」可用;不影响导入修改价格/拉取产品库。' + '先带出上一版全部产品;导入按「产品+国家」匹配:命中则覆盖该国家明细,未命中则新增;其它产品/国家保留。', + '同一产品下重复国家+重量段将整单失败。仅固定价套餐「导入 Excel 建价」可用。' ] }) @@ -324,7 +325,7 @@ const showImportAction = computed(() => hasBuildMode.value && isFixedType(props. const emptyTip = computed(() => { if (props.livePersist) return '暂无产品' if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成' - if (isImportBuildMode(props.priceBuildMode)) return '暂无产品,请点击右上角导入 Excel 建价' + if (isImportBuildMode(props.priceBuildMode)) return '暂无上一版产品,请点击右上角导入 Excel 建价(按产品+国家覆盖或新增)' return '暂无产品,请先选择创建价格方式' }) @@ -373,6 +374,41 @@ function clearLocalDraft() { editGroup.value = null } +/** + * Excel build-price: preload products + details from latest prior version + * so import can upsert by product+country. + */ +async function loadLatestPriorVersionDraft() { + if (!props.priceSetId) return false + pulling.value = true + try { + const res = await getPriceSet(props.priceSetId) + const data = res.data || {} + const versions = data.versions || [] + const prior = pickLatestPriorVersion(versions, props.psvId || resolvedPsvId.value) + if (!prior || prior.psvId == null) return false + const products = (prior.products || []) + .filter(p => p && p.productCode) + .map(p => { + const row = { ...p } + delete row.pspId + delete row.psvId + delete row.priceSetId + return row + }) + if (!products.length) return false + const chunks = await Promise.all(products.map(p => + listSetDetailByProduct(prior.psvId, p.productCode) + .then(r => r.data || []) + .catch(() => []) + )) + applyLocalDraft(products, chunks.flat()) + return true + } finally { + pulling.value = false + } +} + async function loadProductDetails(productCode, force) { if (!productCode) return [] if (!force && detailByProduct[productCode]) return detailByProduct[productCode] @@ -987,7 +1023,7 @@ async function removeProduct(code) { proxy.$modal.msgSuccess('已移除产品明细') } -defineExpose({ pullAllFromLibrary, clearLocalDraft }) +defineExpose({ pullAllFromLibrary, clearLocalDraft, loadLatestPriorVersionDraft }) diff --git a/bms-front/src/views/price/set/components/detail/SetVersionTable.vue b/bms-front/src/views/price/set/components/detail/SetVersionTable.vue index 23fc8967..d515e918 100644 --- a/bms-front/src/views/price/set/components/detail/SetVersionTable.vue +++ b/bms-front/src/views/price/set/components/detail/SetVersionTable.vue @@ -69,13 +69,6 @@ @click="emit('remove', row)" v-hasPermi="['price:set:edit']" >删除 - 复制为新版本 @@ -91,7 +84,7 @@ const props = defineProps({ versions: { type: Array, default: () => [] }, modelValue: { type: [Number, String], default: undefined } }) -const emit = defineEmits(['update:modelValue', 'copy-version', 'create-version', 'open-details', 'view-import-attach', 'activate', 'reschedule', 'edit', 'remove']) +const emit = defineEmits(['update:modelValue', 'create-version', 'open-details', 'view-import-attach', 'activate', 'reschedule', 'edit', 'remove']) const title = '套餐版本' const colNo = '版本号' diff --git a/bms-front/src/views/price/set/create.vue b/bms-front/src/views/price/set/create.vue index 21ab0507..ae358291 100644 --- a/bms-front/src/views/price/set/create.vue +++ b/bms-front/src/views/price/set/create.vue @@ -72,7 +72,7 @@ import { toCustomerPayload, toDetailPayload } from './utils/priceSet' -import { getPriceSet, savePriceSetAsync } from '@/api/price/set' +import { getPriceSet, savePriceSetAsync, createPriceSetVersion, deletePriceSetVersion } from '@/api/price/set' const { proxy } = getCurrentInstance() const route = useRoute() @@ -202,12 +202,19 @@ async function finishEdit() { 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 }) + 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 { + savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p })) + proxy.$modal.msgSuccess("建价方式已保存") } - proxy.$modal.msgSuccess("建价方式已保存") } catch (e) { return } finally { @@ -243,6 +250,12 @@ async function finishEdit() { } creating.value = true try { + if (newVersionMode.value && form.version.psvId == null && form.priceSetId) { + const verRes = await 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 @@ -296,7 +309,7 @@ function buildPayload(opts = {}) { defaultDiscount: undefined, priceSetNote: basic.priceSetNote, version: { - psvId: copyMode.value ? undefined : version.psvId, + psvId: (copyMode.value || (newVersionMode.value && version.psvId == null)) ? undefined : version.psvId, versionNo: version.versionNo, priceBuildMode: version.priceBuildMode || undefined } @@ -323,7 +336,30 @@ function buildPayload(opts = {}) { return payload } -function goBack() { +/** + * 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 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) + } + } catch (e) { + // ignore cleanup errors; still navigate back + } +} + +async function goBack() { + await tryDeleteEmptyNewVersionDraft() const obj = (newVersionMode.value || copyMode.value) && form.priceSetId ? { path: '/customer/setDetail', query: { priceSetId: form.priceSetId } } : { path: '/customer/set' } @@ -383,7 +419,20 @@ function onPriceBuildModeUpdate(mode) { }).catch(() => {}) }) } else if (mode === 'IMPORT') { - proxy.$modal.msgSuccess("请导入 Excel 建价") + nextTick(() => { + const loader = detailsRef.value && detailsRef.value.loadLatestPriorVersionDraft + if (!loader) { + proxy.$modal.msgSuccess("请导入 Excel 建价") + return + } + loader().then(ok => { + proxy.$modal.msgSuccess(ok + ? "已带出上一版全部产品,导入 Excel 按产品+国家覆盖或新增" + : "暂无上一版产品,请导入 Excel 建价") + }).catch(() => { + proxy.$modal.msgSuccess("请导入 Excel 建价") + }) + }) } } @@ -401,6 +450,20 @@ function reloadSavedProductsForCreate() { }) } +async function refreshProductsFromCurrentVersion() { + if (!form.priceSetId) return + const res = await 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, { @@ -426,12 +489,13 @@ function applySetData(data, asCopy, asNewVersion) { form.details = [] form.products = [] } else if (asNewVersion) { - // Pre-created empty draft from detail page; same step2 UX as create package - form.version.psvId = current.psvId - form.version.versionNo = current.versionNo || nextVersionNo(versions) + // Draft is created on finish only. Resume only when query has psvId (legacy empty draft). + 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 = current.status || '0' + form.version.status = (resume && resume.status) || '0' form.version.priceBuildMode = undefined draftPefId.value = undefined savedPriceBuildMode.value = undefined diff --git a/bms-front/src/views/price/set/detail.vue b/bms-front/src/views/price/set/detail.vue index 62132287..a9211e30 100644 --- a/bms-front/src/views/price/set/detail.vue +++ b/bms-front/src/views/price/set/detail.vue @@ -23,7 +23,6 @@ { - const newPsvId = res.data - proxy.$modal.msgSuccess('已创建草稿版本,请继续编辑产品价格') - router.push({ - path: '/customer/setCreate', - query: { priceSetId, psvId: newPsvId, step: '2', newVersion: '1' } - }) - }).catch(() => {}) -} -function onCopyVersion(row) { - if (!row || row.psvId == null) return - const hasDraft = (set.value.versions || []).some(v => v && String(v.status) === '0') - if (hasDraft) { - proxy.$modal.msgWarning('已有草稿版本,请先启动或删除后再复制') - return - } - proxy.$modal.confirm( - '确认复制版本 ' + (row.versionNo || '') + '为新草稿?将新增版本并拷贝全部明细。' - ).then(() => copyPriceSetVersion(row.psvId)).then(res => { - proxy.$modal.msgSuccess('已复制为新版本') - const newPsvId = res.data - load() - if (newPsvId != null) { - editPsvId.value = newPsvId - editVisible.value = true - } - }).catch(() => {}) + // Do not insert draft until user clicks finish on the build-price page. + router.push({ + path: '/customer/setCreate', + query: { priceSetId, step: '2', newVersion: '1' } + }) } function onRemoveVersion(row) { if (!row || row.psvId == null) return diff --git a/bms-front/src/views/price/set/utils/priceSet.js b/bms-front/src/views/price/set/utils/priceSet.js index 6b98b560..5c05be3a 100644 --- a/bms-front/src/views/price/set/utils/priceSet.js +++ b/bms-front/src/views/price/set/utils/priceSet.js @@ -108,6 +108,24 @@ export function nextVersionNo(versions) { return 'V' + (max + 1) } +/** + * Latest prior version for Excel build-price preload (exclude current draft). + * Prefer EFFECT, then PENDING, then highest versionSeq. + */ +export function pickLatestPriorVersion(versions, excludePsvId) { + const list = (versions || []).filter(v => { + if (!v || v.psvId == null) return false + if (excludePsvId != null && String(v.psvId) === String(excludePsvId)) return false + return true + }) + if (!list.length) return null + const effect = list.find(v => String(v.status) === VER_STATUS.EFFECT) + if (effect) return effect + const pending = list.find(v => String(v.status) === VER_STATUS.PENDING) + if (pending) return pending + return list.slice().sort((a, b) => (Number(b.versionSeq) || 0) - (Number(a.versionSeq) || 0))[0] +} + /** Package status label */ export function setStatusLabel(set) { if (set && set.expired) return '已过期'