Files
Wyndham-RSVN/client/src/utils/reservationFieldRules.ts
T

454 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}