nutritionDefaultsMerge.ts 8.93 KB
/**
 * 将管理端保存的营养成分默认值 JSON 合并进 NUTRITION 元素 config(与 Web nutritionManualEntry 字段一致)。
 * `<` 前缀由模板 config 决定,录入 JSON 不包含 LessThan 字段。
 */
import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'
import { NUTRITION_FACTS_LAYOUT_ROWS, buildNutritionFactsViewModel, NUTRITION_BODY_FONT_SIZE } from '../nutritionFactsLayout'

/** 与 Web nutritionManualEntry.NUTRITION_FIELD_COMPOSITE_SEP 一致 */
export const NUTRITION_FIELD_COMPOSITE_SEP = '###nut###'

export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: string): string {
  return `${nutritionElementId}${NUTRITION_FIELD_COMPOSITE_SEP}${subKey}`
}

function defaultLookupCandidates(el: SystemTemplateElementBase): string[] {
  return [
    String(el.id ?? '').trim(),
    String(el.inputKey ?? '').trim(),
    String(el.elementName ?? '').trim(),
  ].filter(Boolean)
}

/** 从 templateProductDefaultValues 解析营养成分手动录入(JSON 或 composite 列键) */
export function collectNutritionManualFromDefaults(
  el: SystemTemplateElementBase,
  defaults: Record<string, string>,
): Record<string, string> {
  if (!defaults || !Object.keys(defaults).length) return {}
  const candidates = defaultLookupCandidates(el)
  for (const k of candidates) {
    if (Object.prototype.hasOwnProperty.call(defaults, k)) {
      const raw = String(defaults[k] ?? '').trim()
      if (raw.startsWith('{')) {
        try {
          const p = JSON.parse(raw) as Record<string, string>
          if (p && typeof p === 'object' && !Array.isArray(p)) return p
        } catch {
          /* ignore */
        }
      }
    }
  }
  const lower = Object.entries(defaults).map(([k, v]) => [k.toLowerCase(), v] as const)
  for (const k of candidates) {
    const hit = lower.find(([dk]) => dk === k.toLowerCase())
    if (hit) {
      const raw = String(hit[1] ?? '').trim()
      if (raw.startsWith('{')) {
        try {
          return JSON.parse(raw) as Record<string, string>
        } catch {
          /* ignore */
        }
      }
    }
  }
  const id = String(el.id ?? '').trim()
  if (!id) return {}
  const prefix = `${id}${NUTRITION_FIELD_COMPOSITE_SEP}`
  const manual: Record<string, string> = {}
  for (const [k, v] of Object.entries(defaults)) {
    if (k.startsWith(prefix)) manual[k.slice(prefix.length)] = String(v ?? '')
  }
  return manual
}

function parseNutritionJsonFromConfigText(cfg: Record<string, unknown>): string | null {
  const text = String(cfg.text ?? cfg.Text ?? '').trim()
  return text.startsWith('{') ? text : null
}

/** 接口 8.2 会把营养成分 JSON 误写入 config.text;解析进 fixedNutrients / calories 等字段 */
export function hydrateNutritionElementsInTemplate(
  template: SystemLabelTemplate,
  defaults: Record<string, string> = {},
): SystemLabelTemplate {
  const elements = (template.elements || []).map((el) => {
    if (String(el.type || '').toUpperCase() !== 'NUTRITION') return el
    let cfg = { ...(el.config || {}) } as Record<string, unknown>
    const fromText = parseNutritionJsonFromConfigText(cfg)
    if (fromText) {
      cfg = applyNutritionDefaultJsonToConfig(cfg, fromText)
    }
    const manual = collectNutritionManualFromDefaults(el, defaults)
    if (Object.keys(manual).some((k) => String(manual[k] ?? '').trim() !== '')) {
      cfg = applyNutritionDefaultJsonToConfig(cfg, JSON.stringify(manual))
    }
    if (fromText) {
      delete cfg.text
      delete cfg.Text
    }
    return { ...el, config: cfg }
  })
  return { ...template, elements }
}

/** 按实际行数估算营养表所需高度,避免矮框裁掉 Vitamin D / 页脚等行 */
export function estimateNutritionPanelHeightPx(cfg: Record<string, unknown>): number {
  const model = buildNutritionFactsViewModel(cfg)
  const bodySize = NUTRITION_BODY_FONT_SIZE
  const titleSize = Math.max(11, Math.min(18, model.titleFontSize))
  const lh = (fs: number) => fs + 4
  let y = 12
  y += titleSize + 6
  y += lh(bodySize) + 3 + lh(bodySize) + 5
  y += bodySize + 8
  for (const row of model.rows) {
    y += lh(bodySize)
    if (row.dividerAfter === 'double') y += 5
    else if (row.dividerAfter === 'thin') y += 3
  }
  y += 24
  return Math.ceil(y)
}

/**
 * 仅当营养表增高时,把「原底边以下」的元素整体下移相同 delta,保持同行左右布局不变。
 * 禁止按 cursor 重排,避免 Consume By 标签/日期、ALLERGENS 等被拆散叠在一起。
 */
export function reflowElementsBelowNutritionPanel(
  template: SystemLabelTemplate,
  previousNutritionBottom?: number,
): SystemLabelTemplate {
  const elements = template.elements || []
  const nutrition = elements.find((el) => String(el.type || '').toUpperCase() === 'NUTRITION')
  if (!nutrition) return template

  const nutY = Number(nutrition.y) || 0
  const nutBottom = nutY + (Number(nutrition.height) || 0)
  const oldBottom = previousNutritionBottom ?? nutBottom
  const delta = nutBottom - oldBottom
  if (delta <= 1) return template

  const threshold = oldBottom - 2
  const next = elements.map((el) => {
    if (el.id === nutrition.id) return el
    const y = Number(el.y) || 0
    if (y >= threshold) return { ...el, y: y + delta }
    return el
  })
  return { ...template, elements: next }
}

/** 仅在内容确实超出模板框时微增高度,并整体下移其下元素(不重排同行) */
export function expandNutritionElementsToFitContent(template: SystemLabelTemplate): SystemLabelTemplate {
  const elements = [...(template.elements || [])]
  const nutIdx = elements.findIndex((el) => String(el.type || '').toUpperCase() === 'NUTRITION')
  if (nutIdx < 0) return template

  const nutrition = elements[nutIdx]
  const nutY = Number(nutrition.y) || 0
  const oldH = Number(nutrition.height) || 0
  const oldBottom = nutY + oldH
  const need = estimateNutritionPanelHeightPx((nutrition.config || {}) as Record<string, unknown>)
  const maxH = Math.max(oldH, need)
  if (maxH <= oldH + 2) return template

  elements[nutIdx] = { ...nutrition, height: maxH }
  return reflowElementsBelowNutritionPanel({ ...template, elements }, oldBottom)
}

function fixedLabelForKey(key: string): string {
  const hit = NUTRITION_FACTS_LAYOUT_ROWS.find((x) => x.key === key)
  return hit?.label ?? key
}

function pickManual(manual: Record<string, string>, subKey: string): string {
  return String(manual[subKey] ?? '').trim()
}

export function applyNutritionDefaultJsonToConfig(
  baseCfg: Record<string, unknown>,
  jsonStr: string,
): Record<string, unknown> {
  const t = String(jsonStr ?? '').trim()
  if (!t.startsWith('{')) return baseCfg
  let manual: Record<string, string> = {}
  try {
    manual = JSON.parse(t) as Record<string, string>
  } catch {
    return baseCfg
  }

  const out: Record<string, unknown> = { ...baseCfg }
  const baseFixed = Array.isArray(baseCfg.fixedNutrients)
    ? (baseCfg.fixedNutrients as Record<string, unknown>[])
    : []

  const cal = pickManual(manual, 'calories')
  if (cal) out.calories = cal
  else {
    delete out.calories
    delete out.Calories
  }
  out.servingsPerContainer = pickManual(manual, 'servingsPerContainer')
  out.servingSize = pickManual(manual, 'servingSize')

  const keysInOrder = [...NUTRITION_FACTS_LAYOUT_ROWS.map((r) => r.key)]
  for (const row of baseFixed) {
    const key = String(row.key ?? '').trim()
    if (key && !keysInOrder.includes(key)) keysInOrder.push(key)
  }

  const fixedArr: Record<string, unknown>[] = []
  for (const key of keysInOrder) {
    const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === key)
    const layout = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key)
    const v = pickManual(manual, key)
    const pct = pickManual(manual, `${key}Percent`)
    const lessThan = Boolean(baseRow?.lessThan ?? baseCfg[`${key}LessThan`])
    const label = String(baseRow?.label ?? layout?.label ?? fixedLabelForKey(key))
    fixedArr.push({
      key,
      label,
      value: v,
      unit: '',
      dailyValuePercent: pct,
      lessThan,
    })
    if (v) out[key] = v
    else delete out[key]
    delete out[`${key}Unit`]
    out[`${key}Percent`] = pct
    out[`${key}LessThan`] = lessThan
  }
  out.fixedNutrients = fixedArr

  const newExtras: Array<{ id: string; name: string; value: string; unit: string }> = []
  for (const k of Object.keys(manual)) {
    if (!k.startsWith('extra:') || !k.endsWith(':value')) continue
    const id = k.slice('extra:'.length, -':value'.length)
    newExtras.push({
      id,
      name: String(
        (Array.isArray(out.extraNutrients)
          ? (out.extraNutrients as Record<string, unknown>[]).find((row) => String(row.id ?? '') === id)
          : undefined)?.name ?? 'Other',
      ),
      value: pickManual(manual, k),
      unit: '',
    })
    out[`extra:${id}:percent`] = pickManual(manual, `extra:${id}:percent`)
    out[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`])
  }
  if (newExtras.length > 0) out.extraNutrients = newExtras

  delete out.ingredientsText
  delete out.IngredientsText
  return out
}