358 lines
13 KiB
JavaScript
358 lines
13 KiB
JavaScript
/**
|
||
* 价格套餐 · 参考数据与明细级联
|
||
*
|
||
* 页面内共享一份(在父页面调用一次,作为 prop 传给各步骤组件),
|
||
* 避免产品 / 产品国家 / 附加费 被重复请求。
|
||
*/
|
||
import { listProductOptions, listProductCountries } from '@/api/price/product'
|
||
import { listSurchargeByProduct, listSurchargeOptions } from '@/api/price/surcharge'
|
||
import {
|
||
buildCountryOptions,
|
||
buildZoneOptions,
|
||
buildWeightOptions,
|
||
countryHasZones,
|
||
countryKeyOf,
|
||
formatWeightLabel,
|
||
matchCountryZone,
|
||
surchargeAppliesToRow,
|
||
toEditableDetailRow
|
||
} from '../utils/priceSet'
|
||
|
||
/**
|
||
* 产品 / 产品国家 / 附加费 参考数据(带缓存)
|
||
*/
|
||
export function usePriceSetRefData() {
|
||
/** 产品下拉选项 */
|
||
const productOptions = ref([])
|
||
/** productCode -> 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 listProductOptions({ status: '1' }).then(res => {
|
||
productOptions.value = (res.data || []).map(p => ({
|
||
value: p.productCode,
|
||
label: `${p.productCode} ${p.productName || ''}`.trim(),
|
||
name: p.productName,
|
||
serviceLevel: p.serviceLevel,
|
||
cargoAttr: p.cargoAttr,
|
||
destRegion: p.destRegion,
|
||
taxMode: p.taxMode,
|
||
countryCount: p.countryCount
|
||
}))
|
||
return productOptions.value
|
||
})
|
||
}
|
||
|
||
/** 产品名称 */
|
||
function productNameOf(productCode) {
|
||
const hit = productMap.value.get(productCode)
|
||
return hit ? hit.name : 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([])
|
||
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 燃油,结果缓存 + 请求去重) */
|
||
function ensureSurcharges(productCode) {
|
||
if (!productCode) return Promise.resolve([])
|
||
if (surchargeCache[productCode]) return Promise.resolve(surchargeCache[productCode])
|
||
if (surchargePending[productCode]) return surchargePending[productCode]
|
||
surchargePending[productCode] = listSurchargeByProduct(productCode).then(res => {
|
||
const rows = (res.data || []).filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
|
||
surchargeCache[productCode] = rows
|
||
return rows
|
||
}).catch(() => {
|
||
// 失败不写缓存,后续可重试(避免一次抖动就让该产品永久显示无附加费)
|
||
return []
|
||
}).finally(() => {
|
||
delete surchargePending[productCode]
|
||
})
|
||
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 []
|
||
return (surchargeCache[row.productCode] || []).filter(s => surchargeAppliesToRow(s, row))
|
||
}
|
||
|
||
/** 明细行可选国家 */
|
||
function countryOptionsOf(row) {
|
||
return buildCountryOptions(countryCache[row?.productCode] || [])
|
||
}
|
||
|
||
function zoneOptionsOf(row) {
|
||
return buildZoneOptions(countryCache[row?.productCode] || [], row || {})
|
||
}
|
||
|
||
function hasZonesOf(row) {
|
||
return countryHasZones(countryCache[row?.productCode] || [], row || {})
|
||
}
|
||
|
||
/** 明细行可选重量段 */
|
||
function weightOptionsOf(row) {
|
||
return buildWeightOptions(countryCache[row?.productCode] || [], row || {})
|
||
}
|
||
|
||
return {
|
||
productOptions,
|
||
loadProducts,
|
||
productNameOf,
|
||
productMetaOf,
|
||
ensureCountries,
|
||
ensureSurcharges,
|
||
allSurchargesOf,
|
||
ensureAllSurcharges,
|
||
allSurchargeConfigs,
|
||
surchargesOf,
|
||
countryOptionsOf,
|
||
zoneOptionsOf,
|
||
hasZonesOf,
|
||
weightOptionsOf
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 明细行级联:产品 → 国家 → 分区 → 重量段,并带出默认运费 / 挂号费
|
||
*
|
||
* @param {Object} refData usePriceSetRefData() 的返回值(页面内共享同一份)
|
||
*/
|
||
export function useDetailCascade(refData) {
|
||
/** 切换产品后清空下游字段 */
|
||
function resetRow(row) {
|
||
row.countryCode = undefined
|
||
row.countryName = undefined
|
||
row._countryKey = undefined
|
||
row.zoneId = undefined
|
||
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 onProductChange(row, setType) {
|
||
row.productName = row.productCode ? refData.productNameOf(row.productCode) : undefined
|
||
resetRow(row)
|
||
if (!row.productCode) return
|
||
refData.ensureSurcharges(row.productCode)
|
||
if (setType === 'FIXED') refData.ensureCountries(row.productCode)
|
||
}
|
||
|
||
/** 选择国家:回填国家码 / 名称,清空重量段与价格 */
|
||
function onCountryChange(row) {
|
||
const opt = refData.countryOptionsOf(row).find(c => c.key === row._countryKey)
|
||
row.countryCode = opt ? opt.code : undefined
|
||
row.countryName = opt ? opt.name : undefined
|
||
row.zoneId = undefined
|
||
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) {
|
||
const opt = refData.zoneOptionsOf(row).find(z => z.key === row.zoneCode)
|
||
row.zoneId = opt ? opt.zoneId : undefined
|
||
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
|
||
}
|
||
|
||
/** 选择重量段:绑定产品国家明细,并带出默认运费 / 挂号费 */
|
||
function onWeightChange(row) {
|
||
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
|
||
if (opt.zoneId != null) row.zoneId = opt.zoneId
|
||
if (opt.zoneCode) row.zoneCode = opt.zoneCode
|
||
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
|
||
// Do NOT prefetch /surcharge/byProduct here: applySetData hydrates every detail row
|
||
// on step-1 load; use SALES rels on 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)
|
||
// Keep package prices in fv*; product base is stamped later from product_country.
|
||
return row
|
||
}
|
||
refData.ensureCountries(row.productCode).then(rows => {
|
||
if (!row.weightLabel) return
|
||
const hit = rows.find(item => {
|
||
if (row.productCountryId != null && Number(item.detailId) === Number(row.productCountryId)) return true
|
||
if (!matchCountryZone(row, item)) return false
|
||
return formatWeightLabel(item.weightMin, item.weightMax) === row.weightLabel
|
||
|| (row.weightLabel.includes(String(item.weightMin)) && row.weightLabel.includes(String(item.weightMax)))
|
||
})
|
||
if (!hit) return
|
||
row.weightLabel = formatWeightLabel(hit.weightMin, hit.weightMax)
|
||
row._countryKey = countryKeyOf(hit)
|
||
row.productCountryId = hit.detailId
|
||
if (!row.countryCode) row.countryCode = hit.countryCode
|
||
if (!row.countryName) row.countryName = hit.countryName
|
||
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
|
||
}
|
||
|
||
return { resetRow, onProductChange, onCountryChange, onZoneChange, onWeightChange, hydrateRow }
|
||
}
|