nutritionManualEntry.ts 11.7 KB
import type { LabelElement } from "../types/labelTemplate";
import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate";
import { NUTRITION_FACTS_LAYOUT_ROWS, DEFAULT_NUTRITION_FOOTER_NOTE } from "./nutritionFactsLayout";

/** 批量表 / 与 elementId 拼接的字段名分隔(避免与普通 element id 冲突) */
export const NUTRITION_FIELD_COMPOSITE_SEP = "###nut###";

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

export type NutritionManualFieldSpec = {
  subKey: string;
  columnLabel: string;
  inputType?: "text" | "checkbox";
};

function nutritionExtraRowsFromCfg(cfg: Record<string, unknown>): Array<{
  id: string;
  name: string;
  value: string;
  unit: string;
}> {
  const raw = cfg.extraNutrients;
  if (!Array.isArray(raw)) return [];
  return raw.map((item, idx) => {
    const row = item as Record<string, unknown>;
    return {
      id: String(row.id ?? `extra-${idx}`),
      name: String(row.name ?? ""),
      value: String(row.value ?? ""),
      unit: String(row.unit ?? ""),
    };
  });
}

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

function fakeNutritionElement(cfg: Record<string, unknown>): LabelElement {
  return {
    id: "__nutrition_cfg__",
    type: "NUTRITION",
    x: 0,
    y: 0,
    width: 1,
    height: 1,
    rotation: "horizontal",
    border: "none",
    config: cfg,
  } as LabelElement;
}

/**
 * 模板中每个 NUTRITION 元素在「录入 / 批量表」中展开的列。
 * 每行营养素固定两列录入:amount(含量含单位,自行填写如 11g)+ %DV。
 */
export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] {
  if (canonicalElementType(el.type) !== "NUTRITION") return [];
  const cfg = (el.config ?? {}) as Record<string, unknown>;
  const specs: NutritionManualFieldSpec[] = [
    { subKey: "servingsPerContainer", columnLabel: "Servings" },
    { subKey: "servingSize", columnLabel: "Serve size" },
    { subKey: "calories", columnLabel: "Calories" },
  ];

  const fixedArr = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : [];
  const labelByKey = new Map<string, string>();
  for (const item of NUTRITION_FACTS_LAYOUT_ROWS) {
    labelByKey.set(item.key, item.label);
  }
  for (const row of fixedArr) {
    const key = String(row.key ?? "").trim();
    if (!key) continue;
    if (!labelByKey.has(key)) labelByKey.set(key, String(row.label ?? key));
    else if (row.label) labelByKey.set(key, String(row.label));
  }

  const keysInOrder: string[] = NUTRITION_FACTS_LAYOUT_ROWS.map((r) => r.key);
  for (const row of fixedArr) {
    const key = String(row.key ?? "").trim();
    if (key && !keysInOrder.includes(key)) keysInOrder.push(key);
  }

  for (const key of keysInOrder) {
    const label = labelByKey.get(key) ?? fixedLabelForKey(key);
    specs.push({ subKey: key, columnLabel: `${label} (amount)` });
    specs.push({ subKey: `${key}Percent`, columnLabel: `${label} (% DV)` });
  }

  for (const ex of nutritionExtraRowsFromCfg(cfg)) {
    const id = String(ex.id ?? "").trim();
    if (!id) continue;
    const name = ex.name.trim() || "Other";
    specs.push({ subKey: `extra:${id}:value`, columnLabel: `${name} (amount)` });
    specs.push({ subKey: `extra:${id}:percent`, columnLabel: `${name} (% DV)` });
  }

  return specs;
}

export function listNutritionElements(elements: LabelElement[]): LabelElement[] {
  return (elements ?? []).filter((el) => canonicalElementType(el.type) === "NUTRITION");
}

/** 从模板 config 初始化手动录入 map(模板不含数值,初始均为空) */
export function nutritionManualValuesFromTemplateConfig(_el: LabelElement): Record<string, string> {
  const specs = listNutritionManualFieldSpecs(_el);
  const out: Record<string, string> = {};
  for (const s of specs) {
    out[s.subKey] = s.inputType === "checkbox" ? "false" : "";
  }
  return out;
}

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

/**
 * 模板编辑器持久化:仅保留表结构(单位、自定义行名称、页脚文案),清除所有展示数值。
 */
export function sanitizeNutritionTemplateConfig(
  cfg: Record<string, unknown>,
): Record<string, unknown> {
  const out: Record<string, unknown> = { ...cfg };
  out.calories = "";
  out.servingsPerContainer = "";
  out.servingSize = "";
  delete out.Calories;
  delete out.ServingsPerContainer;
  delete out.ServingSize;
  delete out.ingredientsText;
  delete out.IngredientsText;
  for (const k of Object.keys(out)) {
    if (k.endsWith("Percent")) delete out[k];
  }

  const baseFixed = Array.isArray(out.fixedNutrients)
    ? (out.fixedNutrients as Record<string, unknown>[])
    : [];
  const fixedArr = NUTRITION_FACTS_LAYOUT_ROWS.map((item) => {
    const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === item.key);
    const unit = String(baseRow?.unit ?? item.defaultUnit ?? "").trim();
    const lessThan = Boolean(baseRow?.lessThan ?? out[`${item.key}LessThan`]);
    return {
      key: item.key,
      label: String(baseRow?.label ?? item.label),
      value: "",
      unit,
      dailyValuePercent: "",
      lessThan,
    };
  });
  out.fixedNutrients = fixedArr;

  for (const item of NUTRITION_FACTS_LAYOUT_ROWS) {
    out[item.key] = "";
    const row = fixedArr.find((r) => r.key === item.key);
    if (row?.unit) out[`${item.key}Unit`] = row.unit;
    else delete out[`${item.key}Unit`];
    out[`${item.key}LessThan`] = Boolean(row?.lessThan);
  }

  out.extraNutrients = nutritionExtraRowsFromCfg(out).map((ex) => ({
    id: ex.id,
    name: ex.name,
    value: "",
    unit: ex.unit,
  }));

  if (!String(out.nutritionFooterNote ?? "").trim()) {
    out.nutritionFooterNote = DEFAULT_NUTRITION_FOOTER_NOTE;
  }
  return out;
}

/** 模板编辑器:清除所有 NUTRITION 元素 config 中的展示数值 */
export function sanitizeNutritionElementsForTemplateEditor(
  elements: LabelElement[],
): LabelElement[] {
  return (elements ?? []).map((el) => {
    if (canonicalElementType(el.type) !== "NUTRITION") return el;
    return {
      ...el,
      config: sanitizeNutritionTemplateConfig((el.config ?? {}) as Record<string, unknown>),
    };
  });
}

/**
 * 将手动录入合并进 NUTRITION 的 config(供画布预览;与 App 端 apply 逻辑字段一致)。
 */
export function mergeNutritionManualIntoConfig(
  baseCfg: Record<string, unknown>,
  manual: Record<string, string>,
): Record<string, unknown> {
  const cfg: Record<string, unknown> = { ...baseCfg };
  const specs = listNutritionManualFieldSpecs(fakeNutritionElement(baseCfg));
  const specSubKeys = new Set(specs.map((s) => s.subKey));

  if (specSubKeys.has("calories")) {
    const cal = pickManual(manual, "calories");
    if (cal) cfg.calories = cal;
    else {
      delete cfg.calories;
      delete cfg.Calories;
    }
  }

  if (specSubKeys.has("servingsPerContainer")) {
    cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer");
  }
  if (specSubKeys.has("servingSize")) {
    cfg.servingSize = pickManual(manual, "servingSize");
  }

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

  for (const s of specs) {
    if (["calories", "servingsPerContainer", "servingSize"].includes(s.subKey)) {
      continue;
    }
    if (s.subKey.startsWith("extra:")) continue;
    if (s.subKey.endsWith("Percent")) continue;

    const key = s.subKey;
    const v = pickManual(manual, key);
    const pct = pickManual(manual, `${key}Percent`);
    const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === key);
    const lessThan = Boolean(baseRow?.lessThan ?? baseCfg[`${key}LessThan`]);
    const label = String(baseRow?.label ?? fixedLabelForKey(key));
    fixedArr.push({
      key,
      label,
      value: v,
      unit: "",
      dailyValuePercent: pct,
      lessThan,
    });
    if (v) cfg[key] = v;
    else {
      delete cfg[key];
      delete cfg[`${key}Unit`];
    }
    cfg[`${key}Percent`] = pct;
    cfg[`${key}LessThan`] = lessThan;
  }
  cfg.fixedNutrients = fixedArr;

  const newExtras: Array<{ id: string; name: string; value: string; unit: string }> = [];
  for (const s of specs) {
    if (!s.subKey.startsWith("extra:") || !s.subKey.endsWith(":value")) continue;
    const id = s.subKey.slice("extra:".length, -":value".length);
    const base = nutritionExtraRowsFromCfg(baseCfg).find((r) => r.id === id);
    newExtras.push({
      id,
      name: (base?.name ?? s.columnLabel).trim() || "Other",
      value: pickManual(manual, s.subKey),
      unit: String(base?.unit ?? "").trim(),
    });
    cfg[`extra:${id}:percent`] = pickManual(manual, `extra:${id}:percent`);
    cfg[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`]);
  }
  cfg.extraNutrients = newExtras;
  return cfg;
}

export function serializeNutritionManualForDefaults(manual: Record<string, string>): string {
  const o: Record<string, string> = {};
  for (const [k, v] of Object.entries(manual)) {
    const t = String(v ?? "").trim();
    if (t !== "") o[k] = t;
  }
  return JSON.stringify(o);
}

export function parseNutritionManualFromDefaults(raw: string | undefined): Record<string, string> {
  const t = String(raw ?? "").trim();
  if (!t.startsWith("{")) return {};
  try {
    const p = JSON.parse(t) as unknown;
    if (p == null || typeof p !== "object" || Array.isArray(p)) return {};
    const out: Record<string, string> = {};
    for (const [k, v] of Object.entries(p as Record<string, unknown>)) {
      out[k] = String(v ?? "");
    }
    return out;
  } catch {
    return {};
  }
}

export function nutritionDefaultValuesJsonForSave(manual: Record<string, string>): string | null {
  const json = serializeNutritionManualForDefaults(manual);
  return json === "{}" ? null : json;
}

/** 从接口 defaultValues 展开营养成分 JSON 为批量表 composite 列键 */
export function hydrateRowFieldValuesWithNutritionColumns(
  defaultValues: Record<string, string>,
  elements: LabelElement[],
): Record<string, string> {
  const out = { ...defaultValues };
  for (const nel of listNutritionElements(elements)) {
    const raw = out[nel.id];
    if (typeof raw !== "string" || !raw.trim().startsWith("{")) continue;
    const parsed = parseNutritionManualFromDefaults(raw);
    delete out[nel.id];
    const allowed = new Set(listNutritionManualFieldSpecs(nel).map((s) => s.subKey));
    for (const [sk, val] of Object.entries(parsed)) {
      if (!allowed.has(sk)) continue;
      out[nutritionCompositeFieldKey(nel.id, sk)] = val;
    }
  }
  return out;
}

/** 将批量表 composite 列折叠回 defaultValues(元素 id → JSON) */
export function foldNutritionCompositeKeysIntoDefaults(
  fieldValues: Record<string, string>,
  elements: LabelElement[],
): Record<string, string> {
  const out: Record<string, string> = { ...fieldValues };
  for (const nel of listNutritionElements(elements)) {
    const specs = listNutritionManualFieldSpecs(nel);
    const manual: Record<string, string> = {};
    for (const s of specs) {
      const ck = nutritionCompositeFieldKey(nel.id, s.subKey);
      if (Object.prototype.hasOwnProperty.call(fieldValues, ck)) {
        manual[s.subKey] = fieldValues[ck] ?? "";
      }
    }
    const prefix = `${nel.id}${NUTRITION_FIELD_COMPOSITE_SEP}`;
    for (const k of Object.keys(out)) {
      if (k.startsWith(prefix)) delete out[k];
    }
    const j = nutritionDefaultValuesJsonForSave(manual);
    if (j) out[nel.id] = j;
    else delete out[nel.id];
  }
  return out;
}