import type { PrintLogGetListInputVo, PrintLogItemDto, UsAppLabelCategoryTreeNodeDto, UsAppLabelingProductNodeDto, UsAppLabelPreviewInputVo, UsAppLabelPrintInputVo, UsAppLabelPrintOutputDto, UsAppLabelReportOutputDto, UsAppLabelReportQueryInputVo, UsAppLabelReprintInputVo, UsAppLabelTypeNodeDto, UsAppProductCategoryNodeDto, } from '../types/usAppLabeling' import { extractPagedItems } from '../utils/pagedList' import { usAppApiRequest } from '../utils/usAppApiRequest' import { enqueueOfflineMutation, fetchWithOfflineCache, isNetworkOnline } from '../utils/sqliteSync' /** 接口 9:与文档路径一致,供日志与请求共用 */ export const US_APP_LABEL_PRINT_PATH = '/api/app/us-app-labeling/print' as const function asArr(v: unknown): unknown[] { return Array.isArray(v) ? v : [] } /** 兼容 camelCase / PascalCase,对齐《标签模块接口对接说明(6).md》8.1 四级树 */ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeDto[] { const list = asArr(raw) return list.map((node: any) => { const pcs = asArr(node?.productCategories ?? node?.ProductCategories) const productCategories: UsAppProductCategoryNodeDto[] = pcs.map((p: any) => { const prods = asArr(p?.products ?? p?.Products) const products: UsAppLabelingProductNodeDto[] = prods.map((x: any) => { const lts = asArr(x?.labelTypes ?? x?.LabelTypes) const labelTypes: UsAppLabelTypeNodeDto[] = lts.map((t: any) => ({ labelTypeId: String(t?.labelTypeId ?? t?.LabelTypeId ?? ''), typeName: String(t?.typeName ?? t?.TypeName ?? ''), orderNum: Number(t?.orderNum ?? t?.OrderNum ?? 0), labelCode: String(t?.labelCode ?? t?.LabelCode ?? ''), templateCode: (t?.templateCode ?? t?.TemplateCode ?? null) as string | null, labelSizeText: (t?.labelSizeText ?? t?.LabelSizeText ?? null) as string | null, })) return { productId: String(x?.productId ?? x?.ProductId ?? ''), templateId: String(x?.templateId ?? x?.TemplateId ?? '').trim() || undefined, templateCode: (x?.templateCode ?? x?.TemplateCode ?? null) as string | null, templateLabelSizeText: (x?.templateLabelSizeText ?? x?.TemplateLabelSizeText ?? null) as | string | null, productName: String(x?.productName ?? x?.ProductName ?? ''), productCode: String(x?.productCode ?? x?.ProductCode ?? ''), productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null, displayText: (x?.displayText ?? x?.DisplayText ?? null) as string | null, categoryPhotoUrl: (x?.categoryPhotoUrl ?? x?.CategoryPhotoUrl ?? null) as string | null, buttonAppearance: (x?.buttonAppearance ?? x?.ButtonAppearance ?? null) as string | null, buttonBgColor: (x?.buttonBgColor ?? x?.ButtonBgColor ?? null) as string | null, buttonImageUrl: (x?.buttonImageUrl ?? x?.ButtonImageUrl ?? null) as string | null, buttonTextColor: (x?.buttonTextColor ?? x?.ButtonTextColor ?? null) as string | null, buttonStyleJson: (x?.buttonStyleJson ?? x?.ButtonStyleJson ?? null) as string | null, subtitle: String(x?.subtitle ?? x?.Subtitle ?? ''), labelTypeCount: Number(x?.labelTypeCount ?? x?.LabelTypeCount ?? labelTypes.length), labelTypes, } }) const catId = p?.categoryId ?? p?.CategoryId return { categoryId: catId != null && String(catId).trim() !== '' ? String(catId) : null, categoryPhotoUrl: (p?.categoryPhotoUrl ?? p?.CategoryPhotoUrl ?? null) as string | null, // 透传平台端扩展字段(用于 APP 按 buttonAppearance 渲染 icon) displayText: (p?.displayText ?? p?.DisplayText ?? null) as string | null, buttonAppearance: (p?.buttonAppearance ?? p?.ButtonAppearance ?? null) as string | null, buttonTextColor: (p?.buttonTextColor ?? p?.ButtonTextColor ?? null) as string | null, buttonBgColor: (p?.buttonBgColor ?? p?.ButtonBgColor ?? null) as string | null, buttonImageUrl: (p?.buttonImageUrl ?? p?.ButtonImageUrl ?? null) as string | null, buttonStyleJson: (p?.buttonStyleJson ?? p?.ButtonStyleJson ?? null) as string | null, availabilityType: (p?.availabilityType ?? p?.AvailabilityType ?? null) as string | null, name: String(p?.name ?? p?.Name ?? ''), itemCount: Number(p?.itemCount ?? p?.ItemCount ?? products.length), products, } }) return { id: String(node?.id ?? node?.Id ?? ''), categoryName: String(node?.categoryName ?? node?.CategoryName ?? ''), categoryPhotoUrl: (node?.categoryPhotoUrl ?? node?.CategoryPhotoUrl ?? null) as string | null, displayText: (node?.displayText ?? node?.DisplayText ?? null) as string | null, buttonBgColor: (node?.buttonBgColor ?? node?.ButtonBgColor ?? null) as string | null, buttonImageUrl: (node?.buttonImageUrl ?? node?.ButtonImageUrl ?? null) as string | null, buttonTextColor: (node?.buttonTextColor ?? node?.ButtonTextColor ?? null) as string | null, // 透传平台端扩展字段(用于 APP 按 buttonAppearance 渲染 icon) buttonAppearance: (node?.buttonAppearance ?? node?.ButtonAppearance ?? null) as string | string[] | null, buttonStyleJson: (node?.buttonStyleJson ?? node?.ButtonStyleJson ?? null) as string | null, orderNum: Number(node?.orderNum ?? node?.OrderNum ?? 0), productCategories, } }) } function buildLabelingTreePath(params: { locationId: string keyword?: string labelCategoryId?: string }): string { const q: string[] = [`locationId=${encodeURIComponent(params.locationId)}`] if (params.keyword != null && String(params.keyword).trim() !== '') { q.push(`keyword=${encodeURIComponent(String(params.keyword).trim())}`) } if (params.labelCategoryId != null && String(params.labelCategoryId).trim() !== '') { q.push(`labelCategoryId=${encodeURIComponent(String(params.labelCategoryId).trim())}`) } return `/api/app/us-app-labeling/labeling-tree?${q.join('&')}` } /** 接口 8.1 */ export async function fetchUsAppLabelingTree(input: { locationId: string keyword?: string labelCategoryId?: string }): Promise { const key = `labeling-tree:${input.locationId}:${input.keyword || ''}:${input.labelCategoryId || ''}` return fetchWithOfflineCache('labeling', key, async () => { const raw = await usAppApiRequest({ path: buildLabelingTreePath(input), method: 'GET', auth: true, }) return normalizeLabelingTreePayload(raw) }) } export function buildLabelPreviewCacheKey(body: UsAppLabelPreviewInputVo): string { const loc = String(body.locationId || '').trim() const code = String(body.labelCode || '').trim() const pid = String(body.productId || '').trim() return `preview:${loc}:${code}:${pid}` } /** 接口 8.2(在线写入 SQLite,离线读缓存) */ export async function postUsAppLabelPreview(body: UsAppLabelPreviewInputVo): Promise { const key = buildLabelPreviewCacheKey(body) return fetchWithOfflineCache('labeling', key, async () => { return usAppApiRequest({ path: '/api/app/us-app-labeling/preview', method: 'POST', auth: true, data: body, }) }) } const PREVIEW_PREFETCH_MAX_PER_LOCATION = 80 /** 同步时预拉标签预览,供断网进入预览页 */ export async function prefetchUsAppLabelPreviewsForLocation( locationId: string, tree: UsAppLabelCategoryTreeNodeDto[] ): Promise { const loc = String(locationId || '').trim() if (!loc) return const jobs: UsAppLabelPreviewInputVo[] = [] outer: for (const cat of tree) { for (const pc of cat.productCategories ?? []) { for (const prod of pc.products ?? []) { for (const lt of prod.labelTypes ?? []) { const labelCode = String(lt.labelCode || '').trim() if (!labelCode) continue jobs.push({ locationId: loc, labelCode, productId: String(prod.productId || '').trim() || undefined, }) if (jobs.length >= PREVIEW_PREFETCH_MAX_PER_LOCATION) break outer } } } } for (const body of jobs) { try { await postUsAppLabelPreview(body) } catch { // 单条失败不阻断整店同步 } } } /** * 接口 9.1 原始请求。 * 注意:仅供业务落库场景调用;打印机设置页「测试打印」、蓝牙页 Test Print 等 **不得** 使用(避免脏数据)。 */ export async function postUsAppLabelPrint(body: UsAppLabelPrintInputVo): Promise { const online = await isNetworkOnline() if (!online) { await enqueueOfflineMutation('/api/app/us-app-labeling/print', 'POST', body) return { taskId: body.clientRequestId || `offline-${Date.now()}`, printQuantity: Math.max(1, Number(body.printQuantity || 1)), } } console.log('[UsAppLabelPrint] 接口 9 请求前 — path:', US_APP_LABEL_PRINT_PATH) console.log('[UsAppLabelPrint] 接口 9 请求体 body:', body) try { console.log('[UsAppLabelPrint] 接口 9 请求体 JSON:', JSON.stringify(body)) } catch { console.log('[UsAppLabelPrint] 接口 9 请求体 JSON 序列化失败(含循环引用等)') } try { return await usAppApiRequest({ path: US_APP_LABEL_PRINT_PATH, method: 'POST', auth: true, data: body, }) } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) // 在线请求失败时若是网络类错误,自动降级写入本地队列,避免用户操作丢失 if (/network|timeout|offline|断网|网络/i.test(msg)) { await enqueueOfflineMutation('/api/app/us-app-labeling/print', 'POST', body) return { taskId: body.clientRequestId || `offline-${Date.now()}`, printQuantity: Math.max(1, Number(body.printQuantity || 1)), } } throw e } } export function buildUsAppLabelPrintRequestBody(input: { locationId?: string | null labelCode?: string | null productId?: string | null printQuantity: number /** 写入接口 9 `printInputJson`:合并模板快照(可与 `buildPrintInputJson` 结果浅合并),应对齐列表 `renderTemplateJson` */ mergedTemplate: Record clientRequestId?: string | null printerMac?: string | null printerAddress?: string | null }): UsAppLabelPrintInputVo | null { const locationId = String(input.locationId || '').trim() const labelCode = String(input.labelCode || '').trim() if (!locationId || !labelCode) return null let printInputJson: Record try { printInputJson = JSON.parse(JSON.stringify(input.mergedTemplate)) as Record } catch { return null } const body: UsAppLabelPrintInputVo = { locationId, labelCode, printQuantity: Math.max(1, Math.round(Number(input.printQuantity) || 1)), printInputJson, baseTime: new Date().toISOString(), } const cid = String(input.clientRequestId || '').trim() if (cid) body.clientRequestId = cid const pid = String(input.productId || '').trim() if (pid) body.productId = pid const mac = String(input.printerMac || '').trim() if (mac) body.printerMac = mac const addr = String(input.printerAddress || '').trim() if (addr) body.printerAddress = addr return body } /** * 接口 9:仅在 **标签预览页**(`pages/labels/preview`)用户打印真实标签、出纸成功后落库。 * `mergedTemplate` 写入请求体 `printInputJson`(与 label-template 同构);缺少 `locationId` 或 `labelCode` 时不发请求。 * 测试打印模板(printers / bluetooth Test Print)不走此函数。 */ export async function reportUsAppLabelPrintIfReady(input: { locationId?: string | null labelCode?: string | null productId?: string | null printQuantity: number mergedTemplate: Record clientRequestId?: string | null printerMac?: string | null printerAddress?: string | null }): Promise { const body = buildUsAppLabelPrintRequestBody(input) if (!body) return null return postUsAppLabelPrint(body) } function numField (o: Record, camel: string, pascal: string, fallback = 0): number { const v = o[camel] ?? o[pascal] const n = Number(v) return Number.isFinite(n) ? n : fallback } function strField (o: Record, camel: string, pascal: string): string { const v = o[camel] ?? o[pascal] return typeof v === 'string' ? v.trim() : String(v ?? '').trim() } /** 规范化 Label Report 响应(camelCase / PascalCase) */ export function normalizeUsAppLabelReport (raw: unknown): UsAppLabelReportOutputDto { const empty: UsAppLabelReportOutputDto = { summary: { totalLabelsPrinted: 0, totalLabelsPrintedChangeRate: 0, mostPrintedCategoryCount: 0, topProductCount: 0, avgDailyPrints: 0, avgDailyPrintsChangeRate: 0, }, labelsByCategory: [], printVolumeTrend: [], mostUsedProducts: [], } if (!raw || typeof raw !== 'object') return empty const root = raw as Record const rangeRaw = root.appliedRange ?? root.AppliedRange let appliedRange: UsAppLabelReportOutputDto['appliedRange'] if (rangeRaw && typeof rangeRaw === 'object') { const r = rangeRaw as Record appliedRange = { period: strField(r, 'period', 'Period') || undefined, startDate: strField(r, 'startDate', 'StartDate') || undefined, endDate: strField(r, 'endDate', 'EndDate') || undefined, dayCount: numField(r, 'dayCount', 'DayCount', 0) || undefined, trendDescription: strField(r, 'trendDescription', 'TrendDescription') || undefined, } } const sumRaw = root.summary ?? root.Summary const s = (sumRaw && typeof sumRaw === 'object' ? sumRaw : {}) as Record const summary = { totalLabelsPrinted: numField(s, 'totalLabelsPrinted', 'TotalLabelsPrinted', 0), totalLabelsPrintedPrevPeriod: numField(s, 'totalLabelsPrintedPrevPeriod', 'TotalLabelsPrintedPrevPeriod', 0), totalLabelsPrintedChangeRate: numField(s, 'totalLabelsPrintedChangeRate', 'TotalLabelsPrintedChangeRate', 0), mostPrintedCategoryName: strField(s, 'mostPrintedCategoryName', 'MostPrintedCategoryName') || null, mostPrintedCategoryCount: numField(s, 'mostPrintedCategoryCount', 'MostPrintedCategoryCount', 0), topProductName: strField(s, 'topProductName', 'TopProductName') || null, topProductCount: numField(s, 'topProductCount', 'TopProductCount', 0), avgDailyPrints: numField(s, 'avgDailyPrints', 'AvgDailyPrints', 0), avgDailyPrintsPrevPeriod: numField(s, 'avgDailyPrintsPrevPeriod', 'AvgDailyPrintsPrevPeriod', 0), avgDailyPrintsChangeRate: numField(s, 'avgDailyPrintsChangeRate', 'AvgDailyPrintsChangeRate', 0), } const labelsByCategory: UsAppLabelReportOutputDto['labelsByCategory'] = [] const labelsRaw = root.labelsByCategory ?? root.LabelsByCategory if (Array.isArray(labelsRaw)) { for (const x of labelsRaw) { if (!x || typeof x !== 'object') continue const row = x as Record const name = strField(row, 'categoryName', 'CategoryName') labelsByCategory.push({ categoryId: strField(row, 'categoryId', 'CategoryId') || null, categoryName: name || 'Uncategorized', count: numField(row, 'count', 'Count', 0), }) } } const printVolumeTrend: UsAppLabelReportOutputDto['printVolumeTrend'] = [] const trendRaw = root.printVolumeTrend ?? root.PrintVolumeTrend if (Array.isArray(trendRaw)) { for (const x of trendRaw) { if (!x || typeof x !== 'object') continue const row = x as Record const date = strField(row, 'date', 'Date') if (!date) continue printVolumeTrend.push({ date, count: numField(row, 'count', 'Count', 0), }) } } const mostUsedProducts: UsAppLabelReportOutputDto['mostUsedProducts'] = [] const productsRaw = root.mostUsedProducts ?? root.MostUsedProducts if (Array.isArray(productsRaw)) { for (const x of productsRaw) { if (!x || typeof x !== 'object') continue const row = x as Record const productName = strField(row, 'productName', 'ProductName') if (!productName) continue mostUsedProducts.push({ productId: strField(row, 'productId', 'ProductId') || null, productName, categoryName: strField(row, 'categoryName', 'CategoryName') || '—', totalPrinted: numField(row, 'totalPrinted', 'TotalPrinted', 0), usagePercent: numField(row, 'usagePercent', 'UsagePercent', 0), }) } } return { appliedRange, summary, labelsByCategory, printVolumeTrend, mostUsedProducts, } } /** 按 5-27 文档计算自然日区间(含起止日) */ export function buildUsAppLabelReportDateRange (input: { period: UsAppLabelReportQueryInputVo['period'] customStart?: string customEnd?: string }): { startDate: string; endDate: string } { const pad = (n: number) => String(n).padStart(2, '0') const fmt = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` const parseYmd = (s: string): Date | null => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim()) if (!m) return null const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) return Number.isNaN(d.getTime()) ? null : d } const today = new Date() today.setHours(0, 0, 0, 0) const period = input.period || '7d' let end = today let start: Date if (period === 'custom') { const cs = input.customStart ? parseYmd(input.customStart) : null const ce = input.customEnd ? parseYmd(input.customEnd) : null end = ce || today start = cs || new Date(end) start.setDate(start.getDate() - 6) if (start > end) start = new Date(end) } else { const days = period === '90d' ? 90 : period === '30d' ? 30 : 7 end = today start = new Date(end) start.setDate(start.getDate() - (days - 1)) } return { startDate: fmt(start), endDate: fmt(end) } } /** Label Report:POST get-label-report */ export async function fetchUsAppLabelReport ( input: UsAppLabelReportQueryInputVo, ): Promise { const period = input.period || '7d' const { startDate, endDate } = buildUsAppLabelReportDateRange({ period, customStart: input.startDate, customEnd: input.endDate, }) const body: Record = { locationId: input.locationId, period, startDate: period === 'custom' ? input.startDate || startDate : startDate, endDate: period === 'custom' ? input.endDate || endDate : endDate, } if (input.keyword?.trim()) { body.keyword = input.keyword.trim() } const raw = await usAppApiRequest({ path: '/api/app/us-app-labeling/get-label-report', method: 'POST', auth: true, data: body, }) return normalizeUsAppLabelReport(raw) } /** 接口 10:分页打印日志 */ export async function fetchUsAppPrintLogList (input: PrintLogGetListInputVo) { const key = `print-log:${input.locationId}:${input.skipCount ?? 1}:${input.maxResultCount ?? 20}` return fetchWithOfflineCache('labeling', key, async () => { const raw = await usAppApiRequest({ path: '/api/app/us-app-labeling/get-print-log-list', method: 'POST', auth: true, data: { locationId: input.locationId, skipCount: input.skipCount ?? 1, maxResultCount: input.maxResultCount ?? 20, }, }) return extractPagedItems(raw) }) } /** 接口 11:重打并返回含 mergedTemplateJson 的出参 */ export async function postUsAppLabelReprint (body: UsAppLabelReprintInputVo): Promise { const online = await isNetworkOnline() if (!online) { await enqueueOfflineMutation('/api/app/us-app-labeling/reprint', 'POST', body) return { taskId: body.clientRequestId || body.taskId || `offline-reprint-${Date.now()}`, printQuantity: Math.max(1, Number(body.printQuantity || 1)), } } try { return await usAppApiRequest({ path: '/api/app/us-app-labeling/reprint', method: 'POST', auth: true, data: body, }) } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) if (/network|timeout|offline|断网|网络/i.test(msg)) { await enqueueOfflineMutation('/api/app/us-app-labeling/reprint', 'POST', body) return { taskId: body.clientRequestId || body.taskId || `offline-reprint-${Date.now()}`, printQuantity: Math.max(1, Number(body.printQuantity || 1)), } } throw e } }