From 68ff9e2b389b57129253b5820db27fe9017eb4e1 Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 22 Jul 2026 22:59:28 +0700 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=89=8B=E5=B7=A5=E5=BC=80?= =?UTF-8?q?=E7=A5=A8Rate=E6=A0=A1=E9=AA=8C=E5=92=8C=E5=A4=9C=E6=95=B0?= =?UTF-8?q?=E6=B4=BE=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/i18n/locales/en-US.ts | 5 +- client/src/i18n/locales/th-TH.ts | 5 +- client/src/i18n/locales/zh-CN.ts | 5 +- client/src/tests/manualInvoiceView.spec.ts | 45 +++++- .../ReservationManualInvoiceView.vue | 138 ++++++++++++++++-- .../backend-to-frontend-notes.md | 2 + .../frontend-to-backend-api-requests.md | 1 + .../M009-manual-invoice-generation-v1.md | 6 +- ...servationInvoiceGenerationServiceImpl.java | 12 +- ...vationInvoiceGenerationControllerTest.java | 28 ++++ 10 files changed, 226 insertions(+), 21 deletions(-) diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 91da88f..de19595 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -369,6 +369,7 @@ export default { required: 'Required', invalidEmail: 'Invalid email', dateOrder: 'Invalid date order', + invalidNumber: 'Invalid number', positiveNumber: 'Must be greater than 0', nonNegativeNumber: 'Cannot be less than 0', }, @@ -391,8 +392,10 @@ export default { invalidEmail: 'Email format is invalid.', dueDateBeforeBookingDate: 'Due date cannot be earlier than booking date.', departureBeforeArrival: 'Departure date must be later than arrival date.', + invalidNumber: 'Invalid number. Check amount, quantity, or nights fields.', extraBedNegative: 'Extra bed rate cannot be less than 0.', - chargePositive: 'Quantity, rate, and nights must be greater than 0.', + chargePositive: 'Quantity and nights must be greater than 0.', + chargeRateNonNegative: 'Rate cannot be less than 0.', }, }, roomingList: { diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 4aafe64..806a9af 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -369,6 +369,7 @@ export default { required: 'จำเป็นต้องกรอก', invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง', dateOrder: 'ลำดับวันที่ไม่ถูกต้อง', + invalidNumber: 'รูปแบบตัวเลขไม่ถูกต้อง', positiveNumber: 'ต้องมากกว่า 0', nonNegativeNumber: 'ต้องไม่ต่ำกว่า 0', }, @@ -391,8 +392,10 @@ export default { invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง', dueDateBeforeBookingDate: 'Due Date ต้องไม่เร็วกว่าวันที่ Booking', departureBeforeArrival: 'Departure Date ต้องหลัง Arrival Date', + invalidNumber: 'รูปแบบตัวเลขไม่ถูกต้อง กรุณาตรวจจำนวนเงิน จำนวนห้อง หรือจำนวนคืน', extraBedNegative: 'Extra Bed Rate ต้องไม่ต่ำกว่า 0', - chargePositive: 'Quantity, Rate และ Nights ต้องมากกว่า 0', + chargePositive: 'Quantity และ Nights ต้องมากกว่า 0', + chargeRateNonNegative: 'Rate ต้องไม่ต่ำกว่า 0', }, }, roomingList: { diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 27d08fb..cf4238b 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -369,6 +369,7 @@ export default { required: '必填', invalidEmail: 'Email 格式不正确', dateOrder: '日期顺序不正确', + invalidNumber: '数字格式不正确', positiveNumber: '必须大于 0', nonNegativeNumber: '不能小于 0', }, @@ -391,8 +392,10 @@ export default { invalidEmail: 'Email 格式不正确。', dueDateBeforeBookingDate: 'Due Date 不能早于 Booking 日期。', departureBeforeArrival: 'Departure Date 必须晚于 Arrival Date。', + invalidNumber: '数字格式不正确,请检查金额、数量或夜数字段。', extraBedNegative: '加床价格不能小于 0。', - chargePositive: '费用明细的 Quantity、Rate、Nights 必须大于 0。', + chargePositive: '费用明细的 Quantity 和 Nights 必须大于 0。', + chargeRateNonNegative: '费用明细的 Rate 不能小于 0。', }, }, roomingList: { diff --git a/client/src/tests/manualInvoiceView.spec.ts b/client/src/tests/manualInvoiceView.spec.ts index 4c95079..30c64cb 100644 --- a/client/src/tests/manualInvoiceView.spec.ts +++ b/client/src/tests/manualInvoiceView.spec.ts @@ -347,7 +347,7 @@ describe('ReservationManualInvoiceView', () => { await wrapper.find('[data-testid="recipient-email"]').setValue('bad-email') await wrapper.find('[data-testid="booking-departure-date"]').setValue('2026-07-19') await wrapper.find('[data-testid="booking-extra-bed-rate"]').setValue('-1') - await wrapper.find('[data-testid="charge-rate-0"]').setValue('0') + await wrapper.find('[data-testid="charge-rate-0"]').setValue('zzz') await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click') await flushPromises() @@ -366,10 +366,51 @@ describe('ReservationManualInvoiceView', () => { ) expect(wrapper.find('[data-testid="charge-rate-0"]').classes()).toContain('is-invalid') expect(wrapper.find('[data-testid="manual-invoice-field-error-charge-0-rate"]').text()).toBe( - zhCN.manualInvoice.fieldErrors.positiveNumber, + zhCN.manualInvoice.fieldErrors.invalidNumber, ) }) + it('allows zero room rate and charge rate while keeping rate required and numeric', 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="primary-room-rate"]').setValue('0') + await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="primary-room-rate"]').classes()).not.toContain('is-invalid') + expect(wrapper.find('[data-testid="manual-invoice-field-error-primary-rate"]').exists()).toBe(false) + expect(service.generateManualReservationInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + invoice_payload: expect.objectContaining({ + charges: [ + expect.objectContaining({ + rate: 0, + }), + ], + }), + }), + ) + }) + + it('auto-fills primary nights from stay dates until the user edits nights manually', async () => { + const wrapper = mountView() + + await wrapper.find('[data-testid="booking-arrival-date"]').setValue('2026-07-20') + await wrapper.find('[data-testid="booking-departure-date"]').setValue('2026-07-22') + + expect((wrapper.find('[data-testid="primary-night-count"]').element as HTMLInputElement).value).toBe('2') + expect((wrapper.find('[data-testid="charge-nights-0"]').element as HTMLInputElement).value).toBe('2') + + await wrapper.find('[data-testid="primary-night-count"]').setValue('3') + await wrapper.find('[data-testid="booking-departure-date"]').setValue('2026-07-24') + + expect((wrapper.find('[data-testid="primary-night-count"]').element as HTMLInputElement).value).toBe('3') + expect((wrapper.find('[data-testid="charge-nights-0"]').element as HTMLInputElement).value).toBe('3') + }) + it('does not render final-format date preview blocks', () => { const wrapper = mountView() diff --git a/client/src/views/reservation/ReservationManualInvoiceView.vue b/client/src/views/reservation/ReservationManualInvoiceView.vue index 02f3104..b3b1623 100644 --- a/client/src/views/reservation/ReservationManualInvoiceView.vue +++ b/client/src/views/reservation/ReservationManualInvoiceView.vue @@ -277,6 +277,7 @@ :invalid="Boolean(fieldError('arrival-date'))" :described-by="fieldError('arrival-date') ? fieldErrorId('arrival-date') : undefined" required + @update:model-value="derivePrimaryNightsFromStayDates" />

('error') const generationResult = ref(null) const documentDatesEdited = ref(false) +const primaryNightsEdited = ref(false) const validationAttempted = ref(false) const recipientContacts = computed(() => { @@ -1176,15 +1181,38 @@ function validateForm(): string[] { Boolean(form.booking.group_name.trim()) && Boolean(form.booking.arrival_date) && Boolean(form.booking.departure_date) - const charges = buildCharges() const extraBedRate = optionalNumberValue(form.booking.extra_bed_rate) + const hasInvalidChargeNumber = form.charges.some( + (charge) => + hasInvalidNumericInput(charge.quantity) || hasInvalidNumericInput(charge.rate) || hasInvalidNumericInput(charge.nights), + ) + const hasInvalidExtraBedRate = hasNumericInputValue(form.booking.extra_bed_rate) && extraBedRate === null const hasRequiredCharges = - charges.length > 0 && - charges.every((charge) => charge.description && charge.room_type && charge.quantity > 0 && charge.rate > 0 && charge.nights > 0) + form.charges.length > 0 && + form.charges.every( + (charge) => + Boolean(charge.description.trim()) && + Boolean(charge.room_type.trim()) && + hasNumericInputValue(charge.quantity) && + hasNumericInputValue(charge.rate) && + hasNumericInputValue(charge.nights), + ) + const hasInvalidQuantityOrNights = form.charges.some((charge) => { + const quantity = optionalNumberValue(charge.quantity) + const nights = optionalNumberValue(charge.nights) + return (quantity !== null && quantity <= 0) || (nights !== null && nights <= 0) + }) + const hasNegativeRate = form.charges.some((charge) => { + const rate = optionalNumberValue(charge.rate) + return rate !== null && rate < 0 + }) if (!hasRequiredDocument || !hasRequiredRecipient || !hasRequiredBooking || !hasRequiredCharges) { errors.push(t('manualInvoice.errors.requiredFields')) } + if (hasInvalidChargeNumber || hasInvalidExtraBedRate) { + errors.push(t('manualInvoice.errors.invalidNumber')) + } if (form.recipient.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.recipient.email)) { errors.push(t('manualInvoice.errors.invalidEmail')) } @@ -1198,18 +1226,22 @@ function validateForm(): string[] { ) { errors.push(t('manualInvoice.errors.departureBeforeArrival')) } - if (form.booking.extra_bed_rate.trim() && (extraBedRate === null || extraBedRate < 0)) { + if (extraBedRate !== null && extraBedRate < 0) { errors.push(t('manualInvoice.errors.extraBedNegative')) } - if (charges.some((charge) => charge.quantity <= 0 || charge.rate <= 0 || charge.nights <= 0)) { + if (hasInvalidQuantityOrNights) { errors.push(t('manualInvoice.errors.chargePositive')) } + if (hasNegativeRate) { + errors.push(t('manualInvoice.errors.chargeRateNonNegative')) + } return [...new Set(errors)] } function collectFieldErrors(): FieldErrorMap { const errors: FieldErrorMap = {} const positiveNumber = t('manualInvoice.fieldErrors.positiveNumber') + const nonNegativeNumber = t('manualInvoice.fieldErrors.nonNegativeNumber') setRequiredError(errors, 'company', form.recipient.company) setRequiredError(errors, 'attention', form.recipient.attention) @@ -1237,21 +1269,24 @@ function collectFieldErrors(): FieldErrorMap { errors['departure-date'] = t('manualInvoice.fieldErrors.dateOrder') } - const extraBedRate = optionalNumberValue(form.booking.extra_bed_rate) - if (form.booking.extra_bed_rate.trim() && (extraBedRate === null || extraBedRate < 0)) { - errors['extra-bed-rate'] = t('manualInvoice.fieldErrors.nonNegativeNumber') + const extraBedRate = validateOptionalNumericField(errors, 'extra-bed-rate', form.booking.extra_bed_rate) + if (extraBedRate !== null && extraBedRate < 0) { + errors['extra-bed-rate'] = nonNegativeNumber } form.charges.forEach((charge, index) => { setRequiredError(errors, chargeFieldKey(index, 'description'), charge.description) setRequiredError(errors, chargeFieldKey(index, 'room-type'), charge.room_type) - if (numberValue(charge.quantity) <= 0) { + const quantity = validateRequiredNumericField(errors, chargeFieldKey(index, 'quantity'), charge.quantity) + if (quantity !== null && quantity <= 0) { errors[chargeFieldKey(index, 'quantity')] = positiveNumber } - if (numberValue(charge.rate) <= 0) { - errors[chargeFieldKey(index, 'rate')] = positiveNumber + const rate = validateRequiredNumericField(errors, chargeFieldKey(index, 'rate'), charge.rate) + if (rate !== null && rate < 0) { + errors[chargeFieldKey(index, 'rate')] = nonNegativeNumber } - if (numberValue(charge.nights) <= 0) { + const nights = validateRequiredNumericField(errors, chargeFieldKey(index, 'nights'), charge.nights) + if (nights !== null && nights <= 0) { errors[chargeFieldKey(index, 'nights')] = positiveNumber } }) @@ -1278,6 +1313,39 @@ function setRequiredError(errors: FieldErrorMap, fieldKey: string, value: string } } +function validateRequiredNumericField(errors: FieldErrorMap, fieldKey: string, value: string | number): number | null { + if (!hasNumericInputValue(value)) { + errors[fieldKey] = t('manualInvoice.fieldErrors.required') + return null + } + const parsed = optionalNumberValue(value) + if (parsed === null) { + errors[fieldKey] = t('manualInvoice.fieldErrors.invalidNumber') + return null + } + return parsed +} + +function validateOptionalNumericField(errors: FieldErrorMap, fieldKey: string, value: string | number): number | null { + if (!hasNumericInputValue(value)) { + return null + } + const parsed = optionalNumberValue(value) + if (parsed === null) { + errors[fieldKey] = t('manualInvoice.fieldErrors.invalidNumber') + return null + } + return parsed +} + +function hasInvalidNumericInput(value: string | number): boolean { + return hasNumericInputValue(value) && optionalNumberValue(value) === null +} + +function hasNumericInputValue(value: string | number): boolean { + return String(value).trim() !== '' +} + function fieldError(fieldKey: string): string { return fieldErrors.value[fieldKey] ?? '' } @@ -1382,6 +1450,27 @@ function markDocumentDatesEdited(): void { documentDatesEdited.value = true } +function markPrimaryNightsEdited(): void { + primaryNightsEdited.value = true +} + +function markChargeNightsEdited(index: number): void { + if (index === 0) { + markPrimaryNightsEdited() + } +} + +function derivePrimaryNightsFromStayDates(): void { + if (primaryNightsEdited.value) { + return + } + const nights = stayNights(form.booking.arrival_date, form.booking.departure_date) + if (nights === null) { + return + } + primaryCharge.value.nights = String(nights) +} + function applyDefaultDocumentDates(timeZone: string): void { const dates = createDefaultDocumentDates(timeZone) form.document.invoice_date = dates.invoiceDate @@ -1483,6 +1572,31 @@ function addDaysToDateInputValue(dateValue: string, days: number): string { ].join('-') } +function stayNights(arrivalDate: string, departureDate: string): number | null { + if (!arrivalDate || !departureDate || departureDate <= arrivalDate) { + return null + } + const arrival = dateInputValueToUtcDate(arrivalDate) + const departure = dateInputValueToUtcDate(departureDate) + if (!arrival || !departure) { + return null + } + const nights = Math.round((departure.getTime() - arrival.getTime()) / 86_400_000) + return nights > 0 ? nights : null +} + +function dateInputValueToUtcDate(dateValue: string): Date | null { + const [year = 0, month = 0, day = 0] = dateValue.split('-').map((part) => Number(part)) + if (!year || !month || !day) { + return null + } + const date = new Date(Date.UTC(year, month - 1, day)) + if (date.getUTCFullYear() !== year || date.getUTCMonth() + 1 !== month || date.getUTCDate() !== day) { + return null + } + return date +} + function formatDateParts(date: Date, timeZone: string): Intl.DateTimeFormatPart[] { return new Intl.DateTimeFormat('en-GB', { timeZone, diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index b635bae..ee204b2 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -449,6 +449,8 @@ M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填 - Company、Attention、Address、Tel、Email 是一组收件方联系人档案,不是五个互相独立字段;选择 Company 后应刷新 Attention 候选,选择 Attention 后应带出 Address、Tel、Email。Booking Date 展示在同一区域,但不属于联系人档案,应作为 `document.booking_date` 独立提交。 - 第一阶段已确认三组客户 / 旅行社种子数据:`LIAN_TAI` / `LIAN TAI TRAVEL (THAILAND) CO., LTD.` + `Khun Ann`;`QBD` / `Q.B.D. TRAVEL GROUP CO., LTD` + `Jitdanun Panaphuchong`;`HANATOUR` / `HANATOUR TD CO., LTD.` + 7 个联系人。完整数据以 `docs/project/requirements/M009-manual-invoice-generation-v1.md` 为准。 - Room Type、Room Rate、Extra Bed 建议也预留“选择 + 可手填 / 可覆盖”能力,后续接 PMS 房型、Rate Code 或价格配置。 +- Room Rate / 费用明细 Rate 第一阶段只校验必填、数字格式和非负数,允许 `0`;Quantity 和 Nights 仍必须大于 `0`。 +- No.of Night(s) 前端默认按 `Departure Date - Arrival Date` 自动填入第一条费用明细 Night(s),用户手工修改后保留用户输入,不再因日期变化覆盖。 - Amount、Sub-Total、VAT、Total 是只读计算字段;前端可展示预览,但最终金额以后端计算和模板公式为准。 - 酒店名称、Tax ID、法人主体、银行账户、Logo 和固定付款文案不应作为每张 Invoice 的手工输入;第一阶段可由模板或酒店发票配置提供。 - 正式业务生成接口为 `POST /api/reservation/invoices/manual-generations`,需要 Bearer token、酒店访问权和 `RESERVATION_INVOICE_GENERATE` 权限。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 52a0413..f288904 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -1175,6 +1175,7 @@ POST /api/reservation/invoices/manual-generations - 如果前端同时传 `task_id` 和 `order_id`,必须保证任务属于该订单;后端不一致时返回 `RESERVATION_INVOICE_CONTEXT_MISMATCH`。 - `recipient.company_code` 和 `recipient.contact_id` 用于表达目录选择结果;`company`、`attention`、`address`、`telephone`、`email` 是最终用于生成 PDF 的文本值,Manual 覆盖时也必须提交。 - 后端需要重新计算金额、VAT 和合计;前端计算只做预览。 +- `charges[].rate` 必填且必须是数字,允许 `0`,不能小于 `0`;`charges[].quantity` 和 `charges[].nights` 仍必须大于 `0`。 - 后端需要返回可预览 / 下载的 PDF URL。 - `pdf_url` 第一版可作为预览地址;前端会优先用浏览器 `fetch + Blob` 触发下载,避免跨域场景下 `` 失效。如果 OSS CORS 不允许浏览器读取文件,前端会退回打开 PDF 页面。后续如要求稳定下载体验,建议后端提供带 `Content-Disposition` 的受控下载代理或签名下载 URL。 - 该接口应走 Bearer 登录、酒店访问权和 `RESERVATION_INVOICE_GENERATE` 权限,不走 M008 调试上传 access key。 diff --git a/docs/project/requirements/M009-manual-invoice-generation-v1.md b/docs/project/requirements/M009-manual-invoice-generation-v1.md index c287d5d..387b818 100644 --- a/docs/project/requirements/M009-manual-invoice-generation-v1.md +++ b/docs/project/requirements/M009-manual-invoice-generation-v1.md @@ -135,11 +135,11 @@ | Arrival Date | 日期输入 | 手工填写;任务预填时来自任务字段 | | Departure Date | 日期输入 | 手工填写;任务预填时来自任务字段 | | Due Date | 日期输入 | 手工填写;可按酒店付款规则默认生成 | -| Room Rate | 选择 + 可手填 | 第一阶段可手填;后续接 Rate Code / 房价配置 | +| Room Rate | 选择 + 可手填 | 第一阶段可手填;必填且必须是数字,允许 `0`,不能小于 `0`;后续接 Rate Code / 房价配置 | | Room Rate Note | 输入框 | 例如 `includingBF` | | Extra bed | 选择 + 可手填 | 第一阶段可手填;后续接加床价格配置 | | No. of Room(s) | 输入框 / 只读派生 | 可由第一条明细同步,也允许手工覆盖 | -| No. of Night(s) | 输入框 / 日期派生 | 可由 Arrival / Departure 派生,也允许手工覆盖 | +| No. of Night(s) | 输入框 / 日期派生 | 前端默认按 `Departure Date - Arrival Date` 自动填入第一条明细 Night(s),用户手工修改后不再覆盖 | ### 4.3 Description & Amount @@ -148,7 +148,7 @@ | Description | 输入框 | 手工填写;默认可使用 Group Name 或 Booking No | | Room Types | 选择 + 可手填 | 第一阶段可手填;后续接 PMS 房型目录 | | Quantity(s) | 数字输入 | 后端要求正数 | -| Rate | 数字输入 | 后端要求正数 | +| Rate | 数字输入 | 后端要求必填、数字且不能小于 `0`,允许 `0` | | Night(s) | 数字输入 | 后端要求正数 | | Amount | 只读 | `quantity * rate * nights` | | Sub-Total | 只读 | 后端按 VAT 规则计算 | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImpl.java index d3d1e5b..c935e46 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImpl.java @@ -294,7 +294,7 @@ public class ReservationInvoiceGenerationServiceImpl implements ReservationInvoi errors.add(prefix + "room_type: 必填字段缺失。"); } validatePositive(charge.quantity(), prefix + "quantity", errors); - validatePositive(charge.rate(), prefix + "rate", errors); + validateNonNegative(charge.rate(), prefix + "rate", errors); validatePositive(charge.nights(), prefix + "nights", errors); } } @@ -490,6 +490,16 @@ public class ReservationInvoiceGenerationServiceImpl implements ReservationInvoi } } + private void validateNonNegative(BigDecimal value, String fieldPath, List errors) { + if (value == null) { + errors.add(fieldPath + ": 必填字段缺失。"); + return; + } + if (value.compareTo(BigDecimal.ZERO) < 0) { + errors.add(fieldPath + ": 不能小于 0。"); + } + } + private String writeJson(Object value) { try { return objectMapper.writeValueAsString(value); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationControllerTest.java index ba9700b..44daeb3 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationControllerTest.java @@ -195,6 +195,34 @@ class ReservationInvoiceGenerationControllerTest { verifyNoInteractions(excelToPdfConverter, objectStorageService); } + @Test + void shouldAllowManualInvoiceChargeRateToBeZero() throws Exception { + byte[] pdfBytes = "%PDF-1.7\nmanual-invoice-zero-rate".getBytes(StandardCharsets.UTF_8); + when(excelToPdfConverter.convert(any())).thenReturn(new ExcelToPdfConvertedDocument( + "proforma-invoice.pdf", + MediaType.APPLICATION_PDF_VALUE, + (long) pdfBytes.length, + pdfBytes, + 88L)); + when(objectStorageService.putObject(any())).thenAnswer(invocation -> { + ObjectStoragePutRequest request = invocation.getArgument(0); + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + }); + String token = loginToken(mockMvc, "invoice-admin", "Admin@123456"); + + performAuthorized(mockMvc, token, post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(validRequest().replace("\"rate\": 3000", "\"rate\": 0"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.totals.subtotal").value(0.00)) + .andExpect(jsonPath("$.totals.vat").value(0.00)) + .andExpect(jsonPath("$.totals.total").value(0.00)); + } + @Test void shouldRejectManualInvoiceWhenTaskDoesNotBelongToOrder() throws Exception { insertOrder(2080010000000000001L, 2080010000000000101L, "GRP-M009-ORDER-1");