454 lines
15 KiB
TypeScript
454 lines
15 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', '隐藏'])
|
||
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<string, string[]> = {
|
||
'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<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 {
|
||
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<ReservationRecord>((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<ReservationRecord>((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<ReservationFieldValidationErrors>((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<unknown>((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())
|
||
}
|