usAppLabeling.ts 12.8 KB
import type {
  PrintLogGetListInputVo,
  PrintLogItemDto,
  UsAppLabelCategoryTreeNodeDto,
  UsAppLabelingProductNodeDto,
  UsAppLabelPreviewInputVo,
  UsAppLabelPrintInputVo,
  UsAppLabelPrintOutputDto,
  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 ?? ''),
          productName: String(x?.productName ?? x?.ProductName ?? ''),
          productCode: String(x?.productCode ?? x?.ProductCode ?? ''),
          productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? 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<UsAppLabelCategoryTreeNodeDto[]> {
  const key = `labeling-tree:${input.locationId}:${input.keyword || ''}:${input.labelCategoryId || ''}`
  return fetchWithOfflineCache('labeling', key, async () => {
    const raw = await usAppApiRequest<unknown>({
      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<unknown> {
  const key = buildLabelPreviewCacheKey(body)
  return fetchWithOfflineCache('labeling', key, async () => {
    return usAppApiRequest<unknown>({
      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<void> {
  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<UsAppLabelPrintOutputDto> {
  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<UsAppLabelPrintOutputDto>({
      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<string, unknown>
  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<string, unknown>
  try {
    printInputJson = JSON.parse(JSON.stringify(input.mergedTemplate)) as Record<string, unknown>
  } 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<string, unknown>
  clientRequestId?: string | null
  printerMac?: string | null
  printerAddress?: string | null
}): Promise<UsAppLabelPrintOutputDto | null> {
  const body = buildUsAppLabelPrintRequestBody(input)
  if (!body) return null
  return postUsAppLabelPrint(body)
}

/** 接口 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<unknown>({
      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<PrintLogItemDto>(raw)
  })
}

/** 接口 11:重打并返回含 mergedTemplateJson 的出参 */
export async function postUsAppLabelReprint (body: UsAppLabelReprintInputVo): Promise<UsAppLabelPrintOutputDto> {
  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<UsAppLabelPrintOutputDto>({
      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
  }
}