printInputOffset.ts 10.7 KB
/**
 * 与 Web 管理端 labelFormDatePreview 一致:templateProductDefaults 中日期/时间/时长存 JSON
 * `{"unit":"Days","value":"2"}`,预览与打印时按 BaseTime 解析为 config.text。
 */
import type { SystemTemplateElementBase } from '../print/types/printer'

const OFFSET_UNITS = new Set([
  'Minutes',
  'Hours',
  'Days',
  'Weeks',
  'Months (30 Day)',
  'Years',
])

export function applyOffsetToDate (base: Date, amount: number, unit: string): Date {
  const d = new Date(base.getTime())
  const u = String(unit ?? '').trim()
  switch (u) {
    case 'Minutes':
      d.setMinutes(d.getMinutes() + amount)
      break
    case 'Hours':
      d.setHours(d.getHours() + amount)
      break
    case 'Days':
      d.setDate(d.getDate() + amount)
      break
    case 'Weeks':
      d.setDate(d.getDate() + amount * 7)
      break
    case 'Months (30 Day)':
      d.setDate(d.getDate() + amount * 30)
      break
    case 'Years':
      d.setFullYear(d.getFullYear() + amount)
      break
    default:
      d.setDate(d.getDate() + amount)
  }
  return d
}

function formatDateByPreset (format: string, date: Date): string {
  const yyyy = String(date.getFullYear())
  const yy = yyyy.slice(-2)
  const mm = String(date.getMonth() + 1).padStart(2, '0')
  const dd = String(date.getDate()).padStart(2, '0')
  const hh = String(date.getHours()).padStart(2, '0')
  const min = String(date.getMinutes()).padStart(2, '0')
  switch (format) {
    case 'DD/MM/YYYY':
      return `${dd}/${mm}/${yyyy}`
    case 'MM/DD/YYYY':
      return `${mm}/${dd}/${yyyy}`
    case 'DD/MM/YY':
      return `${dd}/${mm}/${yy}`
    case 'MM/DD/YY':
      return `${mm}/${dd}/${yy}`
    case 'MM/YY':
      return `${mm}/${yy}`
    case 'MM/DD':
      return `${mm}/${dd}`
    case 'MM':
      return mm
    case 'DD':
      return dd
    case 'YY':
      return yy
    case 'YYYY-MM-DD':
      return `${yyyy}-${mm}-${dd}`
    case 'YYYY-MM-DD HH:mm':
      return `${yyyy}-${mm}-${dd} ${hh}:${min}`
    case 'HH:mm':
      return `${hh}:${min}`
    default:
      return String(format || '')
        .replace(/YYYY/g, yyyy)
        .replace(/YY/g, yy)
        .replace(/MM/g, mm)
        .replace(/DD/g, dd)
        .replace(/HH/g, hh)
        .replace(/mm/g, min)
  }
}

/** 已算好的日期/时间展示串(非 format 预设名、非 JSON 偏移) */
export function isLikelyResolvedDateTimeLiteral (raw: string | null | undefined): boolean {
  const t = String(raw ?? '').trim()
  if (!t || t.startsWith('{')) return false
  if (OFFSET_UNITS.has(t)) return false
  if (/^\d{4}-\d{2}-\d{2}/.test(t)) return true
  if (/^\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}/.test(t)) return true
  if (/^\d{1,2}\/\d{1,2}\/\d{2,4}/.test(t)) return true
  if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(t)) return true
  return false
}

function applyElementPrefix (cfg: Record<string, unknown>, body: string): string {
  const prefix = String(cfg.prefix ?? cfg.Prefix ?? '')
  if (!prefix) return body
  if (body.startsWith(prefix)) return body
  return `${prefix}${body}`
}

function normalizeOffsetAmount (valueRaw: string): number | null {
  const t = String(valueRaw ?? '').trim()
  if (t === '') return 0
  const n = Number(t)
  return Number.isFinite(n) ? n : null
}

function dateFormatPatternForElement (
  el: SystemTemplateElementBase,
  cfg: Record<string, unknown>,
): string {
  const type = String(el.type || '').toUpperCase()
  if (type === 'TIME') return 'HH:mm'
  const it = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase()
  const raw =
    (typeof cfg.format === 'string' && cfg.format.trim()
      ? cfg.format
      : typeof cfg.Format === 'string' && cfg.Format.trim()
        ? cfg.Format
        : it === 'datetime'
          ? 'YYYY-MM-DD HH:mm'
          : 'DD/MM/YYYY') || 'DD/MM/YYYY'
  if (isLikelyResolvedDateTimeLiteral(raw) || OFFSET_UNITS.has(raw)) {
    return it === 'datetime' ? 'YYYY-MM-DD HH:mm' : 'DD/MM/YYYY'
  }
  return raw
}

function readOffsetFromElementConfig (
  el: SystemTemplateElementBase,
): { amount: number; unit: string } | null {
  const type = String(el.type || '').toUpperCase()
  const cfg = (el.config || {}) as Record<string, unknown>
  if (type === 'DURATION') {
    const unit = String(cfg.format ?? cfg.Format ?? 'Days').trim() || 'Days'
    const u = OFFSET_UNITS.has(unit) ? unit : 'Days'
    const rawV =
      cfg.durationValue ??
      cfg.value ??
      cfg.offsetDays ??
      cfg.DurationValue ??
      cfg.Value ??
      cfg.OffsetDays
    const val = Number(rawV)
    return { amount: Number.isFinite(val) ? val : 0, unit: u }
  }
  const od = cfg.offsetDays ?? cfg.OffsetDays
  if (od != null && String(od).trim() !== '') {
    const n = Number(od)
    if (Number.isFinite(n)) return { amount: n, unit: 'Days' }
  }
  const fmt = String(cfg.format ?? cfg.Format ?? '').trim()
  if (fmt && OFFSET_UNITS.has(fmt)) {
    const rawV = cfg.durationValue ?? cfg.value ?? cfg.DurationValue ?? cfg.Value ?? 0
    const val = Number(rawV)
    return { amount: Number.isFinite(val) ? val : 0, unit: fmt }
  }
  return null
}

function resolveFromOffsetAmount (
  el: SystemTemplateElementBase,
  amount: number,
  unit: string,
  base: Date,
): string {
  const type = String(el.type || '').toUpperCase()
  const cfg = (el.config || {}) as Record<string, unknown>
  const d = applyOffsetToDate(base, amount, unit)
  if (type === 'DATE') {
    return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d))
  }
  if (type === 'TIME') {
    return applyElementPrefix(cfg, formatDateByPreset('HH:mm', d))
  }
  return applyElementPrefix(cfg, `${amount} ${unit}`.trim())
}

/**
 * 画布预览 / 原生打印:按基准时刻解析 DATE/TIME/DURATION,忽略 AUTO_DB 上已过期的 text/format。
 */
export function resolveElementDateTimeDisplay (
  el: SystemTemplateElementBase,
  base: Date = new Date(),
): string | null {
  const type = String(el.type || '').toUpperCase()
  if (type !== 'DATE' && type !== 'TIME' && type !== 'DURATION') return null

  const cfg = (el.config || {}) as Record<string, unknown>
  const vst = String(el.valueSourceType ?? '').toUpperCase()
  const inputType = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase()
  const rawText = String(cfg.text ?? cfg.Text ?? '').trim()

  const fromOffsetJson = (raw: string): string | null => {
    const p = tryParsePrintInputOffsetStored(raw)
    if (!p) return null
    const amount = normalizeOffsetAmount(p.value)
    if (amount === null) return null
    return resolveFromOffsetAmount(el, amount, p.unit || 'Days', base)
  }

  const jsonResolved = (rawText ? fromOffsetJson(rawText) : null)
    ?? fromOffsetJson(String(cfg.format ?? cfg.Format ?? ''))
  if (jsonResolved) return jsonResolved

  if (vst === 'PRINT_INPUT') {
    /** 保质期/Prep 等相对偏移字段:须按 base 重算,勿用接口首屏写入的过期 literal */
    if (
      rawText &&
      !tryParsePrintInputOffsetStored(rawText) &&
      !isUniAppDateTimeOffsetField(el)
    ) {
      return applyElementPrefix(cfg, rawText)
    }
    if (
      isUniAppDateTimeOffsetField(el) ||
      inputType === 'date' ||
      inputType === 'datetime' ||
      type === 'TIME'
    ) {
      const off = readOffsetFromElementConfig(el)
      return resolveFromOffsetAmount(el, off?.amount ?? 0, off?.unit ?? 'Days', base)
    }
    return null
  }

  const off = readOffsetFromElementConfig(el)
  if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base)
  return resolveFromOffsetAmount(el, 0, 'Days', base)
}

export function tryParsePrintInputOffsetStored (
  raw: string | null | undefined,
): { unit: string; value: string } | null {
  const t = String(raw ?? '').trim()
  if (!t.startsWith('{')) return null
  try {
    const o = JSON.parse(t) as unknown
    if (o == null || typeof o !== 'object' || Array.isArray(o)) return null
    const rec = o as Record<string, unknown>
    const unit = String(rec.unit ?? rec.Unit ?? '').trim()
    const value = String(rec.value ?? rec.Value ?? '')
    if (!unit || !OFFSET_UNITS.has(unit)) return null
    return { unit, value }
  } catch {
    return null
  }
}

/** 与 Web isDateTimeDataEntryField 对齐(App 侧元素无 typeAdd 时用命名兜底) */
export function isUniAppDateTimeOffsetField (el: SystemTemplateElementBase): boolean {
  const type = String(el.type || '').toUpperCase()
  const cfg = (el.config || {}) as Record<string, unknown>
  if (type === 'TIME' || type === 'DURATION') return true
  if (type !== 'DATE') return false
  const it = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase()
  if (it === 'datetime' || it === 'date') return true
  const ta = String((el as { typeAdd?: string }).typeAdd ?? '').trim().toLowerCase().replace(/\s+/g, ' ')
  if (ta === 'label_duration date' || ta.includes('duration date')) return true
  if (ta === 'auto_current date' || ta.includes('current date')) return true
  if (ta === 'auto_current time' || ta.includes('current time')) return true
  const nameHint = `${el.elementName ?? ''} ${el.inputKey ?? ''}`.toLowerCase()
  if (/(durationdate|duration_date|duration\s*date)/.test(nameHint)) return true
  return false
}

/**
 * 将 templateProductDefaults 里单格字符串转为画布用的展示文案(相对 now)。
 * 非 JSON 或无法解析时返回原串(兼容旧版已展开的日期文案)。
 */
/**
 * 预览/出纸前:按同一基准时刻重算所有 DATE/TIME/DURATION 及偏移类字段,保证 Prep 与 Use By 一致。
 */
export function applyLiveDateTimeFieldsToTemplate (
  template: SystemLabelTemplate,
  base: Date = new Date(),
): SystemLabelTemplate {
  const elements = (template.elements || []).map((el) => {
    const type = String(el.type || '').toUpperCase()
    const needsLive =
      type === 'DATE' ||
      type === 'TIME' ||
      type === 'DURATION' ||
      isUniAppDateTimeOffsetField(el)
    if (!needsLive) return el
    const live = resolveElementDateTimeDisplay(el, base)
    if (live == null || !String(live).trim()) return el
    const cfg = { ...(el.config || {}) } as Record<string, unknown>
    cfg.text = live
    cfg.Text = live
    return { ...el, config: cfg }
  })
  return { ...template, elements }
}

export function resolveTemplateDefaultValueForElement (
  el: SystemTemplateElementBase,
  stored: string,
  now: Date,
): string {
  const s = String(stored ?? '').trim()
  if (!s) return ''
  if (!isUniAppDateTimeOffsetField(el)) return s
  const parsed = tryParsePrintInputOffsetStored(s)
  if (parsed) {
    const amount = normalizeOffsetAmount(parsed.value)
    if (amount === null) return ''
    return resolveFromOffsetAmount(el, amount, parsed.unit || 'Days', now)
  }
  if (isLikelyResolvedDateTimeLiteral(s)) {
    const off = readOffsetFromElementConfig(el)
    return resolveFromOffsetAmount(el, off?.amount ?? 0, off?.unit ?? 'Days', now)
  }
  return s
}