feat: add ticket booking domain model

This commit is contained in:
2026-07-30 15:54:13 +08:00
parent 9487dad416
commit 578e186746
2 changed files with 366 additions and 0 deletions

View 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),
}
}

View 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: [] })
})