printFromPrintDataList.ts
19.7 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
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
import {
normalizeLabelTemplateFromPreviewApi,
parseLabelSizeText,
sortElementsForPreview,
} from './labelPreview/normalizePreviewTemplate'
import {
canPrintCurrentLabelViaNativeFastJob,
printLabelPrintJobPayloadForCurrentPrinter,
printSystemTemplateForCurrentPrinter,
type SystemTemplatePrintCanvasRasterOptions,
} from './print/manager/printerManager'
import {
buildLabelPrintJobPayload,
setLastLabelPrintJobPayload,
} from './labelPreview/buildLabelPrintPayload'
import { getCurrentStoreId } from './stores'
import {
ensureNativeClassicTransportIfPossible,
} from './print/printerConnection'
import {
hydrateSystemTemplateImagesForPrint,
resetHydrateImageDebugRecords,
} from './print/hydrateTemplateImagesForPrint'
import {
normalizeTemplateForNativeFastJob,
templateHasUnsupportedNativeFastElements,
} from './print/nativeTemplateElementSupport'
import { isTemplateWithinNativeFastPrintBounds } from './print/templatePhysicalMm'
import type {
LabelTemplateData,
SystemLabelTemplate,
SystemTemplateElementBase,
} from './print/types/printer'
import type { PrintLogDataItemDto, PrintLogItemDto } from '../types/usAppLabeling'
function nonEmptyDisplay (value: string | null | undefined): string {
if (value == null || value === '' || value === '无') return ''
return String(value).trim()
}
/**
* 接口 9/11 落库的快照里 elements[].config 已是最终展示值;重打时 data 不能再带 productName,
* 否则 TEXT_PRODUCT 会优先解析为 productName(与预览里用 config.text 的「freeze」等冲突,出现两行 sandwich)。
*/
function labelTemplateDataForSnapshotReprint (): LabelTemplateData {
return {}
}
function formatPriceLineForBake (config: Record<string, any>, rawText: string): string {
const prefix = String(config.prefix ?? config.Prefix ?? '')
const suffix = String(config.suffix ?? config.Suffix ?? '')
const decRaw = config.decimal ?? config.Decimal
const decimal = typeof decRaw === 'number' ? decRaw : Number(decRaw)
const numericValue = Number(rawText)
const value =
!Number.isNaN(numericValue) && Number.isFinite(numericValue) && Number(decimal) >= 0
? numericValue.toFixed(Number(decimal))
: rawText
return `${prefix}${value}${suffix}`
}
/**
* 原生快打插件对 TEXT_PRODUCT / TEXT_PRICE / DATE 等仍会按类型做绑定;重打时 dataJson 为空,
* 必须把接口/落库快照里已填好的展示值焙成 TEXT_STATIC,否则会出现第二行商品名、价格 0、日期成 format 等问题。
*/
export function bakeReprintTemplateSnapshot (tmpl: SystemLabelTemplate): SystemLabelTemplate {
const elements = (tmpl.elements || []).map((el) => {
const vst = String(el.valueSourceType || '').toUpperCase()
const type = String(el.type || '').toUpperCase()
const cfg = { ...(el.config || {}) } as Record<string, any>
const toStatic = (line: string): SystemTemplateElementBase => ({
...el,
type: 'TEXT_STATIC',
valueSourceType: 'FIXED',
config: { ...cfg, text: line, Text: line },
})
if (vst === 'FIXED') {
if (type === 'TEXT_PRODUCT' || type === 'TEXT_CATEGORY' || type === 'TEXT_LABEL_ID') {
const t = String(cfg.text ?? cfg.Text ?? '').trim()
if (t) return toStatic(t)
}
if (type === 'TEXT_PRICE') {
const raw = String(cfg.text ?? cfg.Text ?? '').trim()
if (raw) {
const line = formatPriceLineForBake(cfg, raw)
return {
...el,
type: 'TEXT_STATIC',
valueSourceType: 'FIXED',
config: { ...cfg, text: line, Text: line, prefix: '', Prefix: '', suffix: '', Suffix: '' },
}
}
}
}
if (vst === 'PRINT_INPUT') {
if (type === 'DATE' || type === 'TIME' || type === 'DURATION') {
const textVal = String(cfg.text ?? cfg.Text ?? '').trim()
if (textVal) return toStatic(textVal)
}
if (type === 'WEIGHT') {
const textVal = String(cfg.text ?? cfg.Text ?? '').trim()
const v = String(cfg.value ?? cfg.Value ?? '').trim()
const u = String(cfg.unit ?? cfg.Unit ?? '').trim()
const line =
textVal || (v && u ? (v.endsWith(u) ? v : `${v}${u}`) : v || u)
if (line) return toStatic(line)
}
if (type === 'TEXT_STATIC') {
const inputType = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase()
if (inputType === 'number' || inputType === 'text') {
const t = String(cfg.text ?? cfg.Text ?? '').trim()
if (t) return toStatic(t)
}
if (
inputType === 'options' ||
cfg.multipleOptionId ||
cfg.MultipleOptionId
) {
const rawText = String(cfg.text ?? cfg.Text ?? '').trim()
const sel = cfg.selectedOptionValues ?? cfg.SelectedOptionValues
const joined =
Array.isArray(sel) && sel.length ? sel.map((x: unknown) => String(x)).join(', ') : ''
let line = rawText
if (!line && joined) {
const dictLabel = String(
cfg.multipleOptionName ?? cfg.MultipleOptionName ?? 'Options',
).trim()
const prefix = String(cfg.prefix ?? cfg.Prefix ?? '').trim()
line = prefix ? `${prefix}${joined}` : `${dictLabel}: ${joined}`
}
if (line) return toStatic(line)
}
}
}
return el
})
return { ...tmpl, elements }
}
/**
* 接口可能返回两类 renderConfigJson:
* 1)与设计器 elements[] 单项同构(含 type、x、y、config)
* 2)仅画布 config 快照(只有 text、fontSize 等),无 type、无坐标 —— 若直接当 element 传入,
* normalizeLabelTemplateFromPreviewApi 只读 e.config,会得到空 config,打印全空。
*/
function inferElementTypeFromPrintSnapshotConfig (cfg: Record<string, unknown>): string {
const inputType = String(cfg.inputType ?? '').toLowerCase()
const hasUnit = cfg.unit != null || cfg.Unit != null
const hasValue = cfg.value != null || cfg.Value != null
if (hasUnit && hasValue) return 'WEIGHT'
if (
typeof cfg.decimal === 'number' &&
('prefix' in cfg || 'suffix' in cfg)
) {
return 'TEXT_PRICE'
}
if (cfg.format != null && inputType === 'datetime') return 'DATE'
return 'TEXT_STATIC'
}
function applyRenderValueToSnapshotConfig (
cfg: Record<string, unknown>,
elementType: string,
renderValue: string | null | undefined
): void {
if (renderValue === undefined || renderValue === null) return
const t = elementType.toUpperCase()
const s = String(renderValue)
if (t === 'WEIGHT') {
cfg.value = s
cfg.Value = s
return
}
if (t === 'DATE' || t === 'TIME') {
cfg.text = s
cfg.Text = s
return
}
cfg.text = s
cfg.Text = s
}
function pageWidthPxFromPrintLogRow (row: PrintLogItemDto): number {
const size = parseLabelSizeText(row.labelSizeText ?? null)
const w = size?.width ?? 2
return Math.max(96, Math.round(w * 96))
}
/**
* 将 printDataList 单项转为 normalize 可用的 element;扁平 config 会包进 config 并补全坐标(纵向堆叠)。
*/
function elementFromPrintDataItem (
item: PrintLogDataItemDto,
index: number,
pageWidthPx: number
): Record<string, unknown> {
const raw =
item.renderConfigJson ?? (item as unknown as { RenderConfigJson?: unknown }).RenderConfigJson
if (raw == null) {
throw new Error('Missing renderConfigJson')
}
let obj: Record<string, unknown>
if (typeof raw === 'string') {
try {
obj = JSON.parse(raw) as Record<string, unknown>
} catch {
throw new Error('Invalid element JSON')
}
} else if (typeof raw === 'object' && !Array.isArray(raw)) {
obj = { ...(raw as Record<string, unknown>) }
} else {
throw new Error('Invalid renderConfigJson')
}
const hasElementEnvelope =
typeof obj.type === 'string' ||
typeof obj.Type === 'string' ||
typeof obj.elementType === 'string' ||
'x' in obj ||
'posX' in obj ||
'config' in obj ||
'ConfigJson' in obj ||
'configJson' in obj
if (hasElementEnvelope) {
const base = { ...obj }
if (item.elementId) {
base.id = item.elementId
base.Id = item.elementId
}
return base
}
const cfg = { ...obj }
const inferredType = inferElementTypeFromPrintSnapshotConfig(cfg)
applyRenderValueToSnapshotConfig(cfg, inferredType, item.renderValue)
const lineHeight = 40
const pad = 8
return {
id: item.elementId ?? `el-${index}`,
type: inferredType,
x: pad,
y: pad + index * lineHeight,
width: Math.max(40, pageWidthPx - pad * 2),
height: lineHeight,
rotation: 'horizontal',
border: 'none',
config: cfg,
zIndex: index,
orderNum: index,
}
}
/**
* 重打快照:勿调用 overlayProductNameOnPreviewTemplate。
* 列表 printDataList 里 TEXT_PRODUCT 若丢字端,text 变空串会触发「占位」逻辑被整行替成 productName,出现两行 sandwich。
*/
function overlayReprintResolvedFields (
tmpl: SystemLabelTemplate,
row: PrintLogItemDto,
data: LabelTemplateData
): SystemLabelTemplate {
const t = tmpl
const productName = nonEmptyDisplay(row.productName)
/** 勿含空串:接口拆包丢字端时 text 为空,若把 '' 当占位并整行换成 productName,会与第一行商品名重复成两个 sandwich */
const placeholders = new Set([
'文本',
'text',
'Text',
'TEXT',
'名称',
'name',
'Name',
'label',
'Label',
])
const elements = (t.elements || []).map((el) => {
const type = String(el.type || '').toUpperCase()
const cfg = { ...(el.config || {}) } as Record<string, unknown>
const inputType = String(cfg.inputType ?? '').toLowerCase()
if (type === 'TEXT_STATIC' || type === 'TEXT_PRODUCT') {
if (cfg.multipleOptionId || inputType === 'options') {
const rawText = String(cfg.text ?? cfg.Text ?? '').trim()
const sel = cfg.selectedOptionValues
const joined =
Array.isArray(sel) && sel.length ? sel.map((x) => String(x)).join(', ') : ''
/** 快照已存整行「Allergens: x, y」时勿改成仅选项值,否则缺行且缺前缀 */
if (rawText && (rawText.includes(joined) || (joined && rawText.length > joined.length))) {
return el
}
if (!rawText && joined) {
const dictLabel = String(cfg.multipleOptionName ?? cfg.MultipleOptionName ?? 'Options').trim()
const prefix = String(cfg.prefix ?? cfg.Prefix ?? '').trim()
const line = prefix ? `${prefix}${joined}` : `${dictLabel}: ${joined}`
return { ...el, config: { ...cfg, text: line } }
}
return el
}
}
if ((type === 'TEXT_STATIC' || type === 'TEXT_PRODUCT') && productName) {
const raw = String(cfg.text ?? cfg.Text ?? '').trim()
const vstEl = String(el.valueSourceType || '').toUpperCase()
/** FIXED 的 TEXT_PRODUCT 多为标签类型/说明,占位「名称」等不应被商品名替换(避免出现两行 sandwich) */
if (type === 'TEXT_PRODUCT' && vstEl === 'FIXED') {
return el
}
if (placeholders.has(raw)) {
return {
...el,
type: 'TEXT_PRODUCT',
config: { ...cfg, text: productName },
}
}
}
if (type === 'DATE' && !String(cfg.text ?? cfg.Text ?? '').trim() && data.date) {
return { ...el, config: { ...cfg, text: String(data.date) } }
}
if (type === 'TIME' && !String(cfg.text ?? cfg.Text ?? '').trim() && data.time) {
return { ...el, config: { ...cfg, text: String(data.time) } }
}
return el
})
return { ...t, elements }
}
/** 打印日志重打:可选整页 canvas 光栅,与预览页「非 native 快打」分支一致 */
export type PrintFromPrintLogOptions = {
printQty?: number
onProgress?: (percent: number) => void
canvasRaster?: SystemTemplatePrintCanvasRasterOptions
}
/**
* 与 Label Preview 一致:一体机(经典蓝牙 + native-plugin 基座)且模板可走原生时走 printLabelPrintJob + 本地图片 hydration;
* 普通蓝牙仍走 canvas 光栅或直发 TSC。
*/
async function printReprintTemplateWithPreviewStrategy (
tmpl: SystemLabelTemplate,
row: PrintLogItemDto,
options: PrintFromPrintLogOptions,
): Promise<void> {
await ensureNativeClassicTransportIfPossible()
const templateData = labelTemplateDataForSnapshotReprint()
const printInputJson: Record<string, unknown> = {}
const tmplForNative = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any)
const useNative =
canPrintCurrentLabelViaNativeFastJob()
&& isTemplateWithinNativeFastPrintBounds(tmpl)
&& !templateHasUnsupportedNativeFastElements(tmplForNative)
const printQty = options.printQty ?? 1
if (useNative) {
resetHydrateImageDebugRecords()
const hydrated = await hydrateSystemTemplateImagesForPrint(tmplForNative)
const payload = buildLabelPrintJobPayload(hydrated, printInputJson, {
labelCode: row.labelCode,
productId: row.productId ?? undefined,
printQuantity: printQty,
locationId: getCurrentStoreId() || undefined,
})
setLastLabelPrintJobPayload(payload)
await printLabelPrintJobPayloadForCurrentPrinter(
payload,
{ printQty },
options.onProgress,
)
return
}
await printSystemTemplateForCurrentPrinter(
tmpl,
templateData,
{ printQty, canvasRaster: options.canvasRaster },
options.onProgress,
)
}
/**
* 使用接口 10 返回的 `printDataList` 组装模板并走当前打印机。
*/
function logReprintJson (label: string, data: unknown): void {
try {
console.log(`[Reprint] ${label}`, typeof data === 'string' ? data : JSON.stringify(data, null, 2))
} catch {
console.log(`[Reprint] ${label}`, data)
}
}
export async function printFromPrintDataListRow (
row: PrintLogItemDto,
options: PrintFromPrintLogOptions = {}
): Promise<void> {
const list =
row.printDataList ??
(row as unknown as { PrintDataList?: PrintLogDataItemDto[] }).PrintDataList ??
[]
if (!Array.isArray(list) || list.length === 0) {
throw new Error('No printDataList in record')
}
const pageW = pageWidthPxFromPrintLogRow(row)
const elements = list.map((item, index) => elementFromPrintDataItem(item, index, pageW))
const size = parseLabelSizeText(row.labelSizeText ?? null)
const payload: Record<string, unknown> = {
id: row.labelId || row.labelCode || 'reprint',
name: 'Reprint',
unit: size?.unit ?? 'inch',
width: size?.width ?? 2,
height: size?.height ?? 2,
appliedLocation: 'ALL',
elements,
}
console.log('[Reprint] 路径: printDataList 组装')
logReprintJson('printDataList 原始', list)
logReprintJson('组装的 template payload(normalize 前)', payload)
let tmpl = normalizeLabelTemplateFromPreviewApi(payload)
if (!tmpl) {
throw new Error('Cannot build template from printDataList')
}
const templateData = labelTemplateDataForSnapshotReprint()
tmpl = overlayReprintResolvedFields(tmpl, row, templateData)
tmpl = bakeReprintTemplateSnapshot(tmpl)
tmpl = {
...tmpl,
elements: sortElementsForPreview(tmpl.elements || []),
} as SystemLabelTemplate
logReprintJson('bake + sort 后送打印机模板 tmpl', tmpl)
await printReprintTemplateWithPreviewStrategy(tmpl, row, options)
}
/**
* 从列表快照字符串(`printInputJson` / `renderTemplateJson`)解析出与接口 9 同构、可 `normalize` 的模板 JSON 字符串。
*
* 若根上同时有嵌套 `printInputJson`(小对象)和根级 `elements`(整模板),优先保留含 `elements` 的那份。
*/
export function extractPrintTemplateJsonForReprint (raw: string): string | null {
const s = raw.trim()
if (!s) return null
try {
let doc: unknown = JSON.parse(s)
if (typeof doc === 'string') {
doc = JSON.parse(doc)
}
if (doc == null || typeof doc !== 'object' || Array.isArray(doc)) return null
const d = doc as Record<string, unknown>
if (Array.isArray(d.elements) || Array.isArray(d.Elements)) {
return JSON.stringify(d)
}
const pi = d.printInputJson ?? d.PrintInputJson
if (pi != null && typeof pi === 'object' && !Array.isArray(pi)) {
const p = pi as Record<string, unknown>
if (Array.isArray(p.elements) || Array.isArray(p.Elements)) {
return JSON.stringify(p)
}
return JSON.stringify(p)
}
} catch {
return null
}
return null
}
/** 列表项里 JSON 快照字段:可能是 string(含转义)或已解析的 object */
function snapshotJsonFieldToString (
row: PrintLogItemDto,
camel: 'printInputJson' | 'renderTemplateJson',
pascal: 'PrintInputJson' | 'RenderTemplateJson',
): string | null {
const rec = row as unknown as Record<string, unknown>
const r = rec[camel] ?? rec[pascal]
if (r == null) return null
if (typeof r === 'string') {
const t = r.trim()
return t || null
}
try {
return JSON.stringify(r)
} catch {
return null
}
}
/**
* 打印日志重打入口:**优先 `printInputJson`**(与接口 9 落库快照一致),其次 `renderTemplateJson`,最后 `printDataList`。
*/
export async function printFromPrintLogRow (
row: PrintLogItemDto,
options: PrintFromPrintLogOptions = {}
): Promise<void> {
const fromPrintInput = snapshotJsonFieldToString(row, 'printInputJson', 'PrintInputJson')
const fromRender = snapshotJsonFieldToString(row, 'renderTemplateJson', 'RenderTemplateJson')
console.log('[Reprint] ========== 重复打印 JSON 调试 ==========')
console.log('[Reprint] taskId', row.taskId, 'labelCode', row.labelCode, 'productName', row.productName)
logReprintJson('printInputJson(优先,接口 10)', fromPrintInput ?? '(无)')
logReprintJson('renderTemplateJson(回退)', fromRender ?? '(无)')
const tryExtract = (raw: string | null): string | null => {
if (!raw) return null
return extractPrintTemplateJsonForReprint(raw)
}
const extracted = tryExtract(fromPrintInput) ?? tryExtract(fromRender)
if (extracted) {
logReprintJson('extract 后用于打印的模板 JSON 字符串', extracted)
await printFromMergedTemplateJsonString(extracted, row, options)
return
}
if (fromPrintInput || fromRender) {
console.warn('[Reprint] 有 printInputJson/renderTemplateJson 但 extract 失败,回退 printDataList')
} else {
console.log('[Reprint] 无 printInputJson 与 renderTemplateJson,使用 printDataList')
}
await printFromPrintDataListRow(row, options)
}
/**
* 将已解析的快照 JSON 字符串走 normalize → overlay → bake 后送机(列表优先来自 `printInputJson`)。
*/
export async function printFromMergedTemplateJsonString (
mergedTemplateJson: string,
row: PrintLogItemDto,
options: PrintFromPrintLogOptions = {}
): Promise<void> {
console.log('[Reprint] 路径: renderTemplateJson / merged 完整模板')
logReprintJson('mergedTemplateJson 原始字符串', mergedTemplateJson)
let payload: unknown
try {
payload = JSON.parse(mergedTemplateJson) as unknown
/** 部分网关/序列化会把整段再包一层 JSON 字符串 */
if (typeof payload === 'string') {
payload = JSON.parse(payload) as unknown
}
} catch {
throw new Error('Invalid merged template JSON')
}
logReprintJson('JSON.parse 后的 payload', payload)
let tmpl = normalizeLabelTemplateFromPreviewApi(payload)
if (!tmpl) {
throw new Error('Cannot parse merged template')
}
const templateData = labelTemplateDataForSnapshotReprint()
tmpl = overlayReprintResolvedFields(tmpl, row, templateData)
tmpl = bakeReprintTemplateSnapshot(tmpl)
tmpl = {
...tmpl,
elements: sortElementsForPreview(tmpl.elements || []),
} as SystemLabelTemplate
logReprintJson('bake + sort 后送打印机模板 tmpl', tmpl)
logReprintJson('templateData(快照重打为空对象)', templateData)
await printReprintTemplateWithPreviewStrategy(tmpl, row, options)
}