Compare commits

...

2 Commits

Author SHA1 Message Date
andy
7dfaea7245 完善手工开票页面文案国际化 2026-07-17 14:52:36 +07:00
andy
94484dbf14 对齐手工开票页面原型 2026-07-17 14:23:05 +07:00
8 changed files with 1249 additions and 546 deletions

View File

@@ -253,6 +253,7 @@ export default {
}, },
manualInvoice: { manualInvoice: {
title: 'Manual invoice', title: 'Manual invoice',
createTitle: 'Create Invoice',
subtitle: 'Generate a Proforma Invoice PDF by entering fields manually without an order or task.', subtitle: 'Generate a Proforma Invoice PDF by entering fields manually without an order or task.',
sourceBadge: 'Manual generation', sourceBadge: 'Manual generation',
independentHint: 'This V1 page is not linked to an order or task. The backend records generation and audit data.', independentHint: 'This V1 page is not linked to an order or task. The backend records generation and audit data.',
@@ -272,12 +273,16 @@ export default {
company: 'Company', company: 'Company',
attention: 'Attention', attention: 'Attention',
address: 'Address', address: 'Address',
companyInputPlaceholder: 'Enter company',
attentionInputPlaceholder: 'Enter attention',
addressInputPlaceholder: 'Enter full billing address',
telephone: 'Tel', telephone: 'Tel',
email: 'Email', email: 'Email',
groupName: 'Group name', groupName: 'Group name',
arrivalDate: 'Arrival date', arrivalDate: 'Arrival date',
departureDate: 'Departure date', departureDate: 'Departure date',
roomRateNote: 'Room rate note', roomRateNote: 'Room rate note',
roomRateNotePlaceholder: 'e.g. including BF',
extraBedRate: 'Extra bed rate', extraBedRate: 'Extra bed rate',
description: 'Description', description: 'Description',
roomType: 'Room type', roomType: 'Room type',
@@ -288,12 +293,13 @@ export default {
subtotal: 'Sub-total', subtotal: 'Sub-total',
vat: 'VAT 7%', vat: 'VAT 7%',
total: 'Total', total: 'Total',
addCharge: 'Add charge', addCharge: 'Add row',
removeCharge: 'Remove', removeCharge: 'Remove',
removeChargeLineAria: 'Remove row {index}',
maxChargesReached: 'V1 supports up to 10 charge lines.', maxChargesReached: 'V1 supports up to 10 charge lines.',
previewDisclaimer: 'The frontend only previews amounts. Backend totals are authoritative.', previewDisclaimer: 'The frontend only previews amounts. Backend totals are authoritative.',
manualOverrideHint: 'Selecting company and attention fills contact details; every text field remains editable.', manualOverrideHint: 'Selecting company and attention fills contact details; every text field remains editable.',
generate: 'Generate PDF', generate: 'Confirm and generate PDF',
generating: 'Generating', generating: 'Generating',
openPdf: 'Open PDF', openPdf: 'Open PDF',
downloadPdf: 'Download PDF', downloadPdf: 'Download PDF',

View File

@@ -253,6 +253,7 @@ export default {
}, },
manualInvoice: { manualInvoice: {
title: 'ออกใบแจ้งหนี้เอง', title: 'ออกใบแจ้งหนี้เอง',
createTitle: 'สร้าง Invoice',
subtitle: 'กรอกข้อมูลเองเพื่อสร้าง Proforma Invoice PDF โดยไม่ต้องอ้างอิงออเดอร์หรืองาน', subtitle: 'กรอกข้อมูลเองเพื่อสร้าง Proforma Invoice PDF โดยไม่ต้องอ้างอิงออเดอร์หรืองาน',
sourceBadge: 'สร้างด้วยตนเอง', sourceBadge: 'สร้างด้วยตนเอง',
independentHint: 'หน้า V1 นี้ไม่ผูกกับออเดอร์หรืองาน หลังบ้านจะบันทึกประวัติการสร้างและ audit', independentHint: 'หน้า V1 นี้ไม่ผูกกับออเดอร์หรืองาน หลังบ้านจะบันทึกประวัติการสร้างและ audit',
@@ -272,12 +273,16 @@ export default {
company: 'Company', company: 'Company',
attention: 'Attention', attention: 'Attention',
address: 'Address', address: 'Address',
companyInputPlaceholder: 'กรอกชื่อบริษัท',
attentionInputPlaceholder: 'กรอกชื่อผู้รับ',
addressInputPlaceholder: 'กรอกที่อยู่เรียกเก็บเงินแบบเต็ม',
telephone: 'Tel', telephone: 'Tel',
email: 'Email', email: 'Email',
groupName: 'Group Name', groupName: 'Group Name',
arrivalDate: 'Arrival Date', arrivalDate: 'Arrival Date',
departureDate: 'Departure Date', departureDate: 'Departure Date',
roomRateNote: 'Room Rate Note', roomRateNote: 'Room Rate Note',
roomRateNotePlaceholder: 'เช่น including BF',
extraBedRate: 'Extra Bed Rate', extraBedRate: 'Extra Bed Rate',
description: 'Description', description: 'Description',
roomType: 'Room Type', roomType: 'Room Type',
@@ -288,12 +293,13 @@ export default {
subtotal: 'Sub-Total', subtotal: 'Sub-Total',
vat: 'VAT 7%', vat: 'VAT 7%',
total: 'Total', total: 'Total',
addCharge: 'เพิ่มรายการ', addCharge: 'เพิ่มแถว',
removeCharge: 'ลบ', removeCharge: 'ลบ',
removeChargeLineAria: 'ลบแถวที่ {index}',
maxChargesReached: 'V1 รองรับได้สูงสุด 10 รายการค่าใช้จ่าย', maxChargesReached: 'V1 รองรับได้สูงสุด 10 รายการค่าใช้จ่าย',
previewDisclaimer: 'ยอดนี้เป็นเพียงตัวอย่าง ยอดสุดท้ายให้ยึด totals จากหลังบ้าน', previewDisclaimer: 'ยอดนี้เป็นเพียงตัวอย่าง ยอดสุดท้ายให้ยึด totals จากหลังบ้าน',
manualOverrideHint: 'เลือก Company / Attention แล้วระบบจะกรอกข้อมูลให้ แต่ยังแก้ไขข้อความได้ทั้งหมด', manualOverrideHint: 'เลือก Company / Attention แล้วระบบจะกรอกข้อมูลให้ แต่ยังแก้ไขข้อความได้ทั้งหมด',
generate: 'สร้าง PDF', generate: 'ยืนยันและสร้าง PDF',
generating: 'กำลังสร้าง', generating: 'กำลังสร้าง',
openPdf: 'เปิด PDF', openPdf: 'เปิด PDF',
downloadPdf: 'ดาวน์โหลด PDF', downloadPdf: 'ดาวน์โหลด PDF',

View File

@@ -253,6 +253,7 @@ export default {
}, },
manualInvoice: { manualInvoice: {
title: '手工开票', title: '手工开票',
createTitle: '创建 Invoice',
subtitle: '无订单、无任务时,手工填写 Proforma Invoice 字段并生成 PDF。', subtitle: '无订单、无任务时,手工填写 Proforma Invoice 字段并生成 PDF。',
sourceBadge: '手工生成', sourceBadge: '手工生成',
independentHint: '本页第一版不关联订单或任务,生成记录和审计由后端统一落库。', independentHint: '本页第一版不关联订单或任务,生成记录和审计由后端统一落库。',
@@ -272,12 +273,16 @@ export default {
company: '公司Company', company: '公司Company',
attention: '收件人Attention', attention: '收件人Attention',
address: '地址Address', address: '地址Address',
companyInputPlaceholder: '输入公司名称',
attentionInputPlaceholder: '输入收件人',
addressInputPlaceholder: '输入客户完整账单地址',
telephone: 'Tel', telephone: 'Tel',
email: 'Email', email: 'Email',
groupName: '团队名称Group Name', groupName: '团队名称Group Name',
arrivalDate: '到店日期Arrival Date', arrivalDate: '到店日期Arrival Date',
departureDate: '离店日期Departure Date', departureDate: '离店日期Departure Date',
roomRateNote: '房价备注Room Rate Note', roomRateNote: '房价备注Room Rate Note',
roomRateNotePlaceholder: '例如 including BF',
extraBedRate: '加床价格Extra Bed Rate', extraBedRate: '加床价格Extra Bed Rate',
description: '费用描述Description', description: '费用描述Description',
roomType: '房型Room Type', roomType: '房型Room Type',
@@ -288,12 +293,13 @@ export default {
subtotal: 'Sub-Total', subtotal: 'Sub-Total',
vat: 'VAT 7%', vat: 'VAT 7%',
total: 'Total', total: 'Total',
addCharge: '新增费用', addCharge: '新增一行',
removeCharge: '删除', removeCharge: '删除',
removeChargeLineAria: '删除第 {index} 行',
maxChargesReached: '第一版最多支持 10 条费用明细。', maxChargesReached: '第一版最多支持 10 条费用明细。',
previewDisclaimer: '前端仅做金额预览,最终金额以后端返回 totals 为准。', previewDisclaimer: '前端仅做金额预览,最终金额以后端返回 totals 为准。',
manualOverrideHint: '选择 Company / Attention 后会自动带出资料,带出的文本仍可手工覆盖。', manualOverrideHint: '选择 Company / Attention 后会自动带出资料,带出的文本仍可手工覆盖。',
generate: '生成 PDF', generate: '确认并生成 PDF',
generating: '生成中', generating: '生成中',
openPdf: '打开 PDF', openPdf: '打开 PDF',
downloadPdf: '下载 PDF', downloadPdf: '下载 PDF',

View File

@@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import enUS from '@/i18n/locales/en-US'
import thTH from '@/i18n/locales/th-TH'
import zhCN from '@/i18n/locales/zh-CN' import zhCN from '@/i18n/locales/zh-CN'
import { ApiError } from '@/services/httpClient' import { ApiError } from '@/services/httpClient'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
@@ -46,12 +48,17 @@ function createResult(): ManualInvoiceGenerationResult {
const nativeCreateObjectURL = URL.createObjectURL const nativeCreateObjectURL = URL.createObjectURL
const nativeRevokeObjectURL = URL.revokeObjectURL const nativeRevokeObjectURL = URL.revokeObjectURL
function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[] } = {}) { type ManualInvoiceTestLocale = 'zh-CN' | 'en-US' | 'th-TH'
function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[]; locale?: ManualInvoiceTestLocale } = {}) {
const i18n = createI18n({ const i18n = createI18n({
legacy: false, legacy: false,
locale: 'zh-CN', locale: options.locale ?? 'zh-CN',
fallbackLocale: 'zh-CN',
messages: { messages: {
'zh-CN': zhCN, 'zh-CN': zhCN,
'en-US': enUS,
'th-TH': thTH,
}, },
}) })
const pinia = createPinia() const pinia = createPinia()
@@ -80,11 +87,17 @@ function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[]
menus: [], menus: [],
}) })
return mount(ReservationManualInvoiceView, { const wrapper = mount(ReservationManualInvoiceView, {
global: { global: {
plugins: [i18n, pinia], plugins: [i18n, pinia],
}, },
}) })
return Object.assign(wrapper, {
async setLocale(locale: ManualInvoiceTestLocale) {
i18n.global.locale.value = locale
await nextTick()
},
})
} }
async function fillMinimumInvoiceForm(wrapper: ReturnType<typeof mountView>) { async function fillMinimumInvoiceForm(wrapper: ReturnType<typeof mountView>) {
@@ -209,6 +222,131 @@ describe('ReservationManualInvoiceView', () => {
) )
}) })
it('renders the invoice.html style page sections and sticky action bar', () => {
const wrapper = mountView()
expect(wrapper.find('[data-testid="manual-invoice-prototype-page"]').exists()).toBe(true)
expect(wrapper.text()).toContain('创建 Invoice')
expect(wrapper.text()).not.toContain('草稿')
expect(wrapper.text()).toContain('01')
expect(wrapper.text()).toContain('Basic Information')
expect(wrapper.text()).toContain('02')
expect(wrapper.text()).toContain('Please refer to the following reservation')
expect(wrapper.text()).not.toContain('Group Name 与 Group Code 在本流程中为同一字段。')
expect(wrapper.text()).toContain('03')
expect(wrapper.text()).toContain('Description&Amount')
expect(wrapper.find('[data-testid="manual-invoice-action-bar"]').exists()).toBe(true)
})
it('localizes the highlighted invoice title and recipient placeholders', () => {
const wrapper = mountView({ locale: 'en-US' })
const placeholders = wrapper.findAll('input, textarea').map((field) => field.attributes('placeholder'))
expect(wrapper.text()).toContain('Create Invoice')
expect(wrapper.text()).not.toContain('创建 Invoice')
expect(placeholders).toContain('Enter company')
expect(placeholders).toContain('Enter attention')
expect(placeholders).toContain('Enter full billing address')
expect(placeholders).not.toContain('输入 Company')
expect(placeholders).not.toContain('输入 Attention')
expect(placeholders).not.toContain('输入客户完整账单地址')
})
it('localizes the invoice action controls and room rate note placeholder', async () => {
const wrapper = mountView({ locale: 'en-US' })
expect((wrapper.find('[data-testid="booking-room-rate-note"]').element as HTMLInputElement).placeholder).toBe(
'e.g. including BF',
)
expect(wrapper.find('[data-testid="manual-invoice-add-charge"]').text()).toContain('Add row')
expect(wrapper.find('[data-testid="manual-invoice-submit"]').text()).toContain('Confirm and generate PDF')
await wrapper.find('[data-testid="manual-invoice-add-charge"]').trigger('click')
expect(wrapper.findAll('.remove-button')[1]?.text()).toContain('Remove')
expect(wrapper.findAll('.remove-button')[1]?.attributes('aria-label')).toBe('Remove row 2')
expect(wrapper.text()).not.toContain('新增一行')
expect(wrapper.text()).not.toContain('确认并生成 PDF')
expect(wrapper.text()).not.toContain('例如 includingBF')
})
it('shows final-format date previews like the invoice prototype', async () => {
const wrapper = mountView()
await wrapper.find('[data-testid="booking-arrival-date"]').setValue('2026-08-02')
await wrapper.find('[data-testid="booking-departure-date"]').setValue('2026-08-05')
await wrapper.find('[data-testid="document-due-date"]').setValue('2026-07-22')
expect(wrapper.find('[data-testid="arrival-date-preview"]').text()).toContain('02/08/2026')
expect(wrapper.find('[data-testid="departure-date-preview"]').text()).toContain('05/08/2026')
expect(wrapper.find('[data-testid="due-date-preview"]').text()).toContain('22/07/2026')
})
it('keeps primary reservation controls bound to the first charge line', async () => {
const wrapper = mountView()
await wrapper.find('[data-testid="primary-room-rate"]').setValue('3200')
await wrapper.find('[data-testid="primary-room-count"]').setValue('4')
await wrapper.find('[data-testid="primary-night-count"]').setValue('3')
expect((wrapper.find('[data-testid="charge-rate-0"]').element as HTMLInputElement).value).toBe('3200')
expect((wrapper.find('[data-testid="charge-quantity-0"]').element as HTMLInputElement).value).toBe('4')
expect((wrapper.find('[data-testid="charge-nights-0"]').element as HTMLInputElement).value).toBe('3')
expect(wrapper.find('[data-testid="charge-amount-0"]').text()).toContain('38,400.00')
})
it('submits comma-formatted numeric inputs as normalized numbers', 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="booking-extra-bed-rate"]').setValue('1,200')
await wrapper.find('[data-testid="charge-rate-0"]').setValue('3,000')
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
await flushPromises()
expect(service.generateManualReservationInvoice).toHaveBeenCalledWith(
expect.objectContaining({
invoice_payload: expect.objectContaining({
booking: expect.objectContaining({
extra_bed_rate: 1200,
}),
charges: [
expect.objectContaining({
rate: 3000,
}),
],
}),
}),
)
})
it('clears directory contact text when switching company back to manual input', async () => {
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-email"]').element as HTMLInputElement).value).toBe(
'melissa0609@hanatour.com',
)
await wrapper.find('[data-testid="recipient-company-code"]').setValue('MANUAL')
expect((wrapper.find('[data-testid="recipient-contact-id"]').element as HTMLSelectElement).value).toBe('MANUAL')
expect((wrapper.find('[data-testid="recipient-company-input"]').element as HTMLInputElement).value).toBe('')
expect((wrapper.find('[data-testid="recipient-attention-input"]').element as HTMLInputElement).value).toBe('')
expect((wrapper.find('[data-testid="recipient-email"]').element as HTMLInputElement).value).toBe('')
})
it('keeps invoice date out of the keyboard focus order while preserving the payload field', () => {
const wrapper = mountView()
const invoiceDate = wrapper.find('[data-testid="document-invoice-date"]')
expect(invoiceDate.attributes('type')).toBe('hidden')
expect(invoiceDate.attributes('required')).toBeUndefined()
})
it('links company and attention seed data while allowing manual overrides before submit', async () => { it('links company and attention seed data while allowing manual overrides before submit', async () => {
vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult())
const wrapper = mountView() const wrapper = mountView()
@@ -279,6 +417,32 @@ describe('ReservationManualInvoiceView', () => {
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:manual-invoice-pdf') expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:manual-invoice-pdf')
}) })
it('localizes the PDF download fallback message after a locale switch', async () => {
vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult())
const fetchMock = vi.fn().mockRejectedValue(new Error('CORS blocked'))
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
vi.stubGlobal('fetch', fetchMock)
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()
await wrapper.find('[data-testid="manual-invoice-download-pdf"]').trigger('click')
await flushPromises()
expect(openSpy).toHaveBeenCalledWith('https://oss.example/invoices/manual-91001.pdf', '_blank', 'noopener,noreferrer')
expect(wrapper.find('[data-testid="manual-invoice-download-message"]').text()).toBe(
zhCN.manualInvoice.downloadFallback,
)
await wrapper.setLocale('en-US')
expect(wrapper.find('[data-testid="manual-invoice-download-message"]').text()).toBe(
enUS.manualInvoice.downloadFallback,
)
})
it('submits complete numeric input values instead of parseFloat prefixes', async () => { it('submits complete numeric input values instead of parseFloat prefixes', async () => {
vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult())
const wrapper = mountView() const wrapper = mountView()

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -383,8 +383,9 @@ M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填
前端注意: 前端注意:
- `invoice.html` 可以作为交互原型和控件参考,但不直接作为生产页面上线。 -端 V1 已按 `invoice.html` 的业务排版和字段关系实现为项目内页面,但不直接上线原始 HTML
- 前端 V1 已新增 `/reservation/invoices/new` 页面,按 `RESERVATION_INVOICE_GENERATE` 路由权限保护,使用登录态 Bearer token 调用正式业务接口。 - 前端 V1 已新增 `/reservation/invoices/new` 页面,按 `RESERVATION_INVOICE_GENERATE` 路由权限保护,使用登录态 Bearer token 调用正式业务接口。
- 页面标题、关键输入占位符和主要操作按钮已接入中 / 英 / 泰 i18n第一版仍保留原型中的部分英文业务字段标签后续若 Manual Invoice 全量纳入三语言范围,再统一清理。
- 页面已支持无订单 / 无任务独立生成,固定提交 `source_type=MANUAL``order_id=null``task_id=null`;暂不做订单 / 任务自动预填。 - 页面已支持无订单 / 无任务独立生成,固定提交 `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` - 侧边栏入口不由前端硬编码公开,仍依赖后端登录态 `menus[]`。如需菜单中展示,建议菜单管理配置 `menu_code=RESERVATION_MANUAL_INVOICE``route_path=/reservation/invoices/new``permission_code=RESERVATION_INVOICE_GENERATE`
- 第一阶段入口建议是独立页面,例如 `/reservation/invoices/new``/invoices/new`;不要求必须从任务详情或订单详情进入。 - 第一阶段入口建议是独立页面,例如 `/reservation/invoices/new``/invoices/new`;不要求必须从任务详情或订单详情进入。

View File

@@ -2,9 +2,9 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 文档状态 | CP2 后端已实现,前端 CP3 待开发 | | 文档状态 | CP3 前端 V1 已完成,后续继续推进订单 / 任务预填、客户目录接口和历史查询 |
| 适用范围 | 无订单 / 无任务场景下的手工 Invoice 创建,以及后续从任务或订单预填 Invoice | | 适用范围 | 无订单 / 无任务场景下的手工 Invoice 创建,以及后续从任务或订单预填 Invoice |
| 当前目标 | 第一阶段基于现有 `invoice.html` 原型落地 Manual Invoice Mode后端已支持手工生成 PDF前端页面接入 | | 当前目标 | 第一阶段基于现有 `invoice.html` 原型落地 Manual Invoice Mode前端页面接入正式业务接口并按原型三段式结构展示 |
| 依赖能力 | M008 Excel 转 PDF 平台转换能力、阿里云 OSS、登录权限和酒店上下文 | | 依赖能力 | M008 Excel 转 PDF 平台转换能力、阿里云 OSS、登录权限和酒店上下文 |
## 1. 背景 ## 1. 背景
@@ -363,12 +363,13 @@ server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx
- 已完成:失败记录保留已上传成功的 Excel / PDF object key业务审计完成后才标记 `SUCCEEDED` - 已完成:失败记录保留已上传成功的 Excel / PDF object key业务审计完成后才标记 `SUCCEEDED`
- 第一版限制:只支持 `source_type=MANUAL``template_code=PROFORMA_INVOICE_V1`、最多 10 条费用明细;暂不提供历史列表、详情查询、任务 / 订单预填和客户联系人目录查询接口。 - 第一版限制:只支持 `source_type=MANUAL``template_code=PROFORMA_INVOICE_V1`、最多 10 条费用明细;暂不提供历史列表、详情查询、任务 / 订单预填和客户联系人目录查询接口。
### CP3前端 Manual Invoice 页面 ### CP3前端 Manual Invoice 页面(已完成 V1
- 基于当前 `invoice.html` 原型改造成前端项目内页面。 - 已完成:基于当前 `invoice.html` 原型改造成前端项目内 `/reservation/invoices/new` 页面。
- 字段控件按“输入 / 选择 + 可手填 / 只读计算 / 模板配置”实现 - 已完成:页面按 `01 Basic Information``02 Please refer to the following reservation``03 Description&Amount` 三段式结构展示;标题、关键输入占位符和主要操作按钮已接入中 / 英 / 泰 i18n其余原型字段标签后续按需求统一处理
- 提交到后端业务接口,不调用调试上传接口 - 已完成:字段控件按“输入 / 选择 + 可手填 / 只读计算 / 模板配置”实现,第一条费用明细和预订摘要区保持联动
- 展示 PDF 生成结果、错误提示和下载 / 预览入口。 - 已完成:提交到后端业务接口,不调用调试上传接口。
- 已完成:展示 PDF 生成结果、错误提示和下载 / 预览入口。
### CP4任务 / 订单预填 ### CP4任务 / 订单预填