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, 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 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 { 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, 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 { const raw = item.renderConfigJson ?? (item as unknown as { RenderConfigJson?: unknown }).RenderConfigJson if (raw == null) { throw new Error('Missing renderConfigJson') } let obj: Record if (typeof raw === 'string') { try { obj = JSON.parse(raw) as Record } catch { throw new Error('Invalid element JSON') } } else if (typeof raw === 'object' && !Array.isArray(raw)) { obj = { ...(raw as Record) } } 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 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 { await ensureNativeClassicTransportIfPossible() const templateData = labelTemplateDataForSnapshotReprint() const printInputJson: Record = {} 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 { 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 = { 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 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 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 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 { 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 { 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) }