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