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', '隐藏']) const normalPayloadEditScopes = new Set(['NORMAL_TASK', 'NORMAL_AND_MANUAL_REVIEW']) const manualReviewEditScopes = new Set(['MANUAL_REVIEW_ONLY', 'NORMAL_AND_MANUAL_REVIEW']) const editableControlTypes = new Set(['TEXT', 'TEXTAREA', 'INPUT', 'SELECT', 'DATE', 'NUMBER']) const readonlyControlTypes = new Set(['READONLY', 'FILE', 'WORKFLOW_STATE', 'STRUCTURED_TABLE']) const staticOptionSources = new Set(['STATIC_ENUM']) const payloadWriteTargets = new Set([ 'FIELD_VALUES', 'DRAFT_PAYLOAD.FIELD_VALUES', 'CONFIRMED_PAYLOAD.FIELD_VALUES', 'TASK_FIELD_VALUES', 'DRAFT_PAYLOAD_AND_REVIEW_RESOLUTION', ]) const manualReviewWriteTargets = new Set([ 'REVIEW_RESOLUTION.FIELD_OVERRIDES', 'FIELD_OVERRIDES', 'DRAFT_PAYLOAD_AND_REVIEW_RESOLUTION', ]) const roomItemFieldPathAliases: Record = { 'extracted_fields.room_quantity': ['extracted_fields.room_items.0.room_quantity'], 'extracted_fields.room_type': [ 'extracted_fields.room_items.0.room_type_raw', 'extracted_fields.room_items.0.room_type_normalized', ], 'extracted_fields.pms_room_type_code': ['extracted_fields.room_items.0.pms_room_type_code'], 'extracted_fields.room_items.0.room_quantity': ['extracted_fields.room_quantity'], 'extracted_fields.room_items.0.room_type_raw': [ 'extracted_fields.room_type', 'extracted_fields.room_items.0.room_type_normalized', ], 'extracted_fields.room_items.0.pms_room_type_code': ['extracted_fields.pms_room_type_code'], } 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 { void values return isReservationFieldVisible(field) } export function isReservationFieldRequired(field: ReservationTaskFieldResult): boolean { return isRuleTruthy(field.required_rule) } export function isReservationFieldEditable(field: ReservationTaskFieldResult, readOnly: boolean): boolean { if (readOnly) { return false } if (hasControlMetadata(field)) { return isReservationFieldEditableByControlMetadata(field) } 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 ( normalizeControlValue(field.control_type) === 'SELECT' || isRuleTruthy(field.select_editable) ) && isStaticOptionsSource(field) && parseEnumOptions(field.enum_options).length > 0 } export function isDateField(field: ReservationTaskFieldResult): boolean { return normalizeControlValue(field.control_type) === 'DATE' || isRuleTruthy(field.date_picker) } export function isNumberField(field: ReservationTaskFieldResult): boolean { return normalizeControlValue(field.control_type) === 'NUMBER' || isRuleTruthy(field.number_input) } export function isFileField(field: ReservationTaskFieldResult): boolean { return normalizeControlValue(field.control_type) === 'FILE' || isRuleTruthy(field.file_display) } export function isTableField(field: ReservationTaskFieldResult): boolean { return normalizeControlValue(field.control_type) === 'STRUCTURED_TABLE' || 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, fieldContractVersion?: string | null, ): ReservationRecord { return fields.reduce((values, field) => { values[field.field_path] = readReservationFieldValue(field, payload ?? {}, fieldContractVersion) return values }, {}) } export function buildEditableFieldValues( fields: ReservationTaskFieldResult[], values: ReservationRecord, readOnly: boolean, ): ReservationRecord { if (readOnly) { return {} } return fields .filter((field) => isReservationFieldActive(field, values)) .filter((field) => isReservationFieldPayloadWritable(field, readOnly)) .reduce((payload, field) => { payload[field.field_path] = readReservationFieldValue(field, values) return payload }, {}) } export function isReservationManualReviewFieldWritable(field: ReservationTaskFieldResult): boolean { if (!hasControlMetadata(field)) { return true } if (isRawReadonly(field.raw_readonly)) { return false } const editScope = normalizeControlValue(field.edit_scope) if (editScope && !manualReviewEditScopes.has(editScope)) { return false } const writeTarget = normalizeControlValue(field.write_target) return !writeTarget || manualReviewWriteTargets.has(writeTarget) } export function validateReservationFieldValues( fields: ReservationTaskFieldResult[], values: ReservationRecord, ): ReservationFieldValidationErrors { return fields.filter((field) => isReservationFieldActive(field, values)).reduce((errors, field) => { const value = readReservationFieldValue(field, values) const shouldValidateInput = isReservationFieldEditable(field, false) if (shouldValidateInput && isReservationFieldRequired(field) && isEmptyReservationValue(value)) { errors[field.field_path] = createValidationError(field, 'required') return errors } if (!shouldValidateInput || 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 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 } export function readReservationFieldValue( field: ReservationTaskFieldResult, values: ReservationRecord, fieldContractVersion?: string | null, ): unknown { const fieldValues = recordProperty(values, 'field_values') ?? values const legacyValues = recordProperty(values, 'legacy_field_values') const effectivePayload = recordProperty(values, 'effective_payload') const legacyFieldPath = field.legacy_field_path ?? undefined const codeV1 = normalizeContractVersion(fieldContractVersion) === 'CODE_V1' const mainCandidates = fieldPathCandidates(field.field_path) const legacyCandidates = legacyFieldPath ? fieldPathCandidates(legacyFieldPath) : [] if (codeV1) { return firstDefined([ () => firstValueByPaths(legacyValues, legacyCandidates), () => firstValueByPaths(fieldValues, legacyCandidates), () => firstNestedValueByPaths(fieldValues, legacyCandidates), () => firstValueByPaths(values, legacyCandidates), () => firstNestedValueByPaths(effectivePayload, legacyCandidates), () => firstValueByPaths(fieldValues, mainCandidates), () => firstNestedValueByPaths(fieldValues, mainCandidates), () => firstValueByPaths(values, mainCandidates), () => firstNestedValueByPaths(effectivePayload, mainCandidates), () => field.value, () => '', ]) } return firstDefined([ () => firstValueByPaths(fieldValues, mainCandidates), () => firstNestedValueByPaths(fieldValues, mainCandidates), () => firstValueByPaths(values, mainCandidates), () => firstNestedValueByPaths(effectivePayload, mainCandidates), () => firstValueByPaths(legacyValues, legacyCandidates), () => firstValueByPaths(fieldValues, legacyCandidates), () => firstNestedValueByPaths(fieldValues, legacyCandidates), () => firstValueByPaths(values, legacyCandidates), () => field.value, () => '', ]) } function isReservationFieldPayloadWritable(field: ReservationTaskFieldResult, readOnly: boolean): boolean { if (!isReservationFieldEditable(field, readOnly)) { return false } if (!hasControlMetadata(field)) { return true } const writeTarget = normalizeControlValue(field.write_target) return !writeTarget || payloadWriteTargets.has(writeTarget) } function isReservationFieldEditableByControlMetadata(field: ReservationTaskFieldResult): boolean { if (isRawReadonly(field.raw_readonly)) { return false } const editScope = normalizeControlValue(field.edit_scope) if (editScope && !normalPayloadEditScopes.has(editScope)) { return false } const writeTarget = normalizeControlValue(field.write_target) if (writeTarget === 'NONE') { return false } if (writeTarget && !payloadWriteTargets.has(writeTarget)) { return false } const controlType = normalizeControlValue(field.control_type) if (readonlyControlTypes.has(controlType)) { return false } if (controlType) { return editableControlTypes.has(controlType) } return ( isRuleTruthy(field.editable) || isRuleTruthy(field.input_editable) || isRuleTruthy(field.select_editable) || isRuleTruthy(field.date_picker) || isRuleTruthy(field.number_input) ) } function isStaticOptionsSource(field: ReservationTaskFieldResult): boolean { const optionsSource = normalizeControlValue(field.options_source) return !optionsSource || staticOptionSources.has(optionsSource) } function hasControlMetadata(field: ReservationTaskFieldResult): boolean { return Boolean( field.control_type || field.edit_scope || field.write_target || field.options_source || field.raw_readonly !== undefined && field.raw_readonly !== null || field.control_hint, ) } function isRawReadonly(value: boolean | string | null | undefined): boolean { return value === true || String(value).trim().toUpperCase() === 'TRUE' } function normalizeControlValue(value: string | null | undefined): string { return value?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? '' } function fieldPathCandidates(fieldPath: string): string[] { return [fieldPath, ...(roomItemFieldPathAliases[fieldPath] ?? [])] } function firstValueByPaths(values: ReservationRecord | undefined, fieldPaths: string[]): unknown { if (!values) { return undefined } for (const fieldPath of fieldPaths) { if (Object.prototype.hasOwnProperty.call(values, fieldPath)) { return values[fieldPath] } } return undefined } function firstNestedValueByPaths(values: ReservationRecord | undefined, fieldPaths: string[]): unknown { if (!values) { return undefined } for (const fieldPath of fieldPaths) { const value = readNestedPath(values, fieldPath) if (value !== undefined) { return value } } return undefined } function readNestedPath(values: ReservationRecord, fieldPath: string): unknown { return fieldPath.split('.').reduce((current, segment) => { if (current === null || current === undefined) { return undefined } if (Array.isArray(current)) { const index = Number(segment) return Number.isInteger(index) ? current[index] : undefined } if (typeof current === 'object' && Object.prototype.hasOwnProperty.call(current, segment)) { return (current as ReservationRecord)[segment] } return undefined }, values) } function firstDefined(candidates: Array<() => unknown>): unknown { for (const candidate of candidates) { const value = candidate() if (value !== undefined && value !== null) { return value } } return '' } function recordProperty(values: ReservationRecord, key: string): ReservationRecord | undefined { const value = values[key] return value && typeof value === 'object' && !Array.isArray(value) ? value as ReservationRecord : undefined } function normalizeContractVersion(version: string | null | undefined): string { return version?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? '' } 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()) }