480 lines
14 KiB
JavaScript
480 lines
14 KiB
JavaScript
/**
|
||
* 价格套餐 · 纯函数与数据工厂
|
||
*
|
||
* 只做数据转换,不依赖组件实例 / 字典 / 接口,
|
||
* create.vue、detail.vue 及 set/components 下的组件共用。
|
||
*/
|
||
import { parseTime } from '@/utils/bms'
|
||
|
||
/** 计价类型枚举 */
|
||
export const SET_TYPE = {
|
||
FIXED: 'FIXED',
|
||
DISCOUNT: 'DISCOUNT'
|
||
}
|
||
|
||
/**
|
||
* Version create-price mode
|
||
* PULL: from product library
|
||
* IMPORT: Excel + preload latest prior version
|
||
* IMPORT_FRESH: Excel only, no prior version data
|
||
*/
|
||
export const PRICE_BUILD_MODE = {
|
||
PULL: 'PULL',
|
||
IMPORT: 'IMPORT',
|
||
IMPORT_FRESH: 'IMPORT_FRESH'
|
||
}
|
||
|
||
export function isPullBuildMode(mode) {
|
||
return mode === PRICE_BUILD_MODE.PULL
|
||
}
|
||
|
||
export function isImportBuildMode(mode) {
|
||
return mode === PRICE_BUILD_MODE.IMPORT
|
||
}
|
||
|
||
export function isImportFreshBuildMode(mode) {
|
||
return mode === PRICE_BUILD_MODE.IMPORT_FRESH
|
||
}
|
||
|
||
/** Either Excel mode (with or without prior version) */
|
||
export function isExcelBuildMode(mode) {
|
||
return isImportBuildMode(mode) || isImportFreshBuildMode(mode)
|
||
}
|
||
|
||
/** 套餐状态枚举:D 待启用 / A 审核中 / E 生效 / R 驳回 / S 已停用 */
|
||
export const SET_STATUS = {
|
||
DRAFT: 'D',
|
||
AUDIT: 'A',
|
||
EFFECTIVE: 'E',
|
||
REJECT: 'R',
|
||
STOPPED: 'S'
|
||
}
|
||
|
||
const STATUS_LABEL = {
|
||
D: '待启用',
|
||
A: '审核中',
|
||
E: '生效',
|
||
R: '驳回',
|
||
S: '已停用'
|
||
}
|
||
|
||
const STATUS_TAG = {
|
||
D: 'warning',
|
||
A: 'primary',
|
||
E: 'success',
|
||
R: 'danger',
|
||
S: 'info'
|
||
}
|
||
|
||
/** 默认取值方式 */
|
||
export const DEFAULT_ROUND_MODE = 'HALF_UP'
|
||
|
||
/** version status: 0 draft / 3 pending / 1 active / 2 expired */
|
||
export const VER_STATUS = {
|
||
DRAFT: '0',
|
||
PENDING: '3',
|
||
EFFECT: '1',
|
||
EXPIRED: '2'
|
||
}
|
||
|
||
export function versionStatusLabel(status) {
|
||
const map = {
|
||
'0': '草稿',
|
||
'3': '待生效',
|
||
'1': '生效',
|
||
'2': '失效'
|
||
}
|
||
return map[status] || status || '-'
|
||
}
|
||
|
||
export function versionStatusTag(status) {
|
||
const map = { '0': 'warning', '3': 'primary', '1': 'success', '2': 'info' }
|
||
return map[status] || 'info'
|
||
}
|
||
|
||
/** True draft only (not pending) */
|
||
export function isDraftVersion(row) {
|
||
return !!row && String(row.status) === VER_STATUS.DRAFT
|
||
}
|
||
|
||
export function isPendingVersion(row) {
|
||
return !!row && String(row.status) === VER_STATUS.PENDING
|
||
}
|
||
|
||
export function isEffectVersion(row) {
|
||
return !!row && String(row.status) === VER_STATUS.EFFECT
|
||
}
|
||
|
||
/** Draft or pending: may continue editing price details */
|
||
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
|
||
return 'V' + (max + 1)
|
||
}
|
||
|
||
function versionHasProducts(v) {
|
||
return !!(v && (v.products || []).some(p => p && p.productCode))
|
||
}
|
||
|
||
/**
|
||
* Latest prior version for Excel build-price preload (exclude current draft).
|
||
* Prefer EFFECT / PENDING / highest versionSeq that actually has products,
|
||
* so an empty EFFECT does not hide an older version that still has data.
|
||
*/
|
||
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 ranked = []
|
||
const effect = list.find(v => String(v.status) === VER_STATUS.EFFECT)
|
||
if (effect) ranked.push(effect)
|
||
const pending = list.find(v => String(v.status) === VER_STATUS.PENDING)
|
||
if (pending && (!effect || String(pending.psvId) !== String(effect.psvId))) ranked.push(pending)
|
||
const bySeq = list.slice().sort((a, b) => (Number(b.versionSeq) || 0) - (Number(a.versionSeq) || 0))
|
||
bySeq.forEach(v => {
|
||
if (!ranked.some(x => String(x.psvId) === String(v.psvId))) ranked.push(v)
|
||
})
|
||
const withProducts = ranked.find(versionHasProducts)
|
||
return withProducts || ranked[0] || null
|
||
}
|
||
|
||
/** Package status label */
|
||
export function setStatusLabel(set) {
|
||
if (set && set.expired) return '已过期'
|
||
if (set && set.status && STATUS_LABEL[set.status]) return STATUS_LABEL[set.status]
|
||
return set && set.status ? String(set.status) : '-'
|
||
}
|
||
|
||
/** Package status tag type */
|
||
export function setStatusTagType(set) {
|
||
if (set && set.expired) return 'danger'
|
||
if (set && set.status && STATUS_TAG[set.status]) return STATUS_TAG[set.status]
|
||
return 'info'
|
||
}
|
||
|
||
/** 明细 / 客户是否可维护:待启用、驳回、生效 */
|
||
export function isChildEditable(status) {
|
||
return [SET_STATUS.DRAFT, SET_STATUS.REJECT, SET_STATUS.EFFECTIVE].includes(status)
|
||
}
|
||
|
||
/** 日期显示 yyyy-MM-dd */
|
||
export function formatDate(val) {
|
||
return val ? parseTime(val, '{y}-{m}-{d}') : ''
|
||
}
|
||
|
||
/** 有效期区间文案,结束为空表示长期 */
|
||
export function formatDateRange(begin, end) {
|
||
return `${formatDate(begin) || '-'} ~ ${end ? formatDate(end) : '长期'}`
|
||
}
|
||
|
||
/** 是否固定价模式 */
|
||
export function isFixedType(setType) {
|
||
return setType === SET_TYPE.FIXED
|
||
}
|
||
|
||
/** 是否折扣模式 */
|
||
export function isDiscountType(setType) {
|
||
return setType === SET_TYPE.DISCOUNT
|
||
}
|
||
|
||
/** 计价类型展示文案 */
|
||
export function setTypeLabel(setType) {
|
||
return isFixedType(setType) ? 'FIXED 固定价' : 'DISCOUNT 折扣'
|
||
}
|
||
|
||
/**
|
||
* 数字转字符串,抹掉浮点误差尾巴
|
||
* @param {number|string} v
|
||
* @returns {string} 0.1 + 0.2 -> '0.3'
|
||
*/
|
||
export function trimNum(v) {
|
||
if (v == null || v === '') return ''
|
||
const n = Number(v)
|
||
if (Number.isNaN(n)) return String(v)
|
||
return String(parseFloat(n.toFixed(6)))
|
||
}
|
||
|
||
/** Weight label: left-open right-closed, e.g. 0.3<W\u22641 */
|
||
export function formatWeightLabel(min, max) {
|
||
return `${trimNum(min)}<W≤${trimNum(max)}`
|
||
}
|
||
|
||
/** 国家唯一键:优先国家三字码,其次国家名称 */
|
||
export function countryKeyOf(row) {
|
||
if (!row) return ''
|
||
if (row.countryCode) return row.countryCode
|
||
if (row.countryName) return 'N:' + row.countryName
|
||
return ''
|
||
}
|
||
|
||
/** 明细行的国家 与 产品国家配置行 是否指向同一国家 */
|
||
export function matchCountry(detail, item) {
|
||
if (detail.countryCode && item.countryCode) {
|
||
return detail.countryCode === item.countryCode
|
||
}
|
||
return !!detail.countryName && item.countryName === detail.countryName
|
||
}
|
||
|
||
/** detail row zone vs product-country row */
|
||
export function matchZone(detail, item) {
|
||
const detailHas = !!(detail.zoneCode || detail.zoneId)
|
||
const itemHas = !!(item.zoneCode || item.zoneId)
|
||
if (!detailHas && !itemHas) return true
|
||
if (detailHas !== itemHas) return false
|
||
if (detail.zoneId != null && item.zoneId != null) return Number(detail.zoneId) === Number(item.zoneId)
|
||
if (detail.zoneCode && item.zoneCode) return String(detail.zoneCode) === String(item.zoneCode)
|
||
return false
|
||
}
|
||
|
||
/** country + zone match */
|
||
export function matchCountryZone(detail, item) {
|
||
return matchCountry(detail, item) && matchZone(detail, item)
|
||
}
|
||
|
||
/**
|
||
* 国家下拉选项(按产品的国家配置去重)
|
||
* @param {Array} rows product_country 行
|
||
* @returns {Array<{key, code, name, label}>}
|
||
*/
|
||
export function buildCountryOptions(rows) {
|
||
const map = new Map()
|
||
;(rows || []).forEach(item => {
|
||
const key = countryKeyOf(item)
|
||
if (!key || map.has(key)) return
|
||
map.set(key, {
|
||
key,
|
||
code: item.countryCode || undefined,
|
||
name: item.countryName,
|
||
label: item.countryCode ? `${item.countryName} ${item.countryCode}` : item.countryName
|
||
})
|
||
})
|
||
return [...map.values()]
|
||
}
|
||
|
||
/**
|
||
* 重量段下拉选项(按已选国家过滤去重)
|
||
* @param {Array} rows product_country 行
|
||
* @param {Object} row 当前明细行(含国家)
|
||
* @returns {Array<{value, label, detailId, freightPrice, registrationFee}>}
|
||
*/
|
||
/**
|
||
* Zone options for selected country (from product-country rows).
|
||
* @returns {Array<{key, zoneId, zoneCode, zoneName, label}>}
|
||
*/
|
||
export function buildZoneOptions(rows, row) {
|
||
const map = new Map()
|
||
;(rows || []).filter(item => matchCountry(row, item)).forEach(item => {
|
||
if (!item.zoneCode && item.zoneId == null) return
|
||
const key = item.zoneCode || String(item.zoneId)
|
||
if (map.has(key)) return
|
||
map.set(key, {
|
||
key,
|
||
zoneId: item.zoneId,
|
||
zoneCode: item.zoneCode || undefined,
|
||
zoneName: item.zoneName || undefined,
|
||
label: item.zoneName || item.zoneCode || key
|
||
})
|
||
})
|
||
return [...map.values()]
|
||
}
|
||
|
||
/** Whether selected country has any zone rows */
|
||
export function countryHasZones(rows, row) {
|
||
return buildZoneOptions(rows, row).length > 0
|
||
}
|
||
|
||
export function buildWeightOptions(rows, row) {
|
||
const list = rows || []
|
||
if (countryHasZones(list, row) && !(row && (row.zoneCode || row.zoneId))) {
|
||
return []
|
||
}
|
||
const map = new Map()
|
||
list.filter(item => matchCountryZone(row, item)).forEach(item => {
|
||
if (item.weightMin == null && item.weightMax == null) return
|
||
const value = formatWeightLabel(item.weightMin, item.weightMax)
|
||
if (map.has(value)) return
|
||
map.set(value, {
|
||
value,
|
||
label: value,
|
||
detailId: item.detailId,
|
||
zoneId: item.zoneId,
|
||
zoneCode: item.zoneCode,
|
||
zoneName: item.zoneName,
|
||
freightPrice: item.freightPrice,
|
||
registrationFee: item.registrationFee,
|
||
weightMin: item.weightMin,
|
||
weightMax: item.weightMax,
|
||
effectiveDate: item.effectiveDate,
|
||
expireDate: item.expireDate
|
||
})
|
||
})
|
||
return [...map.values()]
|
||
}
|
||
|
||
/** 附加费金额展示:RATE 转百分比,其余带币种 */
|
||
export function formatChargeValue(row) {
|
||
if (!row || row.chargeValue == null) return '-'
|
||
const v = Number(row.chargeValue)
|
||
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
|
||
return v + ' ' + (row.currency || 'CNY')
|
||
}
|
||
|
||
/** 附加费作用范围文案 */
|
||
export function surchargeScopeLabel(s) {
|
||
// 主表(prc_surcharge)数据:country_code 有值 = 该国专属(计费归属),空 = 全部国家
|
||
if (s.detailId == null && s.psdId == null) {
|
||
return s.countryCode ? `国家 ${s.countryCode}` : '全部国家'
|
||
}
|
||
const w = s.weightMin != null && s.weightMax != null ? `${s.weightMin}-${s.weightMax}kg` : '-'
|
||
return `${s.countryName || '-'} · ${w}`
|
||
}
|
||
|
||
/**
|
||
* 产品关联附加费(prc_surcharge_rel)是否作用于该明细行
|
||
* 产品关联:国家 + productCountryId;SALES:detailId=psdId
|
||
*/
|
||
export function surchargeAppliesToRow(rel, row) {
|
||
if (!rel || !row) return false
|
||
if (rel.countryCode && row.countryCode
|
||
&& String(rel.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
|
||
return false
|
||
}
|
||
// SALES rel: detail_id = psd_id
|
||
if (rel.priceType === 'SALES') {
|
||
if (rel.detailId != null && row.psdId != null) {
|
||
return Number(rel.detailId) === Number(row.psdId)
|
||
}
|
||
return true
|
||
}
|
||
// PRODUCT / REDLINE: product_country detail or product-level
|
||
if (rel.detailId == null) return true
|
||
return row.productCountryId != null && Number(rel.detailId) === Number(row.productCountryId)
|
||
}
|
||
|
||
/** 附加费标签文案 */
|
||
export function surchargeTagLabel(s) {
|
||
return `${s.scName} ${formatChargeValue(s)}`
|
||
}
|
||
|
||
/** 新增一条空明细行 */
|
||
export function createEmptyDetailRow() {
|
||
return {
|
||
productCode: undefined,
|
||
productName: undefined,
|
||
countryCode: undefined,
|
||
countryName: undefined,
|
||
_countryKey: undefined,
|
||
zoneId: undefined,
|
||
zoneCode: undefined,
|
||
zoneName: undefined,
|
||
weightLabel: undefined,
|
||
weightMin: undefined,
|
||
weightMax: undefined,
|
||
productCountryId: undefined,
|
||
fvValue: undefined,
|
||
fvRegistrationFee: undefined,
|
||
/** product default freight when copied from product_country */
|
||
_baseFreight: undefined,
|
||
/** product default registration fee */
|
||
_baseReg: undefined,
|
||
discountValue: undefined,
|
||
roundMode: DEFAULT_ROUND_MODE,
|
||
effectiveDate: undefined,
|
||
expireDate: undefined
|
||
}
|
||
}
|
||
|
||
/** 明细行 -> 可编辑行(补齐取值方式与国家键) */
|
||
export function toEditableDetailRow(row) {
|
||
return {
|
||
...row,
|
||
roundMode: row.roundMode || DEFAULT_ROUND_MODE,
|
||
_countryKey: countryKeyOf(row),
|
||
_baseFreight: row._baseFreight != null ? row._baseFreight : undefined,
|
||
_baseReg: row._baseReg != null ? row._baseReg : undefined
|
||
}
|
||
}
|
||
|
||
/** FIXED row changed vs product default */
|
||
export function isDetailPriceChanged(row) {
|
||
if (!row) return false
|
||
if (row._baseFreight != null && Number(row.fvValue) !== Number(row._baseFreight)) return true
|
||
if (row._baseReg != null && Number(row.fvRegistrationFee) !== Number(row._baseReg)) return true
|
||
return false
|
||
}
|
||
|
||
/** 新增一条空客户绑定行 */
|
||
export function createEmptyCustomerRow({ effectiveDate, expireDate, seq = 1 } = {}) {
|
||
return {
|
||
customerId: undefined,
|
||
customerCode: undefined,
|
||
customerName: '',
|
||
bindPriority: seq,
|
||
prBegindate: effectiveDate,
|
||
prEnddate: expireDate
|
||
}
|
||
}
|
||
|
||
/** 新建套餐的基本信息默认值 */
|
||
export function createEmptyBasicInfo() {
|
||
return {
|
||
priceSetName: '',
|
||
setType: SET_TYPE.FIXED,
|
||
defaultDiscount: 88,
|
||
priceBuildMode: undefined,
|
||
effectiveDate: undefined,
|
||
expireDate: undefined,
|
||
priceSetNote: ''
|
||
}
|
||
}
|
||
|
||
/** 明细行 -> 提交后端的 payload */
|
||
export function toDetailPayload(d) {
|
||
return {
|
||
psdId: d.psdId || undefined,
|
||
psvId: d.psvId || undefined,
|
||
productCode: d.productCode,
|
||
productName: d.productName,
|
||
countryCode: d.countryCode || undefined,
|
||
countryName: d.countryName || undefined,
|
||
zoneId: d.zoneId || undefined,
|
||
zoneCode: d.zoneCode || undefined,
|
||
zoneName: d.zoneName || undefined,
|
||
weightLabel: d.weightLabel || undefined,
|
||
weightMin: d.weightMin,
|
||
weightMax: d.weightMax,
|
||
productCountryId: d.productCountryId || undefined,
|
||
fvValue: d.fvValue,
|
||
fvRegistrationFee: d.fvRegistrationFee,
|
||
discountValue: d.discountValue,
|
||
roundMode: d.roundMode || DEFAULT_ROUND_MODE,
|
||
effectiveDate: d.effectiveDate || undefined,
|
||
expireDate: d.expireDate || undefined,
|
||
/** 勾选保存的附加费:带数组即按勾选落快照,空数组=一条都不选 */
|
||
surcharges: Array.isArray(d.surcharges) ? d.surcharges.map(s => ({ scId: s.scId })) : undefined
|
||
}
|
||
}
|
||
|
||
/** 客户绑定行 -> 提交后端的 payload */
|
||
export function toCustomerPayload(c) {
|
||
return { ...c }
|
||
}
|