feat: add ticket booking domain model
This commit is contained in:
201
src/pages-service/tickets/model.mjs
Normal file
201
src/pages-service/tickets/model.mjs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
export const TICKET_VIEWS = {
|
||||||
|
HOME: 'home',
|
||||||
|
DETAIL: 'detail',
|
||||||
|
BOOKING: 'booking',
|
||||||
|
TRAVELERS: 'travelers',
|
||||||
|
PAID: 'paid',
|
||||||
|
ORDERS: 'orders',
|
||||||
|
ORDER_DETAIL: 'orderDetail',
|
||||||
|
REFUND: 'refundApply',
|
||||||
|
REFUND_RESULT: 'refundResult',
|
||||||
|
CREDENTIALS: 'credentials',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMoney = (value) => Number(Number(value || 0).toFixed(2))
|
||||||
|
|
||||||
|
const formatTimestamp = (date) => {
|
||||||
|
const pad = (value) => String(value).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCredential = (prefix, orderId, createdAt) => ({
|
||||||
|
type: prefix === 'GATE' ? 'gate' : 'self',
|
||||||
|
qrToken: `${prefix}-${orderId}`,
|
||||||
|
issuedAt: createdAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const calculateAge = (idNumber, visitDate) => {
|
||||||
|
if (!/^\d{17}[\dXx]$/.test(idNumber || '') || !/^\d{4}-\d{2}-\d{2}$/.test(visitDate || '')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const birthYear = Number(idNumber.slice(6, 10))
|
||||||
|
const birthMonth = Number(idNumber.slice(10, 12))
|
||||||
|
const birthDay = Number(idNumber.slice(12, 14))
|
||||||
|
const visitYear = Number(visitDate.slice(0, 4))
|
||||||
|
const visitMonth = Number(visitDate.slice(5, 7))
|
||||||
|
const visitDay = Number(visitDate.slice(8, 10))
|
||||||
|
const birthDate = new Date(birthYear, birthMonth - 1, birthDay)
|
||||||
|
const visit = new Date(visitYear, visitMonth - 1, visitDay)
|
||||||
|
|
||||||
|
if (
|
||||||
|
birthDate.getFullYear() !== birthYear ||
|
||||||
|
birthDate.getMonth() !== birthMonth - 1 ||
|
||||||
|
birthDate.getDate() !== birthDay ||
|
||||||
|
visit.getFullYear() !== visitYear ||
|
||||||
|
visit.getMonth() !== visitMonth - 1 ||
|
||||||
|
visit.getDate() !== visitDay ||
|
||||||
|
birthDate > visit
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return visitYear - birthYear - (visitMonth < birthMonth || (visitMonth === birthMonth && visitDay < birthDay) ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isTravelerEligible = (traveler, product, visitDate) => {
|
||||||
|
const ageRule = product?.ageRule
|
||||||
|
|
||||||
|
if (!ageRule) return true
|
||||||
|
if (traveler?.idType !== '身份证') return false
|
||||||
|
|
||||||
|
const age = calculateAge(traveler.idNumber, visitDate)
|
||||||
|
|
||||||
|
return age !== null && age >= ageRule.min && age <= ageRule.max
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateBookingTotals = (product, quantity, insuranceSelected, insurancePrice = 5) => {
|
||||||
|
const safeQuantity = Number(quantity) || 0
|
||||||
|
const productAmount = toMoney(product?.salePrice * safeQuantity)
|
||||||
|
const insuranceAmount = insuranceSelected ? toMoney(insurancePrice * safeQuantity) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
productAmount,
|
||||||
|
insuranceAmount,
|
||||||
|
paidAmount: toMoney(productAmount + insuranceAmount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBookingDraft = (product) => ({
|
||||||
|
visitDate: '',
|
||||||
|
timeSlot: '',
|
||||||
|
quantity: 1,
|
||||||
|
travelerIds: [],
|
||||||
|
contactName: '',
|
||||||
|
phone: '',
|
||||||
|
insuranceSelected: false,
|
||||||
|
insurancePrice: 5,
|
||||||
|
maxQuantity: product?.reservation?.maxQuantity,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validateBooking = ({ product, draft, travelers }) => {
|
||||||
|
if (!draft?.visitDate) return '请选择使用日期'
|
||||||
|
if (!draft.timeSlot) return '请选择使用场次'
|
||||||
|
if (!Number.isInteger(draft.quantity) || draft.quantity < 1 || (draft.maxQuantity && draft.quantity > draft.maxQuantity)) return '购买数量无效'
|
||||||
|
|
||||||
|
const selectedTravelers = [...new Set(draft.travelerIds || [])]
|
||||||
|
.map((id) => travelers.find((traveler) => traveler.id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (selectedTravelers.length < draft.quantity) return '请选择足够数量的出行人'
|
||||||
|
|
||||||
|
for (const traveler of selectedTravelers.slice(0, draft.quantity)) {
|
||||||
|
if (!traveler.name || !traveler.idType || !traveler.idNumber) return '请完善出行人实名信息'
|
||||||
|
if (!isTravelerEligible(traveler, product, draft.visitDate)) return '出行人年龄不符合票种要求'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draft.contactName?.trim() || !/^1[3-9]\d{9}$/.test(draft.phone || '')) return '请填写正确的联系人和手机号'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPaidOrder = ({ product, draft, travelers, now = new Date() }) => {
|
||||||
|
const createdAt = formatTimestamp(now)
|
||||||
|
const orderId = `ORDER-${now.getTime()}`
|
||||||
|
const selectedTravelers = [...new Set(draft.travelerIds || [])]
|
||||||
|
.map((id) => travelers.find((traveler) => traveler.id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, draft.quantity)
|
||||||
|
const totals = calculateBookingTotals(product, draft.quantity, draft.insuranceSelected, draft.insurancePrice)
|
||||||
|
const entitlements = (product.entitlements || []).map((entitlement, index) => ({
|
||||||
|
...entitlement,
|
||||||
|
id: `${entitlement.id || 'entitlement'}-${index + 1}`,
|
||||||
|
sourceId: entitlement.id,
|
||||||
|
quantity: draft.quantity,
|
||||||
|
allocation: toMoney(entitlement.allocation * draft.quantity),
|
||||||
|
status: 'pending',
|
||||||
|
}))
|
||||||
|
const hasGateCredential = entitlements.some((entitlement) => entitlement.third_party)
|
||||||
|
const hasSelfCredential = entitlements.some((entitlement) => entitlement.self)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: orderId,
|
||||||
|
product: { ...product },
|
||||||
|
visitDate: draft.visitDate,
|
||||||
|
timeSlot: draft.timeSlot,
|
||||||
|
quantity: draft.quantity,
|
||||||
|
travelers: selectedTravelers.map((traveler) => ({ ...traveler })),
|
||||||
|
contactName: draft.contactName,
|
||||||
|
phone: draft.phone,
|
||||||
|
insurance: draft.insuranceSelected
|
||||||
|
? { price: toMoney(draft.insurancePrice), amount: totals.insuranceAmount }
|
||||||
|
: undefined,
|
||||||
|
...totals,
|
||||||
|
status: 'paid',
|
||||||
|
refundStatus: '',
|
||||||
|
entitlements,
|
||||||
|
...(hasGateCredential ? { gateCredential: createCredential('GATE', orderId, createdAt) } : {}),
|
||||||
|
...(hasSelfCredential ? { selfCredential: createCredential('SELF', orderId, createdAt) } : {}),
|
||||||
|
createdAt,
|
||||||
|
logs: [{ message: '订单支付成功', createdAt }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateRefundAmount = (order, entitlementIds) => toMoney(
|
||||||
|
(order?.entitlements || [])
|
||||||
|
.filter((entitlement) => entitlement.status === 'pending' && entitlementIds.includes(entitlement.id))
|
||||||
|
.reduce((total, entitlement) => total + Number(entitlement.allocation || 0), 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const applyRefund = (order, entitlementIds, reason, now = new Date()) => {
|
||||||
|
const createdAt = formatTimestamp(now)
|
||||||
|
const selectedIds = new Set(entitlementIds)
|
||||||
|
const refundableIds = order.entitlements
|
||||||
|
.filter((entitlement) => entitlement.status === 'pending' && selectedIds.has(entitlement.id))
|
||||||
|
.map((entitlement) => entitlement.id)
|
||||||
|
const amount = calculateRefundAmount(order, entitlementIds)
|
||||||
|
const entitlements = order.entitlements.map((entitlement) => (
|
||||||
|
entitlement.status === 'pending' && selectedIds.has(entitlement.id)
|
||||||
|
? { ...entitlement, status: 'refunding' }
|
||||||
|
: { ...entitlement }
|
||||||
|
))
|
||||||
|
const refund = {
|
||||||
|
id: `REFUND-${now.getTime()}`,
|
||||||
|
entitlementIds: refundableIds,
|
||||||
|
amount,
|
||||||
|
reason,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
order: {
|
||||||
|
...order,
|
||||||
|
entitlements,
|
||||||
|
status: 'refunding',
|
||||||
|
refundStatus: 'pending',
|
||||||
|
logs: [...(order.logs || []), { message: '已申请退款', createdAt }],
|
||||||
|
},
|
||||||
|
refund,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const popViewHistory = (history) => {
|
||||||
|
if (!history?.length) return { view: TICKET_VIEWS.HOME, history: [] }
|
||||||
|
|
||||||
|
return {
|
||||||
|
view: history[history.length - 1],
|
||||||
|
history: history.slice(0, -1),
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/pages-service/tickets/model.test.mjs
Normal file
165
src/pages-service/tickets/model.test.mjs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
TICKET_VIEWS,
|
||||||
|
applyRefund,
|
||||||
|
calculateAge,
|
||||||
|
calculateBookingTotals,
|
||||||
|
calculateRefundAmount,
|
||||||
|
createBookingDraft,
|
||||||
|
createPaidOrder,
|
||||||
|
isTravelerEligible,
|
||||||
|
popViewHistory,
|
||||||
|
validateBooking,
|
||||||
|
} from './model.mjs'
|
||||||
|
|
||||||
|
const adultProduct = {
|
||||||
|
id: 'adult',
|
||||||
|
name: '成人票',
|
||||||
|
salePrice: 160,
|
||||||
|
reservation: {
|
||||||
|
maxQuantity: 5,
|
||||||
|
},
|
||||||
|
ageRule: {
|
||||||
|
min: 18,
|
||||||
|
max: 59,
|
||||||
|
},
|
||||||
|
entitlements: [
|
||||||
|
{
|
||||||
|
id: 'adult-ticket',
|
||||||
|
name: '成人门票',
|
||||||
|
allocation: 120,
|
||||||
|
third_party: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shuttle',
|
||||||
|
name: '摆渡车',
|
||||||
|
allocation: 40,
|
||||||
|
self: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleTraveler = {
|
||||||
|
id: 't-1',
|
||||||
|
name: '林晓',
|
||||||
|
idType: '身份证',
|
||||||
|
idNumber: '110101199001020010',
|
||||||
|
}
|
||||||
|
|
||||||
|
test('exposes the ticket views', () => {
|
||||||
|
assert.deepEqual(TICKET_VIEWS, {
|
||||||
|
HOME: 'home',
|
||||||
|
DETAIL: 'detail',
|
||||||
|
BOOKING: 'booking',
|
||||||
|
TRAVELERS: 'travelers',
|
||||||
|
PAID: 'paid',
|
||||||
|
ORDERS: 'orders',
|
||||||
|
ORDER_DETAIL: 'orderDetail',
|
||||||
|
REFUND: 'refundApply',
|
||||||
|
REFUND_RESULT: 'refundResult',
|
||||||
|
CREDENTIALS: 'credentials',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calculates age from a mainland ID at the visit date', () => {
|
||||||
|
assert.equal(calculateAge('110101196001010010', '2026-08-01'), 66)
|
||||||
|
assert.equal(calculateAge('110101201001020010', '2026-01-01'), 15)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('checks traveler eligibility against an age rule', () => {
|
||||||
|
const traveler = {
|
||||||
|
idType: '身份证',
|
||||||
|
idNumber: '110101201001020010',
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(isTravelerEligible(traveler, { ageRule: { min: 6, max: 17 } }, '2026-08-01'), true)
|
||||||
|
assert.equal(isTravelerEligible(traveler, { ageRule: { min: 60, max: 120 } }, '2026-08-01'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calculates booking totals including selected insurance', () => {
|
||||||
|
assert.deepEqual(calculateBookingTotals(adultProduct, 2, true, 5), {
|
||||||
|
productAmount: 320,
|
||||||
|
insuranceAmount: 10,
|
||||||
|
paidAmount: 330,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validates booking draft fields and selected travelers in order', () => {
|
||||||
|
const draft = createBookingDraft(adultProduct)
|
||||||
|
const incompleteTraveler = { id: 't-1', idType: '身份证', idNumber: '' }
|
||||||
|
const childTraveler = { id: 't-1', name: '小林', idType: '身份证', idNumber: '110101201001020010' }
|
||||||
|
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft, travelers: [] }), '请选择使用日期')
|
||||||
|
|
||||||
|
const completeDraft = {
|
||||||
|
...draft,
|
||||||
|
visitDate: '2026-08-01',
|
||||||
|
timeSlot: '08:00—09:30',
|
||||||
|
quantity: 1,
|
||||||
|
travelerIds: ['t-1'],
|
||||||
|
contactName: '林晓',
|
||||||
|
phone: '13800138000',
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft: { ...completeDraft, timeSlot: '' }, travelers: [eligibleTraveler] }), '请选择使用场次')
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft: { ...completeDraft, quantity: 0 }, travelers: [eligibleTraveler] }), '购买数量无效')
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft: { ...completeDraft, travelerIds: [] }, travelers: [eligibleTraveler] }), '请选择足够数量的出行人')
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft: { ...completeDraft, quantity: 2, travelerIds: ['t-1', 't-1'] }, travelers: [eligibleTraveler] }), '请选择足够数量的出行人')
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft: completeDraft, travelers: [incompleteTraveler] }), '请完善出行人实名信息')
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft: completeDraft, travelers: [childTraveler] }), '出行人年龄不符合票种要求')
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft: { ...completeDraft, phone: '123' }, travelers: [eligibleTraveler] }), '请填写正确的联系人和手机号')
|
||||||
|
assert.equal(validateBooking({ product: adultProduct, draft: completeDraft, travelers: [eligibleTraveler] }), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a paid order with entitlements and credentials', () => {
|
||||||
|
const order = createPaidOrder({
|
||||||
|
product: adultProduct,
|
||||||
|
draft: {
|
||||||
|
...createBookingDraft(adultProduct),
|
||||||
|
visitDate: '2026-08-01',
|
||||||
|
timeSlot: '08:00—09:30',
|
||||||
|
travelerIds: ['t-1'],
|
||||||
|
contactName: '林晓',
|
||||||
|
phone: '13800138000',
|
||||||
|
insuranceSelected: true,
|
||||||
|
},
|
||||||
|
travelers: [eligibleTraveler],
|
||||||
|
now: new Date('2026-07-30T10:00:00+08:00'),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(order.status, 'paid')
|
||||||
|
assert.equal(order.paidAmount, 165)
|
||||||
|
assert.equal(order.entitlements.length, 2)
|
||||||
|
assert.match(order.gateCredential.qrToken, /^GATE-/)
|
||||||
|
assert.equal(order.logs[0].message, '订单支付成功')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies refunds and pops view history without mutation', () => {
|
||||||
|
const order = createPaidOrder({
|
||||||
|
product: adultProduct,
|
||||||
|
draft: {
|
||||||
|
...createBookingDraft(adultProduct),
|
||||||
|
visitDate: '2026-08-01',
|
||||||
|
timeSlot: '08:00—09:30',
|
||||||
|
travelerIds: ['t-1'],
|
||||||
|
contactName: '林晓',
|
||||||
|
phone: '13800138000',
|
||||||
|
},
|
||||||
|
travelers: [eligibleTraveler],
|
||||||
|
})
|
||||||
|
const entitlementId = order.entitlements[0].id
|
||||||
|
const history = ['home', 'detail', 'booking']
|
||||||
|
|
||||||
|
assert.equal(calculateRefundAmount(order, [entitlementId]), 120)
|
||||||
|
|
||||||
|
const result = applyRefund(order, [entitlementId], '行程有变', new Date('2026-07-30T10:01:00+08:00'))
|
||||||
|
assert.equal(result.order.status, 'refunding')
|
||||||
|
assert.equal(result.refund.amount, 120)
|
||||||
|
assert.equal(result.order.entitlements[0].status, 'refunding')
|
||||||
|
assert.deepEqual(applyRefund(result.order, [entitlementId], '重复申请').refund.entitlementIds, [])
|
||||||
|
assert.deepEqual(popViewHistory(history), { view: 'booking', history: ['home', 'detail'] })
|
||||||
|
assert.deepEqual(history, ['home', 'detail', 'booking'])
|
||||||
|
assert.deepEqual(popViewHistory([]), { view: 'home', history: [] })
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user