diff --git a/bms-back/bms-admin/src/main/resources/i18n/messages.properties b/bms-back/bms-admin/src/main/resources/i18n/messages.properties index 2c3d7804..399cb02d 100644 --- a/bms-back/bms-admin/src/main/resources/i18n/messages.properties +++ b/bms-back/bms-admin/src/main/resources/i18n/messages.properties @@ -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} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcIncomeBillFeeMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcIncomeBillFeeMapper.java index 736c2c8d..a1897aff 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcIncomeBillFeeMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcIncomeBillFeeMapper.java @@ -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); } 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 f3a085d5..9436d5a5 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 @@ -309,6 +309,9 @@ public class BmsProductServiceImpl implements IBmsProductService } List euRegions = loadEuRegions(regionByNameCn); Map 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 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 products, + Map regionByNameCn, List euRegions, + Map zoneByCountryAndCode) + { + if (workbook == null || products == null || products.isEmpty()) + { + return; + } + List tips = new ArrayList(); + Set unmatchedCountries = new HashSet(); + for (BmsProduct product : products) + { + if (product == null || StringUtils.isEmpty(product.getProductCode())) + { + continue; + } + Sheet productSheet = findProductSheet(workbook, product); + if (productSheet == null) + { + continue; + } + List 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("
", tips))); + } + } + + /** Fail when Excel rows for one product contain duplicate country + weight band. */ + private void assertNoDuplicateCountryWeightRows(BmsProduct product, List rows) + { + String tip = buildDuplicateCountryWeightTip(product, rows); + if (StringUtils.isNotEmpty(tip)) + { + throw new ServiceException(tip); + } + } + + private String buildDuplicateCountryWeightTip(BmsProduct product, List rows) + { + List 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 findDuplicateCountryWeightLabels(List rows) + { + List dups = new ArrayList(); + if (rows == null || rows.isEmpty()) + { + return dups; + } + Map counts = new LinkedHashMap(); + Map labels = new LinkedHashMap(); + 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 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 countries, Date newEff) { diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java index 9ef6a685..09b7d47f 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java @@ -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 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 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 filterAutoFeesAgainstManual(String billId, List autoFees) + { + if (autoFees == null || autoFees.isEmpty() || StringUtils.isEmpty(billId)) + { + return autoFees == null ? new ArrayList() : autoFees; + } + List existing = incomeBillFeeMapper.selectByBillId(billId); + if (existing == null || existing.isEmpty()) + { + return autoFees; + } + Set manualKeys = new HashSet(); + for (PrcIncomeBillFee row : existing) + { + if (row != null && "1".equals(row.getManualFlag())) + { + manualKeys.add(feeUniqueKey(row.getFeeType(), row.getScId())); + } + } + if (manualKeys.isEmpty()) + { + return autoFees; + } + List out = new ArrayList(); + 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. 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 55096802..10649d04 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 @@ -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) { diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcIncomeBillFeeMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcIncomeBillFeeMapper.xml index 208c858d..dea6e05d 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcIncomeBillFeeMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcIncomeBillFeeMapper.xml @@ -180,4 +180,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from prc_income_bill_fee where bill_id = #{billId} + + + delete from prc_income_bill_fee + where bill_id = #{billId} + and (manual_flag is null or manual_flag <> '1') + diff --git a/bms-back/sql/price/prc_price_excel_file.sql b/bms-back/sql/price/prc_price_excel_file.sql new file mode 100644 index 00000000..4245c594 --- /dev/null +++ b/bms-back/sql/price/prc_price_excel_file.sql @@ -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); 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 c35f46e2..6dfe8d2c 100644 --- a/bms-front/src/views/price/set/components/create/PriceEditPanel.vue +++ b/bms-front/src/views/price/set/components/create/PriceEditPanel.vue @@ -11,13 +11,14 @@
关闭 - 完成 + 完成
@@ -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') } 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 216dd676..8acff56c 100644 --- a/bms-front/src/views/price/set/components/create/StepDetails.vue +++ b/bms-front/src/views/price/set/components/create/StepDetails.vue @@ -17,21 +17,21 @@ v-if="showImportAction" type="primary" size="small" - :disabled="(livePersist && !priceSetId) || pulling" + :disabled="(livePersist && !priceSetId) || pulling || buildModeDirty" @click="openImport" >{{ importActionLabel }} -
+

创建价格方式