调整手工开票Rate校验和夜数派生
This commit is contained in:
@@ -369,6 +369,7 @@ export default {
|
|||||||
required: 'Required',
|
required: 'Required',
|
||||||
invalidEmail: 'Invalid email',
|
invalidEmail: 'Invalid email',
|
||||||
dateOrder: 'Invalid date order',
|
dateOrder: 'Invalid date order',
|
||||||
|
invalidNumber: 'Invalid number',
|
||||||
positiveNumber: 'Must be greater than 0',
|
positiveNumber: 'Must be greater than 0',
|
||||||
nonNegativeNumber: 'Cannot be less than 0',
|
nonNegativeNumber: 'Cannot be less than 0',
|
||||||
},
|
},
|
||||||
@@ -391,8 +392,10 @@ export default {
|
|||||||
invalidEmail: 'Email format is invalid.',
|
invalidEmail: 'Email format is invalid.',
|
||||||
dueDateBeforeBookingDate: 'Due date cannot be earlier than booking date.',
|
dueDateBeforeBookingDate: 'Due date cannot be earlier than booking date.',
|
||||||
departureBeforeArrival: 'Departure date must be later than arrival 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.',
|
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: {
|
roomingList: {
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ export default {
|
|||||||
required: 'จำเป็นต้องกรอก',
|
required: 'จำเป็นต้องกรอก',
|
||||||
invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง',
|
invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง',
|
||||||
dateOrder: 'ลำดับวันที่ไม่ถูกต้อง',
|
dateOrder: 'ลำดับวันที่ไม่ถูกต้อง',
|
||||||
|
invalidNumber: 'รูปแบบตัวเลขไม่ถูกต้อง',
|
||||||
positiveNumber: 'ต้องมากกว่า 0',
|
positiveNumber: 'ต้องมากกว่า 0',
|
||||||
nonNegativeNumber: 'ต้องไม่ต่ำกว่า 0',
|
nonNegativeNumber: 'ต้องไม่ต่ำกว่า 0',
|
||||||
},
|
},
|
||||||
@@ -391,8 +392,10 @@ export default {
|
|||||||
invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง',
|
invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง',
|
||||||
dueDateBeforeBookingDate: 'Due Date ต้องไม่เร็วกว่าวันที่ Booking',
|
dueDateBeforeBookingDate: 'Due Date ต้องไม่เร็วกว่าวันที่ Booking',
|
||||||
departureBeforeArrival: 'Departure Date ต้องหลัง Arrival Date',
|
departureBeforeArrival: 'Departure Date ต้องหลัง Arrival Date',
|
||||||
|
invalidNumber: 'รูปแบบตัวเลขไม่ถูกต้อง กรุณาตรวจจำนวนเงิน จำนวนห้อง หรือจำนวนคืน',
|
||||||
extraBedNegative: 'Extra Bed Rate ต้องไม่ต่ำกว่า 0',
|
extraBedNegative: 'Extra Bed Rate ต้องไม่ต่ำกว่า 0',
|
||||||
chargePositive: 'Quantity, Rate และ Nights ต้องมากกว่า 0',
|
chargePositive: 'Quantity และ Nights ต้องมากกว่า 0',
|
||||||
|
chargeRateNonNegative: 'Rate ต้องไม่ต่ำกว่า 0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
roomingList: {
|
roomingList: {
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ export default {
|
|||||||
required: '必填',
|
required: '必填',
|
||||||
invalidEmail: 'Email 格式不正确',
|
invalidEmail: 'Email 格式不正确',
|
||||||
dateOrder: '日期顺序不正确',
|
dateOrder: '日期顺序不正确',
|
||||||
|
invalidNumber: '数字格式不正确',
|
||||||
positiveNumber: '必须大于 0',
|
positiveNumber: '必须大于 0',
|
||||||
nonNegativeNumber: '不能小于 0',
|
nonNegativeNumber: '不能小于 0',
|
||||||
},
|
},
|
||||||
@@ -391,8 +392,10 @@ export default {
|
|||||||
invalidEmail: 'Email 格式不正确。',
|
invalidEmail: 'Email 格式不正确。',
|
||||||
dueDateBeforeBookingDate: 'Due Date 不能早于 Booking 日期。',
|
dueDateBeforeBookingDate: 'Due Date 不能早于 Booking 日期。',
|
||||||
departureBeforeArrival: 'Departure Date 必须晚于 Arrival Date。',
|
departureBeforeArrival: 'Departure Date 必须晚于 Arrival Date。',
|
||||||
|
invalidNumber: '数字格式不正确,请检查金额、数量或夜数字段。',
|
||||||
extraBedNegative: '加床价格不能小于 0。',
|
extraBedNegative: '加床价格不能小于 0。',
|
||||||
chargePositive: '费用明细的 Quantity、Rate、Nights 必须大于 0。',
|
chargePositive: '费用明细的 Quantity 和 Nights 必须大于 0。',
|
||||||
|
chargeRateNonNegative: '费用明细的 Rate 不能小于 0。',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
roomingList: {
|
roomingList: {
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ describe('ReservationManualInvoiceView', () => {
|
|||||||
await wrapper.find('[data-testid="recipient-email"]').setValue('bad-email')
|
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-departure-date"]').setValue('2026-07-19')
|
||||||
await wrapper.find('[data-testid="booking-extra-bed-rate"]').setValue('-1')
|
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 wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
|
||||||
await flushPromises()
|
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="charge-rate-0"]').classes()).toContain('is-invalid')
|
||||||
expect(wrapper.find('[data-testid="manual-invoice-field-error-charge-0-rate"]').text()).toBe(
|
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', () => {
|
it('does not render final-format date preview blocks', () => {
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
|
|
||||||
|
|||||||
@@ -277,6 +277,7 @@
|
|||||||
:invalid="Boolean(fieldError('arrival-date'))"
|
:invalid="Boolean(fieldError('arrival-date'))"
|
||||||
:described-by="fieldError('arrival-date') ? fieldErrorId('arrival-date') : undefined"
|
:described-by="fieldError('arrival-date') ? fieldErrorId('arrival-date') : undefined"
|
||||||
required
|
required
|
||||||
|
@update:model-value="derivePrimaryNightsFromStayDates"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -306,6 +307,7 @@
|
|||||||
:invalid="Boolean(fieldError('departure-date'))"
|
:invalid="Boolean(fieldError('departure-date'))"
|
||||||
:described-by="fieldError('departure-date') ? fieldErrorId('departure-date') : undefined"
|
:described-by="fieldError('departure-date') ? fieldErrorId('departure-date') : undefined"
|
||||||
required
|
required
|
||||||
|
@update:model-value="derivePrimaryNightsFromStayDates"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -464,6 +466,7 @@
|
|||||||
:class="validationClass('primary-nights')"
|
:class="validationClass('primary-nights')"
|
||||||
v-bind="validationAria('primary-nights')"
|
v-bind="validationAria('primary-nights')"
|
||||||
required
|
required
|
||||||
|
@input="markPrimaryNightsEdited"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="number-suffix"
|
class="number-suffix"
|
||||||
@@ -630,6 +633,7 @@
|
|||||||
:class="validationClass(chargeFieldKey(index, 'nights'))"
|
:class="validationClass(chargeFieldKey(index, 'nights'))"
|
||||||
v-bind="validationAria(chargeFieldKey(index, 'nights'))"
|
v-bind="validationAria(chargeFieldKey(index, 'nights'))"
|
||||||
required
|
required
|
||||||
|
@input="markChargeNightsEdited(index)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="fieldError(chargeFieldKey(index, 'nights'))"
|
v-if="fieldError(chargeFieldKey(index, 'nights'))"
|
||||||
@@ -961,6 +965,7 @@ const downloadMessageKey = ref('')
|
|||||||
const alertTone = ref<'error' | 'success'>('error')
|
const alertTone = ref<'error' | 'success'>('error')
|
||||||
const generationResult = ref<ManualInvoiceGenerationResult | null>(null)
|
const generationResult = ref<ManualInvoiceGenerationResult | null>(null)
|
||||||
const documentDatesEdited = ref(false)
|
const documentDatesEdited = ref(false)
|
||||||
|
const primaryNightsEdited = ref(false)
|
||||||
const validationAttempted = ref(false)
|
const validationAttempted = ref(false)
|
||||||
|
|
||||||
const recipientContacts = computed(() => {
|
const recipientContacts = computed(() => {
|
||||||
@@ -1176,15 +1181,38 @@ function validateForm(): string[] {
|
|||||||
Boolean(form.booking.group_name.trim()) &&
|
Boolean(form.booking.group_name.trim()) &&
|
||||||
Boolean(form.booking.arrival_date) &&
|
Boolean(form.booking.arrival_date) &&
|
||||||
Boolean(form.booking.departure_date)
|
Boolean(form.booking.departure_date)
|
||||||
const charges = buildCharges()
|
|
||||||
const extraBedRate = optionalNumberValue(form.booking.extra_bed_rate)
|
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 =
|
const hasRequiredCharges =
|
||||||
charges.length > 0 &&
|
form.charges.length > 0 &&
|
||||||
charges.every((charge) => charge.description && charge.room_type && charge.quantity > 0 && charge.rate > 0 && charge.nights > 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) {
|
if (!hasRequiredDocument || !hasRequiredRecipient || !hasRequiredBooking || !hasRequiredCharges) {
|
||||||
errors.push(t('manualInvoice.errors.requiredFields'))
|
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)) {
|
if (form.recipient.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.recipient.email)) {
|
||||||
errors.push(t('manualInvoice.errors.invalidEmail'))
|
errors.push(t('manualInvoice.errors.invalidEmail'))
|
||||||
}
|
}
|
||||||
@@ -1198,18 +1226,22 @@ function validateForm(): string[] {
|
|||||||
) {
|
) {
|
||||||
errors.push(t('manualInvoice.errors.departureBeforeArrival'))
|
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'))
|
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'))
|
errors.push(t('manualInvoice.errors.chargePositive'))
|
||||||
}
|
}
|
||||||
|
if (hasNegativeRate) {
|
||||||
|
errors.push(t('manualInvoice.errors.chargeRateNonNegative'))
|
||||||
|
}
|
||||||
return [...new Set(errors)]
|
return [...new Set(errors)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectFieldErrors(): FieldErrorMap {
|
function collectFieldErrors(): FieldErrorMap {
|
||||||
const errors: FieldErrorMap = {}
|
const errors: FieldErrorMap = {}
|
||||||
const positiveNumber = t('manualInvoice.fieldErrors.positiveNumber')
|
const positiveNumber = t('manualInvoice.fieldErrors.positiveNumber')
|
||||||
|
const nonNegativeNumber = t('manualInvoice.fieldErrors.nonNegativeNumber')
|
||||||
|
|
||||||
setRequiredError(errors, 'company', form.recipient.company)
|
setRequiredError(errors, 'company', form.recipient.company)
|
||||||
setRequiredError(errors, 'attention', form.recipient.attention)
|
setRequiredError(errors, 'attention', form.recipient.attention)
|
||||||
@@ -1237,21 +1269,24 @@ function collectFieldErrors(): FieldErrorMap {
|
|||||||
errors['departure-date'] = t('manualInvoice.fieldErrors.dateOrder')
|
errors['departure-date'] = t('manualInvoice.fieldErrors.dateOrder')
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraBedRate = optionalNumberValue(form.booking.extra_bed_rate)
|
const extraBedRate = validateOptionalNumericField(errors, 'extra-bed-rate', form.booking.extra_bed_rate)
|
||||||
if (form.booking.extra_bed_rate.trim() && (extraBedRate === null || extraBedRate < 0)) {
|
if (extraBedRate !== null && extraBedRate < 0) {
|
||||||
errors['extra-bed-rate'] = t('manualInvoice.fieldErrors.nonNegativeNumber')
|
errors['extra-bed-rate'] = nonNegativeNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
form.charges.forEach((charge, index) => {
|
form.charges.forEach((charge, index) => {
|
||||||
setRequiredError(errors, chargeFieldKey(index, 'description'), charge.description)
|
setRequiredError(errors, chargeFieldKey(index, 'description'), charge.description)
|
||||||
setRequiredError(errors, chargeFieldKey(index, 'room-type'), charge.room_type)
|
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
|
errors[chargeFieldKey(index, 'quantity')] = positiveNumber
|
||||||
}
|
}
|
||||||
if (numberValue(charge.rate) <= 0) {
|
const rate = validateRequiredNumericField(errors, chargeFieldKey(index, 'rate'), charge.rate)
|
||||||
errors[chargeFieldKey(index, 'rate')] = positiveNumber
|
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
|
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 {
|
function fieldError(fieldKey: string): string {
|
||||||
return fieldErrors.value[fieldKey] ?? ''
|
return fieldErrors.value[fieldKey] ?? ''
|
||||||
}
|
}
|
||||||
@@ -1382,6 +1450,27 @@ function markDocumentDatesEdited(): void {
|
|||||||
documentDatesEdited.value = true
|
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 {
|
function applyDefaultDocumentDates(timeZone: string): void {
|
||||||
const dates = createDefaultDocumentDates(timeZone)
|
const dates = createDefaultDocumentDates(timeZone)
|
||||||
form.document.invoice_date = dates.invoiceDate
|
form.document.invoice_date = dates.invoiceDate
|
||||||
@@ -1483,6 +1572,31 @@ function addDaysToDateInputValue(dateValue: string, days: number): string {
|
|||||||
].join('-')
|
].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[] {
|
function formatDateParts(date: Date, timeZone: string): Intl.DateTimeFormatPart[] {
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
timeZone,
|
timeZone,
|
||||||
|
|||||||
@@ -449,6 +449,8 @@ M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填
|
|||||||
- Company、Attention、Address、Tel、Email 是一组收件方联系人档案,不是五个互相独立字段;选择 Company 后应刷新 Attention 候选,选择 Attention 后应带出 Address、Tel、Email。Booking Date 展示在同一区域,但不属于联系人档案,应作为 `document.booking_date` 独立提交。
|
- 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` 为准。
|
- 第一阶段已确认三组客户 / 旅行社种子数据:`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 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 是只读计算字段;前端可展示预览,但最终金额以后端计算和模板公式为准。
|
- Amount、Sub-Total、VAT、Total 是只读计算字段;前端可展示预览,但最终金额以后端计算和模板公式为准。
|
||||||
- 酒店名称、Tax ID、法人主体、银行账户、Logo 和固定付款文案不应作为每张 Invoice 的手工输入;第一阶段可由模板或酒店发票配置提供。
|
- 酒店名称、Tax ID、法人主体、银行账户、Logo 和固定付款文案不应作为每张 Invoice 的手工输入;第一阶段可由模板或酒店发票配置提供。
|
||||||
- 正式业务生成接口为 `POST /api/reservation/invoices/manual-generations`,需要 Bearer token、酒店访问权和 `RESERVATION_INVOICE_GENERATE` 权限。
|
- 正式业务生成接口为 `POST /api/reservation/invoices/manual-generations`,需要 Bearer token、酒店访问权和 `RESERVATION_INVOICE_GENERATE` 权限。
|
||||||
|
|||||||
@@ -1175,6 +1175,7 @@ POST /api/reservation/invoices/manual-generations
|
|||||||
- 如果前端同时传 `task_id` 和 `order_id`,必须保证任务属于该订单;后端不一致时返回 `RESERVATION_INVOICE_CONTEXT_MISMATCH`。
|
- 如果前端同时传 `task_id` 和 `order_id`,必须保证任务属于该订单;后端不一致时返回 `RESERVATION_INVOICE_CONTEXT_MISMATCH`。
|
||||||
- `recipient.company_code` 和 `recipient.contact_id` 用于表达目录选择结果;`company`、`attention`、`address`、`telephone`、`email` 是最终用于生成 PDF 的文本值,Manual 覆盖时也必须提交。
|
- `recipient.company_code` 和 `recipient.contact_id` 用于表达目录选择结果;`company`、`attention`、`address`、`telephone`、`email` 是最终用于生成 PDF 的文本值,Manual 覆盖时也必须提交。
|
||||||
- 后端需要重新计算金额、VAT 和合计;前端计算只做预览。
|
- 后端需要重新计算金额、VAT 和合计;前端计算只做预览。
|
||||||
|
- `charges[].rate` 必填且必须是数字,允许 `0`,不能小于 `0`;`charges[].quantity` 和 `charges[].nights` 仍必须大于 `0`。
|
||||||
- 后端需要返回可预览 / 下载的 PDF URL。
|
- 后端需要返回可预览 / 下载的 PDF URL。
|
||||||
- `pdf_url` 第一版可作为预览地址;前端会优先用浏览器 `fetch + Blob` 触发下载,避免跨域场景下 `<a download>` 失效。如果 OSS CORS 不允许浏览器读取文件,前端会退回打开 PDF 页面。后续如要求稳定下载体验,建议后端提供带 `Content-Disposition` 的受控下载代理或签名下载 URL。
|
- `pdf_url` 第一版可作为预览地址;前端会优先用浏览器 `fetch + Blob` 触发下载,避免跨域场景下 `<a download>` 失效。如果 OSS CORS 不允许浏览器读取文件,前端会退回打开 PDF 页面。后续如要求稳定下载体验,建议后端提供带 `Content-Disposition` 的受控下载代理或签名下载 URL。
|
||||||
- 该接口应走 Bearer 登录、酒店访问权和 `RESERVATION_INVOICE_GENERATE` 权限,不走 M008 调试上传 access key。
|
- 该接口应走 Bearer 登录、酒店访问权和 `RESERVATION_INVOICE_GENERATE` 权限,不走 M008 调试上传 access key。
|
||||||
|
|||||||
@@ -135,11 +135,11 @@
|
|||||||
| Arrival Date | 日期输入 | 手工填写;任务预填时来自任务字段 |
|
| Arrival Date | 日期输入 | 手工填写;任务预填时来自任务字段 |
|
||||||
| Departure Date | 日期输入 | 手工填写;任务预填时来自任务字段 |
|
| Departure Date | 日期输入 | 手工填写;任务预填时来自任务字段 |
|
||||||
| Due Date | 日期输入 | 手工填写;可按酒店付款规则默认生成 |
|
| Due Date | 日期输入 | 手工填写;可按酒店付款规则默认生成 |
|
||||||
| Room Rate | 选择 + 可手填 | 第一阶段可手填;后续接 Rate Code / 房价配置 |
|
| Room Rate | 选择 + 可手填 | 第一阶段可手填;必填且必须是数字,允许 `0`,不能小于 `0`;后续接 Rate Code / 房价配置 |
|
||||||
| Room Rate Note | 输入框 | 例如 `includingBF` |
|
| Room Rate Note | 输入框 | 例如 `includingBF` |
|
||||||
| Extra bed | 选择 + 可手填 | 第一阶段可手填;后续接加床价格配置 |
|
| Extra bed | 选择 + 可手填 | 第一阶段可手填;后续接加床价格配置 |
|
||||||
| No. of Room(s) | 输入框 / 只读派生 | 可由第一条明细同步,也允许手工覆盖 |
|
| No. of Room(s) | 输入框 / 只读派生 | 可由第一条明细同步,也允许手工覆盖 |
|
||||||
| No. of Night(s) | 输入框 / 日期派生 | 可由 Arrival / Departure 派生,也允许手工覆盖 |
|
| No. of Night(s) | 输入框 / 日期派生 | 前端默认按 `Departure Date - Arrival Date` 自动填入第一条明细 Night(s),用户手工修改后不再覆盖 |
|
||||||
|
|
||||||
### 4.3 Description & Amount
|
### 4.3 Description & Amount
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
| Description | 输入框 | 手工填写;默认可使用 Group Name 或 Booking No |
|
| Description | 输入框 | 手工填写;默认可使用 Group Name 或 Booking No |
|
||||||
| Room Types | 选择 + 可手填 | 第一阶段可手填;后续接 PMS 房型目录 |
|
| Room Types | 选择 + 可手填 | 第一阶段可手填;后续接 PMS 房型目录 |
|
||||||
| Quantity(s) | 数字输入 | 后端要求正数 |
|
| Quantity(s) | 数字输入 | 后端要求正数 |
|
||||||
| Rate | 数字输入 | 后端要求正数 |
|
| Rate | 数字输入 | 后端要求必填、数字且不能小于 `0`,允许 `0` |
|
||||||
| Night(s) | 数字输入 | 后端要求正数 |
|
| Night(s) | 数字输入 | 后端要求正数 |
|
||||||
| Amount | 只读 | `quantity * rate * nights` |
|
| Amount | 只读 | `quantity * rate * nights` |
|
||||||
| Sub-Total | 只读 | 后端按 VAT 规则计算 |
|
| Sub-Total | 只读 | 后端按 VAT 规则计算 |
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ public class ReservationInvoiceGenerationServiceImpl implements ReservationInvoi
|
|||||||
errors.add(prefix + "room_type: 必填字段缺失。");
|
errors.add(prefix + "room_type: 必填字段缺失。");
|
||||||
}
|
}
|
||||||
validatePositive(charge.quantity(), prefix + "quantity", errors);
|
validatePositive(charge.quantity(), prefix + "quantity", errors);
|
||||||
validatePositive(charge.rate(), prefix + "rate", errors);
|
validateNonNegative(charge.rate(), prefix + "rate", errors);
|
||||||
validatePositive(charge.nights(), prefix + "nights", errors);
|
validatePositive(charge.nights(), prefix + "nights", errors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,6 +490,16 @@ public class ReservationInvoiceGenerationServiceImpl implements ReservationInvoi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateNonNegative(BigDecimal value, String fieldPath, List<String> errors) {
|
||||||
|
if (value == null) {
|
||||||
|
errors.add(fieldPath + ": 必填字段缺失。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
errors.add(fieldPath + ": 不能小于 0。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String writeJson(Object value) {
|
private String writeJson(Object value) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.writeValueAsString(value);
|
return objectMapper.writeValueAsString(value);
|
||||||
|
|||||||
@@ -195,6 +195,34 @@ class ReservationInvoiceGenerationControllerTest {
|
|||||||
verifyNoInteractions(excelToPdfConverter, objectStorageService);
|
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
|
@Test
|
||||||
void shouldRejectManualInvoiceWhenTaskDoesNotBelongToOrder() throws Exception {
|
void shouldRejectManualInvoiceWhenTaskDoesNotBelongToOrder() throws Exception {
|
||||||
insertOrder(2080010000000000001L, 2080010000000000101L, "GRP-M009-ORDER-1");
|
insertOrder(2080010000000000001L, 2080010000000000101L, "GRP-M009-ORDER-1");
|
||||||
|
|||||||
Reference in New Issue
Block a user