From 578e18674633efca53d2f7f978ad44f22be3581b Mon Sep 17 00:00:00 2001 From: zoujing Date: Thu, 30 Jul 2026 15:54:13 +0800 Subject: [PATCH] feat: add ticket booking domain model --- src/pages-service/tickets/model.mjs | 201 +++++++++++++++++++++++ src/pages-service/tickets/model.test.mjs | 165 +++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 src/pages-service/tickets/model.mjs create mode 100644 src/pages-service/tickets/model.test.mjs diff --git a/src/pages-service/tickets/model.mjs b/src/pages-service/tickets/model.mjs new file mode 100644 index 0000000..3928e82 --- /dev/null +++ b/src/pages-service/tickets/model.mjs @@ -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), + } +} diff --git a/src/pages-service/tickets/model.test.mjs b/src/pages-service/tickets/model.test.mjs new file mode 100644 index 0000000..3f87eec --- /dev/null +++ b/src/pages-service/tickets/model.test.mjs @@ -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: [] }) +})