修改套餐价格

This commit is contained in:
wuweihua
2026-09-16 17:14:19 +08:00
parent 7172621fd3
commit 4441aa12bf
48 changed files with 4258 additions and 682 deletions
@@ -5,7 +5,7 @@
* 避免产品 / 产品国家 / 附加费 被重复请求。
*/
import { listProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct } from '@/api/price/surcharge'
import { listSurchargeByProduct, listSurchargeOptions } from '@/api/price/surcharge'
import {
buildCountryOptions,
buildZoneOptions,
@@ -14,6 +14,7 @@ import {
countryKeyOf,
formatWeightLabel,
matchCountryZone,
surchargeAppliesToRow,
toEditableDetailRow
} from '../utils/priceSet'
@@ -25,20 +26,30 @@ export function usePriceSetRefData() {
const productOptions = ref([])
/** productCode -> bms_product_country 行 */
const countryCache = reactive({})
/** productCode -> 进行中的国家明细请求(去重,避免 hydrate 按行打爆接口) */
const countryPending = {}
/** productCode -> 有效附加费关联 */
const surchargeCache = reactive({})
/** productCode -> 进行中的请求 */
const surchargePending = {}
/** 价格基础 → 附加费配置 主表全量(勾选弹窗数据源,页面内共享一份) */
const allSurchargeList = ref(null)
let allSurchargePending = null
const productMap = computed(() => new Map(productOptions.value.map(p => [p.value, p])))
/** 加载可选产品(已生效) */
/** 加载可选产品(已生效),保留列表展示字段 */
function loadProducts() {
return listProduct({ status: '1', pageNum: 1, pageSize: 1000 }).then(res => {
productOptions.value = (res.rows || []).map(p => ({
value: p.productCode,
label: `${p.productCode} ${p.productName || ''}`.trim(),
name: p.productName
name: p.productName,
serviceLevel: p.serviceLevel,
cargoAttr: p.cargoAttr,
destRegion: p.destRegion,
taxMode: p.taxMode,
countryCount: p.countryCount
}))
return productOptions.value
})
@@ -50,15 +61,72 @@ export function usePriceSetRefData() {
return hit ? hit.name : productCode
}
/** 确保产品的国家×重量段配置已加载(过滤已失效配置) */
function ensureCountries(productCode) {
/** product meta for step-3 product list */
function productMetaOf(productCode) {
return productMap.value.get(productCode) || null
}
/** YYYY-MM-DD from datetime / date */
function dateOnly(v) {
if (!v) return null
return String(v).slice(0, 10)
}
/**
* Country row active at asOf (version effective time).
* Window: effective_date <= asOf AND (expire_date is null OR expire_date > asOf)
*/
function isCountryActiveAt(item, asOf) {
const t = dateOnly(asOf)
if (!t) return true
const eff = dateOnly(item.effectiveDate)
const exp = dateOnly(item.expireDate)
if (eff && eff > t) return false
if (exp && exp <= t) return false
return true
}
/** currently open country rows (not expired) */
function isCountryOpen(item) {
if (String(item.status) === '2') return false
const exp = dateOnly(item.expireDate)
if (!exp) return true
const today = dateOnly(new Date().toISOString())
return exp > today
}
/**
* Load product country×weight rows.
* Cache keeps full list; pass asOf (version effective_time) to filter the active window.
* If asOf match is empty, fall back to currently open rows so step-3 can still show full product prices.
* In-flight requests are deduped per productCode (hydrateRow is per-detail-row).
*/
function ensureCountries(productCode, asOf) {
if (!productCode) return Promise.resolve([])
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
return listProductCountries(productCode).then(res => {
const rows = (res.data || []).filter(item => !item.expireDate)
const filterRows = (rows) => {
const all = rows || []
if (!asOf) return all.filter(isCountryOpen)
const hit = all.filter(item => isCountryActiveAt(item, asOf))
if (hit.length) return hit
return all.filter(isCountryOpen)
}
if (countryCache[productCode]) {
return Promise.resolve(filterRows(countryCache[productCode]))
}
if (countryPending[productCode]) {
return countryPending[productCode].then(filterRows)
}
countryPending[productCode] = listProductCountries(productCode).then(res => {
const rows = res.data || []
countryCache[productCode] = rows
return rows
}).catch(() => {
// do not cache failure; allow later retry without stampeding while in-flight
return []
}).finally(() => {
delete countryPending[productCode]
})
return countryPending[productCode].then(filterRows)
}
/** 确保产品的有效附加费已加载(剔除 OF 燃油,结果缓存 + 请求去重) */
@@ -71,7 +139,7 @@ export function usePriceSetRefData() {
surchargeCache[productCode] = rows
return rows
}).catch(() => {
surchargeCache[productCode] = []
// 失败不写缓存,后续可重试(避免一次抖动就让该产品永久显示无附加费)
return []
}).finally(() => {
delete surchargePending[productCode]
@@ -79,18 +147,41 @@ export function usePriceSetRefData() {
return surchargePending[productCode]
}
/** 产品已关联的附加费全量(不按国家/重量段过滤),供套餐勾选使用 */
function allSurchargesOf(productCode) {
if (!productCode) return []
return surchargeCache[productCode] || []
}
/** 确保附加费配置主表已加载(剔除 OF 燃油,页面内共享一份缓存) */
function ensureAllSurcharges() {
if (allSurchargeList.value) return Promise.resolve(allSurchargeList.value)
if (!allSurchargePending) {
allSurchargePending = listSurchargeOptions().then(res => {
// /options 返回 AjaxResult(data),兼容分页接口的 rows 写法
const raw = res.data || res.rows || []
const rows = raw.filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
allSurchargeList.value = rows
return rows
}).catch(() => {
// 失败不缓存,允许下次重试;调用方拿空数组也不至于报错中断
return []
}).finally(() => {
allSurchargePending = null
})
}
return allSurchargePending
}
/** 附加费配置主表全量(需先 ensureAllSurcharges) */
function allSurchargeConfigs() {
return allSurchargeList.value || []
}
/** 明细行命中的附加费(按产品 + 国家 + 重量段过滤) */
function surchargesOf(row) {
if (!row || !row.productCode) return []
const all = surchargeCache[row.productCode] || []
return all.filter(s => {
if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
return false
}
if (s.detailId == null) return true
if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true
return false
})
return (surchargeCache[row.productCode] || []).filter(s => surchargeAppliesToRow(s, row))
}
/** 明细行可选国家 */
@@ -115,8 +206,12 @@ export function usePriceSetRefData() {
productOptions,
loadProducts,
productNameOf,
productMetaOf,
ensureCountries,
ensureSurcharges,
allSurchargesOf,
ensureAllSurcharges,
allSurchargeConfigs,
surchargesOf,
countryOptionsOf,
zoneOptionsOf,
@@ -140,9 +235,13 @@ export function useDetailCascade(refData) {
row.zoneCode = undefined
row.zoneName = undefined
row.weightLabel = undefined
row.weightMin = undefined
row.weightMax = undefined
row.productCountryId = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
row.effectiveDate = undefined
row.expireDate = undefined
}
/** 选择产品:回填名称、清空级联、预取附加费与国家配置 */
@@ -163,9 +262,13 @@ export function useDetailCascade(refData) {
row.zoneCode = undefined
row.zoneName = undefined
row.weightLabel = undefined
row.weightMin = undefined
row.weightMax = undefined
row.productCountryId = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
row.effectiveDate = undefined
row.expireDate = undefined
}
function onZoneChange(row) {
@@ -174,9 +277,13 @@ export function useDetailCascade(refData) {
row.zoneCode = opt ? opt.zoneCode : undefined
row.zoneName = opt ? opt.zoneName : undefined
row.weightLabel = undefined
row.weightMin = undefined
row.weightMax = undefined
row.productCountryId = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
row.effectiveDate = undefined
row.expireDate = undefined
}
/** 选择重量段:绑定产品国家明细,并带出默认运费 / 挂号费 */
@@ -184,6 +291,10 @@ export function useDetailCascade(refData) {
const opt = refData.weightOptionsOf(row).find(w => w.value === row.weightLabel)
if (!opt) {
row.productCountryId = undefined
row.weightMin = undefined
row.weightMax = undefined
row.effectiveDate = undefined
row.expireDate = undefined
return
}
row.productCountryId = opt.detailId
@@ -192,14 +303,32 @@ export function useDetailCascade(refData) {
if (opt.zoneName) row.zoneName = opt.zoneName
if (opt.freightPrice != null) row.fvValue = Number(opt.freightPrice)
if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee)
row.weightMin = opt.weightMin
row.weightMax = opt.weightMax
row.effectiveDate = opt.effectiveDate
row.expireDate = opt.expireDate
}
/** hydrate detail row; normalize weight label to min<W\u2264max */
function hydrateRow(d, setType) {
const row = toEditableDetailRow(d)
if (!row.productCode) return row
refData.ensureSurcharges(row.productCode)
// Do NOT prefetch /surcharge/byProduct here: applySetData hydrates every detail row
// on step-1 load; use version snapshot row.surcharges instead. Fetch on demand in picker.
if (setType !== 'FIXED') return row
// Bulk load from DB already has productCountryId + weight bounds: normalize locally,
// do not fire /countries per detail row (was stampeding thousands of identical requests).
if (row.productCountryId != null && (row.weightMin != null || row.weightMax != null || row.weightLabel)) {
if (row.weightMin != null || row.weightMax != null) {
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)
return row
}
refData.ensureCountries(row.productCode).then(rows => {
if (!row.weightLabel) return
const hit = rows.find(item => {
@@ -217,6 +346,12 @@ export function useDetailCascade(refData) {
if (hit.zoneId != null) row.zoneId = hit.zoneId
if (hit.zoneCode) row.zoneCode = hit.zoneCode
if (hit.zoneName) row.zoneName = hit.zoneName
row.weightMin = hit.weightMin
row.weightMax = hit.weightMax
row.effectiveDate = hit.effectiveDate
row.expireDate = hit.expireDate
if (row._baseFreight == null && hit.freightPrice != null) row._baseFreight = Number(hit.freightPrice)
if (row._baseReg == null && hit.registrationFee != null) row._baseReg = Number(hit.registrationFee)
})
return row
}