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 export function groupReservationFields( fields: ReservationTaskFieldResult[], fallbackArea: string, values: ReservationRecord = {}, ): FieldAreaGroup[] { const grouped = new Map() 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((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((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((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()) }