nutritionDefaultsMerge.ts
8.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
/**
* 将管理端保存的营养成分默认值 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
}