接入前端真实接口并标记邮件HTML清洗

This commit is contained in:
andy
2026-07-08 23:37:03 +08:00
parent c83935a26c
commit a0cb3a05d7
25 changed files with 2581 additions and 157 deletions

View File

@@ -13,9 +13,22 @@ export interface EnumOption {
label: string
}
export function groupReservationFields(fields: ReservationTaskFieldResult[], fallbackArea: string): FieldAreaGroup[] {
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(isReservationFieldVisible).forEach((field) => {
fields.filter((field) => isReservationFieldActive(field, values)).forEach((field) => {
const area = field.display_area?.trim() || fallbackArea
grouped.set(area, [...(grouped.get(area) ?? []), field])
})
@@ -30,6 +43,71 @@ export function isReservationFieldVisible(field: ReservationTaskFieldResult): bo
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)
}
@@ -95,6 +173,105 @@ export function buildInitialFieldValues(fields: ReservationTaskFieldResult[], pa
}, {})
}
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
@@ -108,6 +285,27 @@ export function stringifyReservationValue(value: unknown, emptyLabel: string): s
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