实现 M009 手工开票前端页面

This commit is contained in:
andy
2026-07-17 11:57:26 +07:00
parent 11478c6913
commit 72fee44a68
16 changed files with 2032 additions and 3 deletions

View File

@@ -26,6 +26,7 @@ export default {
nav: {
orders: 'Order list',
taskQueue: 'Task queue',
manualInvoice: 'Manual invoice',
sourceMessages: 'Source messages',
systemStatus: 'System status',
orderDetail: 'Order detail',
@@ -250,6 +251,84 @@ export default {
createdAt: 'Created at',
},
},
manualInvoice: {
title: 'Manual invoice',
subtitle: 'Generate a Proforma Invoice PDF by entering fields manually without an order or task.',
sourceBadge: 'Manual generation',
independentHint: 'This V1 page is not linked to an order or task. The backend records generation and audit data.',
documentSection: 'Document',
recipientSection: 'Recipient',
bookingSection: 'Booking',
chargesSection: 'Charges',
previewSection: 'Amount preview',
resultSection: 'Generation result',
invoiceDate: 'Invoice date',
bookingDate: 'Booking date',
dueDate: 'Due date',
companySeed: 'Select company',
contactSeed: 'Select attention',
selectCompany: 'Select company',
selectContact: 'Select attention',
company: 'Company',
attention: 'Attention',
address: 'Address',
telephone: 'Tel',
email: 'Email',
groupName: 'Group name',
arrivalDate: 'Arrival date',
departureDate: 'Departure date',
roomRateNote: 'Room rate note',
extraBedRate: 'Extra bed rate',
description: 'Description',
roomType: 'Room type',
quantity: 'Quantity',
rate: 'Rate',
nights: 'Nights',
amount: 'Amount',
subtotal: 'Sub-total',
vat: 'VAT 7%',
total: 'Total',
addCharge: 'Add charge',
removeCharge: 'Remove',
maxChargesReached: 'V1 supports up to 10 charge lines.',
previewDisclaimer: 'The frontend only previews amounts. Backend totals are authoritative.',
manualOverrideHint: 'Selecting company and attention fills contact details; every text field remains editable.',
generate: 'Generate PDF',
generating: 'Generating',
openPdf: 'Open PDF',
downloadPdf: 'Download PDF',
generationId: 'Generation ID',
generationStatus: 'Status',
pdfUrl: 'PDF URL',
hotelId: 'Hotel ID',
templateCode: 'Template',
createdAt: 'Created at',
noPdfUrl: 'The backend did not return a PDF URL.',
backendTotals: 'Backend totals',
validationTitle: 'Please fix the form',
statuses: {
SUCCEEDED: 'Generated',
GENERATED: 'Generated',
FAILED: 'Failed',
PROCESSING: 'Generating',
},
errors: {
FRONTEND_VALIDATION: 'Form validation failed. Check required fields and charge lines.',
RESERVATION_INVOICE_VALIDATION_FAILED: 'Invoice field validation failed. Please check the form.',
RESERVATION_INVOICE_CONTEXT_MISMATCH: 'The order or task context does not match.',
DOCUMENT_CONVERSION_FAILED: 'PDF generation failed. Please retry or contact an administrator.',
DOCUMENT_CONVERSION_TIMEOUT: 'PDF generation timed out. Please retry later.',
AUTH_401: 'Your session has expired. Please sign in again.',
AUTH_403: 'Your account does not have manual invoice permission.',
UNKNOWN: 'Generation failed. Please retry later.',
requiredFields: 'Complete dates, recipient, booking details, and at least one valid charge line.',
invalidEmail: 'Email format is invalid.',
dueDateBeforeBookingDate: 'Due date cannot be earlier than booking date.',
departureBeforeArrival: 'Departure date must be later than arrival date.',
extraBedNegative: 'Extra bed rate cannot be less than 0.',
chargePositive: 'Quantity, rate, and nights must be greater than 0.',
},
},
order: {
summary: 'Order summary',
taskQueue: 'Order task queue',

View File

@@ -26,6 +26,7 @@ export default {
nav: {
orders: 'รายการออเดอร์',
taskQueue: 'คิวงาน',
manualInvoice: 'ออกใบแจ้งหนี้เอง',
sourceMessages: 'แหล่งที่มาข้อความ',
systemStatus: 'สถานะระบบ',
orderDetail: 'รายละเอียดออเดอร์',
@@ -250,6 +251,84 @@ export default {
createdAt: 'เวลาสร้าง',
},
},
manualInvoice: {
title: 'ออกใบแจ้งหนี้เอง',
subtitle: 'กรอกข้อมูลเองเพื่อสร้าง Proforma Invoice PDF โดยไม่ต้องอ้างอิงออเดอร์หรืองาน',
sourceBadge: 'สร้างด้วยตนเอง',
independentHint: 'หน้า V1 นี้ไม่ผูกกับออเดอร์หรืองาน หลังบ้านจะบันทึกประวัติการสร้างและ audit',
documentSection: 'ข้อมูลเอกสาร',
recipientSection: 'ผู้รับ',
bookingSection: 'ข้อมูลการจอง',
chargesSection: 'รายการค่าใช้จ่าย',
previewSection: 'ตัวอย่างยอดเงิน',
resultSection: 'ผลการสร้าง',
invoiceDate: 'วันที่ Invoice',
bookingDate: 'วันที่ Booking',
dueDate: 'Due Date',
companySeed: 'เลือก Company',
contactSeed: 'เลือก Attention',
selectCompany: 'เลือก Company',
selectContact: 'เลือก Attention',
company: 'Company',
attention: 'Attention',
address: 'Address',
telephone: 'Tel',
email: 'Email',
groupName: 'Group Name',
arrivalDate: 'Arrival Date',
departureDate: 'Departure Date',
roomRateNote: 'Room Rate Note',
extraBedRate: 'Extra Bed Rate',
description: 'Description',
roomType: 'Room Type',
quantity: 'Quantity',
rate: 'Rate',
nights: 'Nights',
amount: 'Amount',
subtotal: 'Sub-Total',
vat: 'VAT 7%',
total: 'Total',
addCharge: 'เพิ่มรายการ',
removeCharge: 'ลบ',
maxChargesReached: 'V1 รองรับได้สูงสุด 10 รายการค่าใช้จ่าย',
previewDisclaimer: 'ยอดนี้เป็นเพียงตัวอย่าง ยอดสุดท้ายให้ยึด totals จากหลังบ้าน',
manualOverrideHint: 'เลือก Company / Attention แล้วระบบจะกรอกข้อมูลให้ แต่ยังแก้ไขข้อความได้ทั้งหมด',
generate: 'สร้าง PDF',
generating: 'กำลังสร้าง',
openPdf: 'เปิด PDF',
downloadPdf: 'ดาวน์โหลด PDF',
generationId: 'รหัสการสร้าง',
generationStatus: 'สถานะ',
pdfUrl: 'ลิงก์ PDF',
hotelId: 'รหัสโรงแรม',
templateCode: 'เทมเพลต',
createdAt: 'เวลาสร้าง',
noPdfUrl: 'หลังบ้านไม่ได้ส่งลิงก์ PDF กลับมา',
backendTotals: 'ยอดจากหลังบ้าน',
validationTitle: 'กรุณาแก้ไขแบบฟอร์ม',
statuses: {
SUCCEEDED: 'สร้างแล้ว',
GENERATED: 'สร้างแล้ว',
FAILED: 'สร้างไม่สำเร็จ',
PROCESSING: 'กำลังสร้าง',
},
errors: {
FRONTEND_VALIDATION: 'ตรวจสอบแบบฟอร์มไม่ผ่าน กรุณาตรวจฟิลด์ที่จำเป็นและรายการค่าใช้จ่าย',
RESERVATION_INVOICE_VALIDATION_FAILED: 'ตรวจสอบข้อมูล Invoice ไม่ผ่าน กรุณาตรวจแบบฟอร์ม',
RESERVATION_INVOICE_CONTEXT_MISMATCH: 'ออเดอร์หรืองานไม่ตรงกัน',
DOCUMENT_CONVERSION_FAILED: 'สร้าง PDF ไม่สำเร็จ กรุณาลองอีกครั้งหรือติดต่อผู้ดูแล',
DOCUMENT_CONVERSION_TIMEOUT: 'สร้าง PDF หมดเวลา กรุณาลองใหม่ภายหลัง',
AUTH_401: 'เซสชันหมดอายุ กรุณาเข้าสู่ระบบใหม่',
AUTH_403: 'บัญชีนี้ไม่มีสิทธิ์ออกใบแจ้งหนี้เอง',
UNKNOWN: 'สร้างไม่สำเร็จ กรุณาลองอีกครั้ง',
requiredFields: 'กรุณากรอกวันที่ ผู้รับ ข้อมูลการจอง และอย่างน้อยหนึ่งรายการค่าใช้จ่ายที่ถูกต้อง',
invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง',
dueDateBeforeBookingDate: 'Due Date ต้องไม่เร็วกว่าวันที่ Booking',
departureBeforeArrival: 'Departure Date ต้องหลัง Arrival Date',
extraBedNegative: 'Extra Bed Rate ต้องไม่ต่ำกว่า 0',
chargePositive: 'Quantity, Rate และ Nights ต้องมากกว่า 0',
},
},
order: {
summary: 'สรุปออเดอร์',
taskQueue: 'คิวงานของออเดอร์นี้',

View File

@@ -26,6 +26,7 @@ export default {
nav: {
orders: '订单列表',
taskQueue: '任务队列',
manualInvoice: '手工开票',
sourceMessages: '消息来源',
systemStatus: '系统状态',
orderDetail: '订单详情',
@@ -250,6 +251,84 @@ export default {
createdAt: '创建时间',
},
},
manualInvoice: {
title: '手工开票',
subtitle: '无订单、无任务时,手工填写 Proforma Invoice 字段并生成 PDF。',
sourceBadge: '手工生成',
independentHint: '本页第一版不关联订单或任务,生成记录和审计由后端统一落库。',
documentSection: '单据信息',
recipientSection: '收件方',
bookingSection: '预订信息',
chargesSection: '费用明细',
previewSection: '金额预览',
resultSection: '生成结果',
invoiceDate: '开票日期Invoice Date',
bookingDate: '预订日期Booking Date',
dueDate: '付款截止日期Due Date',
companySeed: '选择公司Company',
contactSeed: '选择收件人Attention',
selectCompany: '请选择公司',
selectContact: '请选择收件人',
company: '公司Company',
attention: '收件人Attention',
address: '地址Address',
telephone: 'Tel',
email: 'Email',
groupName: '团队名称Group Name',
arrivalDate: '到店日期Arrival Date',
departureDate: '离店日期Departure Date',
roomRateNote: '房价备注Room Rate Note',
extraBedRate: '加床价格Extra Bed Rate',
description: '费用描述Description',
roomType: '房型Room Type',
quantity: '数量Quantity',
rate: '单价Rate',
nights: '晚数Nights',
amount: 'Amount',
subtotal: 'Sub-Total',
vat: 'VAT 7%',
total: 'Total',
addCharge: '新增费用',
removeCharge: '删除',
maxChargesReached: '第一版最多支持 10 条费用明细。',
previewDisclaimer: '前端仅做金额预览,最终金额以后端返回 totals 为准。',
manualOverrideHint: '选择 Company / Attention 后会自动带出资料,带出的文本仍可手工覆盖。',
generate: '生成 PDF',
generating: '生成中',
openPdf: '打开 PDF',
downloadPdf: '下载 PDF',
generationId: '生成记录 ID',
generationStatus: '生成状态',
pdfUrl: 'PDF 链接',
hotelId: '酒店 ID',
templateCode: '模板',
createdAt: '创建时间',
noPdfUrl: '后端未返回 PDF 链接。',
backendTotals: '后端返回金额',
validationTitle: '请先修正表单',
statuses: {
SUCCEEDED: '已生成',
GENERATED: '已生成',
FAILED: '生成失败',
PROCESSING: '生成中',
},
errors: {
FRONTEND_VALIDATION: '表单校验未通过,请检查必填字段和费用明细。',
RESERVATION_INVOICE_VALIDATION_FAILED: 'Invoice 字段校验失败,请检查表单内容。',
RESERVATION_INVOICE_CONTEXT_MISMATCH: '订单或任务上下文不匹配。',
DOCUMENT_CONVERSION_FAILED: 'PDF 生成失败,请稍后重试或联系管理员。',
DOCUMENT_CONVERSION_TIMEOUT: 'PDF 生成超时,请稍后重试。',
AUTH_401: '登录已失效,请重新登录后再生成。',
AUTH_403: '当前账号没有手工开票权限。',
UNKNOWN: '生成失败,请稍后重试。',
requiredFields: '请补充日期、收件方、预订信息和至少一条有效费用明细。',
invalidEmail: 'Email 格式不正确。',
dueDateBeforeBookingDate: 'Due Date 不能早于 Booking 日期。',
departureBeforeArrival: 'Departure Date 必须晚于 Arrival Date。',
extraBedNegative: '加床价格不能小于 0。',
chargePositive: '费用明细的 Quantity、Rate、Nights 必须大于 0。',
},
},
order: {
summary: '订单摘要',
taskQueue: '同订单任务队列',

View File

@@ -173,6 +173,8 @@ const localeOptions: Array<{ value: SupportedLocale; label: string }> = [
const menuLabelKeys: Record<string, string> = {
RESERVATION_ORDERS: 'nav.orders',
RESERVATION_TASKS: 'nav.taskQueue',
RESERVATION_MANUAL_INVOICE: 'nav.manualInvoice',
RESERVATION_INVOICES: 'nav.manualInvoice',
DEBUG_EML_SUPERAGENT: 'nav.debugEml',
SYSTEM_SETTINGS: 'nav.systemSettings',
}

View File

@@ -62,6 +62,15 @@ export const router = createRouter({
permission: 'RESERVATION_TASK_READ',
},
},
{
path: '/reservation/invoices/new',
name: 'reservation-manual-invoice',
component: () => import('@/views/reservation/ReservationManualInvoiceView.vue'),
meta: {
titleKey: 'nav.manualInvoice',
permission: 'RESERVATION_INVOICE_GENERATE',
},
},
{
path: '/reservation/orders/:orderId',
name: 'reservation-order-detail',

View File

@@ -4,6 +4,10 @@ import {
} from '@/fixtures/reservation'
import { getReservationHotelId } from '@/config/reservationConfig'
import { getJson, sendJson } from '@/services/httpClient'
import type {
ManualInvoiceGenerationRequest,
ManualInvoiceGenerationResult,
} from '@/types/manualInvoice'
import type {
ReservationManualReviewConversionRequest,
ReservationManualReviewConversionResult,
@@ -150,6 +154,12 @@ export async function fetchReservationTaskAudits(taskId: string): Promise<Reserv
return getJson<ReservationTaskAuditListResult>(`/api/reservation/tasks/${taskId}/audits`)
}
export async function generateManualReservationInvoice(
request: ManualInvoiceGenerationRequest,
): Promise<ManualInvoiceGenerationResult> {
return sendJson<ManualInvoiceGenerationResult>('/api/reservation/invoices/manual-generations', 'POST', request)
}
function withQuery(path: string, params: object): string {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearStoredAccessToken, setStoredAccessToken } from '@/services/authSession'
import { generateManualReservationInvoice } from '@/services/reservationService'
import type { ManualInvoiceGenerationRequest, ManualInvoiceGenerationResult } from '@/types/manualInvoice'
const jsonHeaders = {
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null),
},
}
function mockJsonResponse(payload: unknown): Response {
return {
ok: true,
status: 200,
json: async () => payload,
text: async () => JSON.stringify(payload),
...jsonHeaders,
} as Response
}
function createManualInvoiceRequest(): ManualInvoiceGenerationRequest {
return {
hotel_id: 'HOTEL-TEST',
source_type: 'MANUAL',
task_id: null,
order_id: null,
template_code: 'PROFORMA_INVOICE_V1',
invoice_payload: {
document: {
invoice_date: '2026-07-17',
booking_date: '2026-07-17',
due_date: '2026-07-24',
},
recipient: {
company_code: 'QBD',
contact_id: 'QBD_JITDANUN_PANAPHUCHONG',
company: 'Q.B.D. TRAVEL GROUP CO., LTD',
attention: 'Jitdanun Panaphuchong',
address: '2/90 Rajpattana,Rajpattana,Sapansoong, Bangkok, TH, 10240',
telephone: '089-032 0176',
email: 'op.qbdtravel@gmail.com',
},
booking: {
group_name: 'LLT260509FA213',
arrival_date: '2026-07-20',
departure_date: '2026-07-22',
room_rate_note: 'Inclusive breakfast',
extra_bed_rate: 900,
},
charges: [
{
description: '2N TWN +1 DBL',
room_type: 'TWN/DBL',
quantity: 3,
rate: 2500,
nights: 2,
},
],
},
}
}
function createManualInvoiceResult(): ManualInvoiceGenerationResult {
return {
invoice_generation_id: '91001',
generation_status: 'GENERATED',
source_type: 'MANUAL',
hotel_id: 'HOTEL-TEST',
order_id: null,
task_id: null,
template_code: 'PROFORMA_INVOICE_V1',
pdf_url: 'https://oss.example/invoices/manual-91001.pdf',
pdf_object_key: 'reservation/invoices/manual-91001.pdf',
generated_excel_object_key: 'reservation/invoices/manual-91001.xlsx',
totals: {
subtotal: 14018.69,
vat: 981.31,
total: 15000,
currency: 'THB',
},
created_at: '2026-07-17T08:00:00Z',
}
}
describe('manual invoice reservation service', () => {
beforeEach(() => {
vi.restoreAllMocks()
clearStoredAccessToken()
})
it('posts manual invoice payload to the M009 business endpoint with Bearer auth', async () => {
const request = createManualInvoiceRequest()
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse(createManualInvoiceResult()))
setStoredAccessToken('session-token')
const result = await generateManualReservationInvoice(request)
expect(fetchMock).toHaveBeenCalledWith(
'/api/reservation/invoices/manual-generations',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Accept: 'application/json',
Authorization: 'Bearer session-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
}),
)
expect(fetchMock.mock.calls[0]?.[0]).not.toContain('/api/system/document-conversions/excel-to-pdf')
expect(result.pdf_url).toBe('https://oss.example/invoices/manual-91001.pdf')
})
})

View File

@@ -0,0 +1,233 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import zhCN from '@/i18n/locales/zh-CN'
import { ApiError } from '@/services/httpClient'
import { useAuthStore } from '@/stores/authStore'
import type { ManualInvoiceGenerationResult } from '@/types/manualInvoice'
import ReservationManualInvoiceView from '@/views/reservation/ReservationManualInvoiceView.vue'
vi.mock('@/services/reservationService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/reservationService')>()
return {
...actual,
generateManualReservationInvoice: vi.fn(),
}
})
const service = await import('@/services/reservationService')
function createResult(): ManualInvoiceGenerationResult {
return {
invoice_generation_id: '91001',
generation_status: 'GENERATED',
source_type: 'MANUAL',
hotel_id: 'HOTEL-TEST',
order_id: null,
task_id: null,
template_code: 'PROFORMA_INVOICE_V1',
pdf_url: 'https://oss.example/invoices/manual-91001.pdf',
pdf_object_key: 'reservation/invoices/manual-91001.pdf',
generated_excel_object_key: 'reservation/invoices/manual-91001.xlsx',
totals: {
subtotal: 14018.69,
vat: 981.31,
total: 15000,
currency: 'THB',
},
created_at: '2026-07-17T08:00:00Z',
}
}
function mountView(options: { timeZone?: string } = {}) {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().applyLoginResult({
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-18T00:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: options.timeZone ?? 'Asia/Bangkok',
default_hotel: true,
},
],
permissions: ['RESERVATION_INVOICE_GENERATE'],
menus: [],
})
return mount(ReservationManualInvoiceView, {
global: {
plugins: [i18n, pinia],
},
})
}
async function fillMinimumInvoiceForm(wrapper: ReturnType<typeof mountView>) {
await wrapper.find('[data-testid="document-invoice-date"]').setValue('2026-07-17')
await wrapper.find('[data-testid="document-booking-date"]').setValue('2026-07-17')
await wrapper.find('[data-testid="document-due-date"]').setValue('2026-07-24')
await wrapper.find('[data-testid="booking-group-name"]').setValue('LLT260509FA213')
await wrapper.find('[data-testid="booking-arrival-date"]').setValue('2026-07-20')
await wrapper.find('[data-testid="booking-departure-date"]').setValue('2026-07-22')
await wrapper.find('[data-testid="booking-room-rate-note"]').setValue('Inclusive breakfast')
await wrapper.find('[data-testid="booking-extra-bed-rate"]').setValue('900')
await wrapper.find('[data-testid="charge-description-0"]').setValue('2N TWN +1 DBL')
await wrapper.find('[data-testid="charge-room-type-0"]').setValue('TWN/DBL')
await wrapper.find('[data-testid="charge-quantity-0"]').setValue('3')
await wrapper.find('[data-testid="charge-rate-0"]').setValue('2500')
await wrapper.find('[data-testid="charge-nights-0"]').setValue('2')
}
describe('ReservationManualInvoiceView', () => {
beforeEach(() => {
vi.mocked(service.generateManualReservationInvoice).mockReset()
sessionStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
it('defaults document dates in the selected hotel timezone', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-17T12:30:00Z'))
const wrapper = mountView({ timeZone: 'Pacific/Kiritimati' })
expect((wrapper.find('[data-testid="document-invoice-date"]').element as HTMLInputElement).value).toBe(
'2026-07-18',
)
expect((wrapper.find('[data-testid="document-booking-date"]').element as HTMLInputElement).value).toBe(
'2026-07-18',
)
expect((wrapper.find('[data-testid="document-due-date"]').element as HTMLInputElement).value).toBe('2026-07-25')
})
it('links company and attention seed data while allowing manual overrides before submit', async () => {
vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult())
const wrapper = mountView()
await wrapper.find('[data-testid="recipient-company-code"]').setValue('HANATOUR')
await wrapper.find('[data-testid="recipient-contact-id"]').setValue('HANATOUR_BOLAM_JO')
expect((wrapper.find('[data-testid="recipient-telephone"]').element as HTMLInputElement).value).toBe(
'82 010 4182 4615',
)
await wrapper.find('[data-testid="recipient-email"]').setValue('custom@example.test')
await fillMinimumInvoiceForm(wrapper)
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
await flushPromises()
expect(service.generateManualReservationInvoice).toHaveBeenCalledWith(
expect.objectContaining({
hotel_id: 'HOTEL-TEST',
source_type: 'MANUAL',
task_id: null,
order_id: null,
template_code: 'PROFORMA_INVOICE_V1',
invoice_payload: expect.objectContaining({
recipient: expect.objectContaining({
company_code: 'HANATOUR',
contact_id: 'HANATOUR_BOLAM_JO',
attention: 'Bolam Jo',
email: 'custom@example.test',
}),
charges: [
expect.objectContaining({
description: '2N TWN +1 DBL',
quantity: 3,
rate: 2500,
nights: 2,
}),
],
}),
}),
)
expect(wrapper.text()).toContain('91001')
expect(wrapper.find('a[href="https://oss.example/invoices/manual-91001.pdf"]').exists()).toBe(true)
})
it('submits complete numeric input values instead of parseFloat prefixes', async () => {
vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult())
const wrapper = mountView()
await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD')
await fillMinimumInvoiceForm(wrapper)
await wrapper.find('[data-testid="charge-rate-0"]').setValue('1e3')
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
await flushPromises()
expect(service.generateManualReservationInvoice).toHaveBeenCalledWith(
expect.objectContaining({
invoice_payload: expect.objectContaining({
charges: [
expect.objectContaining({
rate: 1000,
}),
],
}),
}),
)
})
it('limits manual charge lines to ten rows', async () => {
const wrapper = mountView()
const addButton = () => wrapper.find('[data-testid="manual-invoice-add-charge"]')
for (let index = 1; index < 10; index += 1) {
await addButton().trigger('click')
}
expect(wrapper.findAll('[data-testid^="charge-row-"]')).toHaveLength(10)
expect(addButton().attributes('disabled')).toBeDefined()
})
it('shows a friendly backend error for document conversion timeout', async () => {
vi.mocked(service.generateManualReservationInvoice).mockRejectedValue(
new ApiError('Request failed with status 500', 500, {
error_code: 'DOCUMENT_CONVERSION_TIMEOUT',
message: 'Document conversion timeout.',
}),
)
const wrapper = mountView()
await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD')
await fillMinimumInvoiceForm(wrapper)
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('PDF 生成超时')
})
it('blocks negative extra bed rate before calling the backend', async () => {
const wrapper = mountView()
await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD')
await fillMinimumInvoiceForm(wrapper)
await wrapper.find('[data-testid="booking-extra-bed-rate"]').setValue('-1')
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
await flushPromises()
expect(service.generateManualReservationInvoice).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('加床价格不能小于 0')
})
})

View File

@@ -79,6 +79,18 @@ function createDebugMenu(): AuthMenuResult {
}
}
function createManualInvoiceMenu(): AuthMenuResult {
return {
menu_code: 'RESERVATION_MANUAL_INVOICE',
menu_name: '手工开票',
route_path: '/reservation/invoices/new',
component_key: 'ReservationManualInvoice',
icon_key: 'pi pi-file-pdf',
permission_code: 'RESERVATION_INVOICE_GENERATE',
sort_order: 25,
}
}
function createSystemMenu(): AuthMenuResult {
return {
menu_code: 'SYSTEM_SETTINGS',
@@ -120,6 +132,11 @@ async function mountShell(menus = [createReservationOrdersMenu(), createReservat
component: { template: '<div />' },
meta: { titleKey: 'debugEml.title' },
},
{
path: '/reservation/invoices/new',
component: { template: '<div />' },
meta: { titleKey: 'nav.manualInvoice' },
},
{
path: '/system',
component: { template: '<div />' },
@@ -175,6 +192,13 @@ describe('ReservationAppShell', () => {
expect(wrapper.text()).toContain('Debug EML')
})
it('shows Manual Invoice only when backend menus include it', async () => {
const wrapper = await mountShell([createReservationOrdersMenu(), createManualInvoiceMenu()])
expect(wrapper.find('a[href="/reservation/invoices/new"]').exists()).toBe(true)
expect(wrapper.text()).toContain('手工开票')
})
it('shows System Settings only when backend menus include it', async () => {
const wrapper = await mountShell([createReservationOrdersMenu(), createSystemMenu()])

View File

@@ -42,6 +42,7 @@ describe('reservation router', () => {
expect(router.resolve('/login').name).toBe('login')
expect(router.resolve('/reservation/orders').name).toBe('reservation-order-list')
expect(router.resolve('/reservation/tasks').name).toBe('reservation-task-list')
expect(router.resolve('/reservation/invoices/new').name).toBe('reservation-manual-invoice')
expect(router.resolve('/reservation/orders/20001').name).toBe('reservation-order-detail')
expect(router.resolve('/reservation/tasks/10001').name).toBe('reservation-task-detail')
expect(router.resolve('/reservation/source-messages/30001/conversation').name).toBe(
@@ -67,6 +68,7 @@ describe('reservation router', () => {
expect(router.resolve('/reservation/orders/20001').meta.permission).toBe('RESERVATION_ORDER_READ')
expect(router.resolve('/reservation/tasks').meta.permission).toBe('RESERVATION_TASK_READ')
expect(router.resolve('/reservation/tasks/10001').meta.permission).toBe('RESERVATION_TASK_READ')
expect(router.resolve('/reservation/invoices/new').meta.permission).toBe('RESERVATION_INVOICE_GENERATE')
expect(router.resolve('/reservation/source-messages/30001/conversation').meta.permission).toBe(
'SOURCE_MESSAGE_ORIGINAL_READ',
)

View File

@@ -7,6 +7,7 @@ export type AuthPermissionCode =
| 'RESERVATION_TASK_CONFIRM'
| 'RESERVATION_OPERA_SIM_EXECUTE'
| 'RESERVATION_AUDIT_READ'
| 'RESERVATION_INVOICE_GENERATE'
| 'HOTEL_SWITCH'
| 'SYSTEM_AUTH_READ'
| 'SYSTEM_ADMIN_CONSOLE_ACCESS'

View File

@@ -0,0 +1,72 @@
export type ManualInvoiceSourceType = 'MANUAL'
export type ManualInvoiceTemplateCode = 'PROFORMA_INVOICE_V1'
export interface ManualInvoiceDocumentPayload {
invoice_date: string
booking_date: string
due_date: string
}
export interface ManualInvoiceRecipientPayload {
company_code: string | null
contact_id: string | null
company: string
attention: string
address: string
telephone: string
email: string
}
export interface ManualInvoiceBookingPayload {
group_name: string
arrival_date: string
departure_date: string
room_rate_note: string | null
extra_bed_rate: number | null
}
export interface ManualInvoiceChargePayload {
description: string
room_type: string
quantity: number
rate: number
nights: number
}
export interface ManualInvoicePayload {
document: ManualInvoiceDocumentPayload
recipient: ManualInvoiceRecipientPayload
booking: ManualInvoiceBookingPayload
charges: ManualInvoiceChargePayload[]
}
export interface ManualInvoiceGenerationRequest {
hotel_id?: string
source_type: ManualInvoiceSourceType
task_id: null
order_id: null
template_code: ManualInvoiceTemplateCode
invoice_payload: ManualInvoicePayload
}
export interface ManualInvoiceTotalsResult {
subtotal: number
vat: number
total: number
currency: string
}
export interface ManualInvoiceGenerationResult {
invoice_generation_id: string
generation_status: string
source_type: ManualInvoiceSourceType
hotel_id: string
order_id: string | null
task_id: string | null
template_code: ManualInvoiceTemplateCode
pdf_url: string | null
pdf_object_key: string | null
generated_excel_object_key: string | null
totals: ManualInvoiceTotalsResult
created_at: string
}

File diff suppressed because it is too large Load Diff

View File

@@ -60,7 +60,7 @@
- 用户 / 权限底座后端 CP1 已完成;前端登录页、动态菜单、管理后台和业务审计 actor 全量迁移仍后置。
- 真实 OPERA / OHIP 接入后置;当前仅有 OPERA 模拟骨架。
- 任务卡字段控件契约 V1 后端第一版已完成,任务详情 `fields[]` 已返回 `control_type/edit_scope/write_target/options_source/raw_readonly/control_hint`;前端后续按契约接入,不要硬编码 PMS 房型、Rate Code 或未冻结枚举。
- Manual Invoice 第一阶段按 M009 推进:后端已提供 `POST /api/reservation/invoices/manual-generations`,页面可以不依赖订单或任务,用户手工填写 / 选择字段后由后端业务接口填充 Excel 模板并生成 PDF前端不得直接调用 M008 的调试上传转换接口来完成业务开票。
- Manual Invoice 第一阶段按 M009 推进:后端已提供 `POST /api/reservation/invoices/manual-generations`前端已新增 `/reservation/invoices/new` 手工开票页面,页面可以不依赖订单或任务,用户手工填写 / 选择字段后由后端业务接口填充 Excel 模板并生成 PDF前端不得直接调用 M008 的调试上传转换接口来完成业务开票。侧边栏入口仍以登录后端返回的 menus 为准,建议后续在菜单管理中配置 `RESERVATION_MANUAL_INVOICE` / `/reservation/invoices/new` / `RESERVATION_INVOICE_GENERATE`
## 6. 前端开发注意事项

View File

@@ -384,6 +384,9 @@ M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填
前端注意:
- 当前 `invoice.html` 可以作为交互原型和控件参考,但不能直接作为生产页面上线。
- 前端 V1 已新增 `/reservation/invoices/new` 页面,按 `RESERVATION_INVOICE_GENERATE` 路由权限保护,使用登录态 Bearer token 调用正式业务接口。
- 页面已支持无订单 / 无任务独立生成,固定提交 `source_type=MANUAL``order_id=null``task_id=null`;暂不做订单 / 任务自动预填。
- 侧边栏入口不由前端硬编码公开,仍依赖后端登录态 `menus[]`。如需菜单中展示,建议菜单管理配置 `menu_code=RESERVATION_MANUAL_INVOICE``route_path=/reservation/invoices/new``permission_code=RESERVATION_INVOICE_GENERATE`
- 第一阶段入口建议是独立页面,例如 `/reservation/invoices/new``/invoices/new`;不要求必须从任务详情或订单详情进入。
- 无订单 / 无任务时,页面按 `source_type=MANUAL` 提交,`task_id``order_id` 可以为空。
- 从任务进入时,后续可以使用 `GET /api/reservation/tasks/{taskId}``fields[]`、草稿或确认 payload 预填;从订单进入时,需要明确选择具体任务或提示仅使用订单摘要,避免一个订单多任务时字段来源不清。

View File

@@ -931,7 +931,94 @@ GET /api/reservation/task-card-field-whitelist
}
```
## 12. 已确认后置接口
## 12. Manual Invoice 手工开票生成接口
M009 后端 CP2 已实现,前端 V1 已接入 `/reservation/invoices/new`,可以在无订单 / 无任务数据时独立生成 Proforma Invoice。该接口是正式业务开票生成入口不走 M008 调试上传 access key。
路径:
```text
POST /api/reservation/invoices/manual-generations
```
入参:
```json
{
"hotel_id": "HOTEL-TEST",
"source_type": "MANUAL",
"task_id": null,
"order_id": null,
"template_code": "PROFORMA_INVOICE_V1",
"invoice_payload": {
"document": {
"invoice_date": "2026-07-17",
"booking_date": "2026-07-12",
"due_date": "2026-07-22"
},
"recipient": {
"company_code": "LIAN_TAI",
"contact_id": "LIAN_TAI_KHUN_ANN",
"company": "LIAN TAI TRAVEL (THAILAND) CO., LTD.",
"attention": "Khun Ann",
"address": "2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240",
"telephone": "061-397-2675",
"email": "op.liantaitravel@gmail.com"
},
"booking": {
"group_name": "GRP-DEMO-0802",
"arrival_date": "2026-08-02",
"departure_date": "2026-08-05",
"room_rate_note": "includingBF",
"extra_bed_rate": 1200
},
"charges": [
{
"description": "GRP-DEMO-0802",
"room_type": "Deluxe Room",
"quantity": 2,
"rate": 3000,
"nights": 3
}
]
}
}
```
返参:
```json
{
"invoice_generation_id": "2080000000000000001",
"generation_status": "SUCCEEDED",
"pdf_url": "https://oss.example.test/reservation-invoices/HOTEL-TEST/2026-07-17/.../proforma-invoice.pdf",
"pdf_object_key": "reservation-invoices/HOTEL-TEST/2026-07-17/.../proforma-invoice.pdf",
"generated_excel_object_key": "reservation-invoices/HOTEL-TEST/2026-07-17/.../proforma-invoice.xlsx",
"totals": {
"subtotal": 16822.43,
"vat": 1177.57,
"total": 18000.00,
"currency": "THB"
},
"created_at": "2026-07-17T03:30:00Z"
}
```
前端诉求:
- 前端已按 `RESERVATION_INVOICE_GENERATE` 做路由权限保护;侧边栏是否展示仍取决于登录态后端返回的 `menus[]`
- 如需在菜单中展示,建议后端 / 管理员配置 `menu_code=RESERVATION_MANUAL_INVOICE``route_path=/reservation/invoices/new``permission_code=RESERVATION_INVOICE_GENERATE`
- 该接口必须支持 `source_type=MANUAL``task_id` / `order_id` 为空。
- 如果前端同时传 `task_id``order_id`,必须保证任务属于该订单;后端不一致时返回 `RESERVATION_INVOICE_CONTEXT_MISMATCH`
- `recipient.company_code``recipient.contact_id` 用于表达目录选择结果;`company``attention``address``telephone``email` 是最终用于生成 PDF 的文本值Manual 覆盖时也必须提交。
- 后端需要重新计算金额、VAT 和合计;前端计算只做预览。
- 后端需要返回可预览 / 下载的 PDF URL。
- 该接口应走 Bearer 登录、酒店访问权和 `RESERVATION_INVOICE_GENERATE` 权限,不走 M008 调试上传 access key。
- 第一版最多支持 10 条 `charges[]`;超过 10 条会返回 `RESERVATION_INVOICE_VALIDATION_FAILED`
- 第一版只支持 `template_code=PROFORMA_INVOICE_V1`,模板文件由后端受控维护。
- 第一版已写入 `workflow_reservation_invoice_generation` 生成记录和业务审计,但暂不提供前端查询历史列表 / 详情接口。
## 13. 已确认后置接口
普通任务切换订单接口继续后置,前端暂不开发提交能力。后续如果恢复开发,建议另行确认:
@@ -960,7 +1047,7 @@ POST /api/reservation/tasks/{taskId}/order-binding
}
```
## 13. 待确认问题
## 14. 待确认问题
- 订单列表、任务列表当前统一使用 `items + page` 分页结构;邮件会话详情不分页,返回同一会话全部邮件。
- 邮件会话详情接口已优先使用 `GET /api/source-messages/{sourceMessageId}/conversation`
@@ -970,3 +1057,4 @@ POST /api/reservation/tasks/{taskId}/order-binding
- `GET /api/reservation/tasks` 结构化 S10/S99 行的 `task_type` 返回值请后端最终确认:前端已兼容 `SOURCE_MESSAGE_ONLY``MESSAGE_NOTIFICATION`,但文档口径最好稳定一个。
- `manual-review-resolutions` 成功响应中的 `opera_operations[]` 数量请后端最终确认;前端不写死两条,只按返回内容刷新展示。
- 系统管理菜单树增强接口已完成:`GET /api/admin/menus/tree``PUT /api/admin/menus/tree-order`
- Manual Invoice 第一阶段的客户 / 联系人目录来源、模板初始文件、VAT 配置和生成记录是否必须落库,已在 M009 中列为开发前确认项。