实现 M009 手工开票前端页面
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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: 'คิวงานของออเดอร์นี้',
|
||||
|
||||
@@ -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: '同订单任务队列',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
115
client/src/tests/manualInvoiceService.spec.ts
Normal file
115
client/src/tests/manualInvoiceService.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
233
client/src/tests/manualInvoiceView.spec.ts
Normal file
233
client/src/tests/manualInvoiceView.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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()])
|
||||
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
72
client/src/types/manualInvoice.ts
Normal file
72
client/src/types/manualInvoice.ts
Normal 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
|
||||
}
|
||||
1233
client/src/views/reservation/ReservationManualInvoiceView.vue
Normal file
1233
client/src/views/reservation/ReservationManualInvoiceView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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. 前端开发注意事项
|
||||
|
||||
|
||||
@@ -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 预填;从订单进入时,需要明确选择具体任务或提示仅使用订单摘要,避免一个订单多任务时字段来源不清。
|
||||
|
||||
@@ -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 中列为开发前确认项。
|
||||
|
||||
Reference in New Issue
Block a user