322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import type { ReservationRecord, ReservationTaskFieldResult } from '@/types/reservation'
|
||
|
||
const truthyRuleValues = new Set(['Y', 'YES', 'TRUE', '1', '是', '可编辑', 'ENABLED'])
|
||
const falsyRuleValues = new Set(['N', 'NO', 'FALSE', '0', '否', '不可编辑', 'DISABLED', '隐藏'])
|
||
|
||
export interface FieldAreaGroup {
|
||
area: string
|
||
fields: ReservationTaskFieldResult[]
|
||
}
|
||
|
||
export interface EnumOption {
|
||
value: string
|
||
label: string
|
||
}
|
||
|
||
export type ReservationFieldValidationErrorCode = 'required' | 'enum' | 'date' | 'number'
|
||
|
||
export interface ReservationFieldValidationError {
|
||
code: ReservationFieldValidationErrorCode
|
||
field_name: string
|
||
}
|
||
|
||
export type ReservationFieldValidationErrors = Record<string, ReservationFieldValidationError>
|
||
|
||
export function groupReservationFields(
|
||
fields: ReservationTaskFieldResult[],
|
||
fallbackArea: string,
|
||
values: ReservationRecord = {},
|
||
): FieldAreaGroup[] {
|
||
const grouped = new Map<string, ReservationTaskFieldResult[]>()
|
||
fields.filter((field) => isReservationFieldActive(field, values)).forEach((field) => {
|
||
const area = field.display_area?.trim() || fallbackArea
|
||
grouped.set(area, [...(grouped.get(area) ?? []), field])
|
||
})
|
||
|
||
return Array.from(grouped.entries()).map(([area, areaFields]) => ({
|
||
area,
|
||
fields: areaFields.sort((left, right) => (left.row_number ?? 0) - (right.row_number ?? 0)),
|
||
}))
|
||
}
|
||
|
||
export function isReservationFieldVisible(field: ReservationTaskFieldResult): boolean {
|
||
return !isRuleFalsy(field.visible)
|
||
}
|
||
|
||
export function isReservationFieldActive(field: ReservationTaskFieldResult, values: ReservationRecord = {}): boolean {
|
||
if (!isReservationFieldVisible(field)) {
|
||
return false
|
||
}
|
||
const condition = field.display_condition?.trim()
|
||
if (!condition || condition === '-' || condition.includes('始终展示')) {
|
||
return true
|
||
}
|
||
const currentValue = valueForConditionPath(values, field, field.field_path)
|
||
if (condition.includes('有值时展示')) {
|
||
return !isEmptyReservationValue(currentValue)
|
||
}
|
||
if (condition.includes('有附件')) {
|
||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'attachments'))
|
||
}
|
||
if (condition.includes('对象为FIT Reservation')) {
|
||
return isFitTarget(values, field)
|
||
}
|
||
if (condition.includes('对象为Group Block或Allotment')) {
|
||
return isGroupOrAllotmentTarget(values, field)
|
||
}
|
||
if (condition.includes('涉及房型')) {
|
||
return (
|
||
matrixContains(field.task_type, 'New Booking') ||
|
||
!isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.room_type')) ||
|
||
!isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.pms_room_type_code'))
|
||
)
|
||
}
|
||
if (condition.includes('涉及Rate Code') || condition.includes('涉及价格/Rate Code')) {
|
||
return (
|
||
!isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.rate_code_result')) ||
|
||
hasNonEmptyValueWithPrefix(values, 'extracted_fields.rate_code_result.') ||
|
||
matrixContains(field.task_subtype, 'manual_rate_code_or_settlement_price')
|
||
)
|
||
}
|
||
if (condition.includes('需要价格确认')) {
|
||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.rate_code_result.settlement_price'))
|
||
}
|
||
if (condition.includes('有复合单价') || condition.includes('fix_charge_required=true')) {
|
||
return (
|
||
isBooleanTrue(valueForConditionPath(values, field, 'extracted_fields.fix_charge_required')) ||
|
||
!isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.fix_charge_items[]'))
|
||
)
|
||
}
|
||
if (condition.includes('allocation_split_from_parent=true')) {
|
||
return isBooleanTrue(valueForConditionPath(values, field, 'extracted_fields.allocation_split_from_parent'))
|
||
}
|
||
if (condition.includes('来自QBD/LianTai表格')) {
|
||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.table_evidence'))
|
||
}
|
||
if (condition.includes('有Group目标')) {
|
||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'case_keys.group_code')) || isGroupOrAllotmentTarget(values, field)
|
||
}
|
||
if (condition.includes('有FIT目标') || condition.includes('FIT取消')) {
|
||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'case_keys.confirmation_number')) || isFitTarget(values, field)
|
||
}
|
||
if (condition.includes('有before/after修改')) {
|
||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.before_after'))
|
||
}
|
||
if (condition.includes('update_fix_charge')) {
|
||
return matrixContains(field.task_subtype, 'update_fix_charge') || isBooleanTrue(valueForConditionPath(values, field, 'extracted_fields.fix_charge_required'))
|
||
}
|
||
return true
|
||
}
|
||
|
||
export function isReservationFieldRequired(field: ReservationTaskFieldResult): boolean {
|
||
return isRuleTruthy(field.required_rule)
|
||
}
|
||
|
||
export function isReservationFieldEditable(field: ReservationTaskFieldResult, readOnly: boolean): boolean {
|
||
if (readOnly) {
|
||
return false
|
||
}
|
||
return (
|
||
isRuleTruthy(field.editable) ||
|
||
isRuleTruthy(field.input_editable) ||
|
||
isRuleTruthy(field.select_editable) ||
|
||
isRuleTruthy(field.date_picker) ||
|
||
isRuleTruthy(field.number_input) ||
|
||
isRuleTruthy(field.table_editable)
|
||
)
|
||
}
|
||
|
||
export function isSelectField(field: ReservationTaskFieldResult): boolean {
|
||
return isRuleTruthy(field.select_editable) && parseEnumOptions(field.enum_options).length > 0
|
||
}
|
||
|
||
export function isDateField(field: ReservationTaskFieldResult): boolean {
|
||
return isRuleTruthy(field.date_picker)
|
||
}
|
||
|
||
export function isNumberField(field: ReservationTaskFieldResult): boolean {
|
||
return isRuleTruthy(field.number_input)
|
||
}
|
||
|
||
export function isFileField(field: ReservationTaskFieldResult): boolean {
|
||
return isRuleTruthy(field.file_display)
|
||
}
|
||
|
||
export function isTableField(field: ReservationTaskFieldResult): boolean {
|
||
return isRuleTruthy(field.table_editable)
|
||
}
|
||
|
||
export function parseEnumOptions(enumOptions: string | null): EnumOption[] {
|
||
if (!enumOptions?.trim()) {
|
||
return []
|
||
}
|
||
|
||
return enumOptions
|
||
.split(/[,,;;/]/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.map((item) => {
|
||
const separatorIndex = item.indexOf(':')
|
||
const value = separatorIndex >= 0 ? item.slice(0, separatorIndex) : item
|
||
const label = separatorIndex >= 0 ? item.slice(separatorIndex + 1) : value
|
||
return {
|
||
value: value.trim(),
|
||
label: label.trim(),
|
||
}
|
||
})
|
||
}
|
||
|
||
export function buildInitialFieldValues(fields: ReservationTaskFieldResult[], payload: ReservationRecord | null): ReservationRecord {
|
||
return fields.reduce<ReservationRecord>((values, field) => {
|
||
values[field.field_path] = payload?.[field.field_path] ?? field.value ?? ''
|
||
return values
|
||
}, {})
|
||
}
|
||
|
||
export function buildEditableFieldValues(
|
||
fields: ReservationTaskFieldResult[],
|
||
values: ReservationRecord,
|
||
readOnly: boolean,
|
||
): ReservationRecord {
|
||
if (readOnly) {
|
||
return {}
|
||
}
|
||
return fields
|
||
.filter((field) => isReservationFieldActive(field, values))
|
||
.filter((field) => isReservationFieldEditable(field, readOnly))
|
||
.reduce<ReservationRecord>((payload, field) => {
|
||
payload[field.field_path] = field.field_path in values ? values[field.field_path] : field.value ?? ''
|
||
return payload
|
||
}, {})
|
||
}
|
||
|
||
export function validateReservationFieldValues(
|
||
fields: ReservationTaskFieldResult[],
|
||
values: ReservationRecord,
|
||
): ReservationFieldValidationErrors {
|
||
return fields.filter((field) => isReservationFieldActive(field, values)).reduce<ReservationFieldValidationErrors>((errors, field) => {
|
||
const value = values[field.field_path] ?? field.value ?? ''
|
||
if (isReservationFieldRequired(field) && isEmptyReservationValue(value)) {
|
||
errors[field.field_path] = createValidationError(field, 'required')
|
||
return errors
|
||
}
|
||
|
||
if (isEmptyReservationValue(value)) {
|
||
return errors
|
||
}
|
||
|
||
if (isSelectField(field) && !parseEnumOptions(field.enum_options).some((option) => option.value === String(value))) {
|
||
errors[field.field_path] = createValidationError(field, 'enum')
|
||
return errors
|
||
}
|
||
|
||
if (isDateField(field) && !isIsoLocalDate(String(value))) {
|
||
errors[field.field_path] = createValidationError(field, 'date')
|
||
return errors
|
||
}
|
||
|
||
if (isNumberField(field) && !Number.isFinite(Number(value))) {
|
||
errors[field.field_path] = createValidationError(field, 'number')
|
||
}
|
||
return errors
|
||
}, {})
|
||
}
|
||
|
||
function valueForConditionPath(
|
||
values: ReservationRecord,
|
||
currentField: ReservationTaskFieldResult,
|
||
fieldPath: string,
|
||
): unknown {
|
||
if (fieldPath in values) {
|
||
return values[fieldPath]
|
||
}
|
||
if (fieldPath === currentField.field_path) {
|
||
return currentField.value
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function hasNonEmptyValueWithPrefix(values: ReservationRecord, prefix: string): boolean {
|
||
return Object.entries(values).some(([key, value]) => key.startsWith(prefix) && !isEmptyReservationValue(value))
|
||
}
|
||
|
||
function isFitTarget(values: ReservationRecord, field: ReservationTaskFieldResult): boolean {
|
||
const target = stringifyConditionValue(valueForConditionPath(values, field, 'extracted_fields.booking_object_type'))
|
||
return target.includes('FIT') || target.includes('RESERVATION')
|
||
}
|
||
|
||
function isGroupOrAllotmentTarget(values: ReservationRecord, field: ReservationTaskFieldResult): boolean {
|
||
const target = stringifyConditionValue(valueForConditionPath(values, field, 'extracted_fields.booking_object_type'))
|
||
return target.includes('GROUP') || target.includes('ALLOTMENT') || !isEmptyReservationValue(valueForConditionPath(values, field, 'case_keys.group_code'))
|
||
}
|
||
|
||
function stringifyConditionValue(value: unknown): string {
|
||
return value === null || value === undefined ? '' : String(value).trim().toUpperCase()
|
||
}
|
||
|
||
function matrixContains(value: string | null | undefined, keyword: string): boolean {
|
||
return Boolean(value?.toLowerCase().includes(keyword.toLowerCase()))
|
||
}
|
||
|
||
function isBooleanTrue(value: unknown): boolean {
|
||
return value === true || String(value).trim().toLowerCase() === 'true' || String(value).trim() === '是'
|
||
}
|
||
|
||
function createValidationError(
|
||
field: ReservationTaskFieldResult,
|
||
code: ReservationFieldValidationErrorCode,
|
||
): ReservationFieldValidationError {
|
||
return {
|
||
code,
|
||
field_name: field.display_name,
|
||
}
|
||
}
|
||
|
||
export function stringifyReservationValue(value: unknown, emptyLabel: string): string {
|
||
if (value === null || value === undefined || value === '') {
|
||
return emptyLabel
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.map((item) => stringifyReservationValue(item, emptyLabel)).join(', ')
|
||
}
|
||
if (typeof value === 'object') {
|
||
return JSON.stringify(value)
|
||
}
|
||
return String(value)
|
||
}
|
||
|
||
function isEmptyReservationValue(value: unknown): boolean {
|
||
if (value === null || value === undefined) {
|
||
return true
|
||
}
|
||
if (typeof value === 'string') {
|
||
return value.trim() === ''
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.length === 0
|
||
}
|
||
return false
|
||
}
|
||
|
||
function isIsoLocalDate(value: string): boolean {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||
return false
|
||
}
|
||
const date = new Date(`${value}T00:00:00Z`)
|
||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||
}
|
||
|
||
function isRuleTruthy(value: string | null): boolean {
|
||
if (!value) {
|
||
return false
|
||
}
|
||
return truthyRuleValues.has(value.trim().toUpperCase()) || truthyRuleValues.has(value.trim())
|
||
}
|
||
|
||
function isRuleFalsy(value: string | null): boolean {
|
||
if (!value) {
|
||
return false
|
||
}
|
||
return falsyRuleValues.has(value.trim().toUpperCase()) || falsyRuleValues.has(value.trim())
|
||
}
|