Просмотр исходного кода

feat(web-admin): add mobile print preview for transport plan slips.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 месяцев назад
Родитель
Сommit
866a87f747
2 измененных файлов с 266 добавлено и 9 удалено
  1. 127 0
      web-admin/src/utils/mp-pl-plan-print.ts
  2. 139 9
      web-admin/src/views/m/pl/plan/print.vue

+ 127 - 0
web-admin/src/utils/mp-pl-plan-print.ts

@@ -0,0 +1,127 @@
+import { fetchProductDetail } from '@/api/crm/product'
+import { planApi, type Plan } from '@/api/pl'
+import { useDictStore } from '@/store/modules/dict'
+import { useUserStore } from '@/store/modules/user'
+import { applyDeliveryCustomerToPlan } from '@/utils/pl-delivery-slip-print'
+import {
+  generateDeliverySlipTemplate,
+  renderBlankDeliverySlipHtml,
+  replaceDeliverySlipPlaceholders
+} from '@/utils/pl-delivery-slip-template'
+import {
+  generateInboundSlipTemplate,
+  renderBlankInboundSlipHtml,
+  replaceInboundSlipPlaceholders
+} from '@/utils/pl-inbound-slip-template'
+import {
+  formatSubpackSlipDateIso,
+  generateSubpackSlipTemplate,
+  renderBlankSubpackSlipHtml,
+  replaceSubpackSlipPlaceholders
+} from '@/utils/pl-subpack-slip-template'
+
+export type MpPlanPrintKind =
+  | 'inbound'
+  | 'delivery'
+  | 'subpack'
+  | 'blank-inbound'
+  | 'blank-delivery'
+  | 'blank-subpack'
+
+async function loadProductCodeContext(plan: Plan) {
+  const dictStore = useDictStore()
+  await dictStore.loadAllDict()
+  const productIds = [
+    ...new Set(
+      (plan.products || []).map((item) => String(item.productId || '').trim()).filter(Boolean)
+    )
+  ]
+  const productCodeById: Record<string, string> = {}
+  await Promise.all(
+    productIds.map(async (productId) => {
+      try {
+        const res = await fetchProductDetail({ id: productId })
+        const data = (res as { data?: { productNo?: string }; productNo?: string }).data ?? res
+        const productNo = String((data as { productNo?: string }).productNo || '').trim()
+        if (productNo) productCodeById[productId] = productNo
+      } catch {
+        // 单个产品查询失败时保留计划明细中的冗余字段
+      }
+    })
+  )
+  return {
+    productCodeById,
+    getUnitLabel: (unitCode: string) => dictStore.getLabel('crm_product_unit', unitCode) || unitCode
+  }
+}
+
+async function loadSavedTemplate(
+  loader: (id: string) => Promise<unknown>,
+  id: string,
+  fallback: () => string
+) {
+  try {
+    const templateRes = await loader(id)
+    const saved = (templateRes as { data?: string }).data ?? templateRes
+    if (typeof saved === 'string' && saved.trim()) return saved
+  } catch {
+    // 无模板时用默认
+  }
+  return fallback()
+}
+
+export async function buildMpPlanPrintHtml(
+  kind: MpPlanPrintKind,
+  plan: Plan,
+  options?: { customerId?: string }
+): Promise<string> {
+  if (kind === 'blank-inbound') return renderBlankInboundSlipHtml()
+  if (kind === 'blank-delivery') return renderBlankDeliverySlipHtml()
+  if (kind === 'blank-subpack') return renderBlankSubpackSlipHtml()
+
+  const id = String(plan.id || '').trim()
+  if (!id) throw new Error('无法获取计划ID')
+
+  if (kind === 'inbound') {
+    const htmlContent = await loadSavedTemplate(
+      (planId) => planApi.getInboundSlipTemplate(planId),
+      id,
+      () => generateInboundSlipTemplate(plan)
+    )
+    const ctx = await loadProductCodeContext(plan)
+    return replaceInboundSlipPlaceholders(htmlContent, plan, ctx)
+  }
+
+  if (kind === 'delivery') {
+    let view = plan
+    const customerId = String(options?.customerId || '').trim()
+    if (customerId) view = applyDeliveryCustomerToPlan(plan, customerId)
+    const htmlContent = await loadSavedTemplate(
+      (planId) => planApi.getDeliverySlipTemplate(planId),
+      id,
+      () => generateDeliverySlipTemplate(view)
+    )
+    const ctx = await loadProductCodeContext(view)
+    return replaceDeliverySlipPlaceholders(htmlContent, view, ctx)
+  }
+
+  const slipNoRes = await planApi.ensureSubpackSlipNo(id)
+  const subpackSlipNo = String((slipNoRes as { data?: string }).data ?? slipNoRes ?? '').trim()
+  const htmlContent = await loadSavedTemplate(
+    (planId) => planApi.getSubpackSlipTemplate(planId),
+    id,
+    () => generateSubpackSlipTemplate(plan)
+  )
+  const dictStore = useDictStore()
+  const userStore = useUserStore()
+  await dictStore.loadAllDict()
+  const userInfo = userStore.getUserInfo as { realName?: string; userName?: string } | null
+  const loginUserName = String(userInfo?.realName || userInfo?.userName || '').trim()
+  return replaceSubpackSlipPlaceholders(htmlContent, plan, {
+    getUnitLabel: (unitCode: string) => dictStore.getLabel('crm_product_unit', unitCode) || unitCode,
+    subpackSlipNo,
+    orderDate: formatSubpackSlipDateIso(),
+    orderUser: loginUserName,
+    preparerUser: loginUserName
+  })
+}

+ 139 - 9
web-admin/src/views/m/pl/plan/print.vue

@@ -1,18 +1,148 @@
 <template>
   <MpWebviewShell>
-    <p class="mp-wv-placeholder">打印预览加载中…</p>
+    <div v-if="forbidden" class="mp-wv-empty">无权限</div>
+    <div v-else-if="errorText" class="mp-wv-empty">{{ errorText }}</div>
+    <template v-else>
+      <header class="mp-wv-header">
+        <div class="mp-wv-header__row">
+          <button class="mp-wv-header__back" type="button" @click="goBack">← 详情</button>
+        </div>
+        <h1>打印单据</h1>
+      </header>
+
+      <p class="mp-print-hint">长按预览区可保存图片;WebView 不支持系统打印。</p>
+
+      <div class="mp-wv-chips">
+        <button
+          v-for="item in kindOptions"
+          :key="item.value"
+          type="button"
+          class="mp-wv-chip"
+          :class="{ 'is-active': kind === item.value }"
+          @click="selectKind(item.value)"
+        >
+          {{ item.label }}
+        </button>
+      </div>
+
+      <div v-if="kind === 'delivery' && customers.length > 1" class="mp-wv-toolbar">
+        <el-select
+          v-model="customerId"
+          placeholder="选择送货客户"
+          style="width: 100%"
+          @change="reloadPreview"
+        >
+          <el-option
+            v-for="item in customers"
+            :key="item.customerId"
+            :label="item.customerName"
+            :value="item.customerId"
+          />
+        </el-select>
+      </div>
+
+      <div v-if="loading" class="mp-wv-empty">正在生成单据…</div>
+      <div v-else-if="blockReason" class="mp-wv-empty">{{ blockReason }}</div>
+      <div v-else class="mp-print-preview" v-html="html"></div>
+    </template>
   </MpWebviewShell>
 </template>
 
 <script setup lang="ts">
+  import { onMounted, ref } from 'vue'
+  import { useRoute, useRouter } from 'vue-router'
   import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
-</script>
+  import { planApi, type Plan } from '@/api/pl'
+  import { canBatchPrintSlip } from '@/utils/pl-batch-print'
+  import { listDeliveryPrintCustomers } from '@/utils/pl-delivery-slip-print'
+  import { buildMpPlanPrintHtml, type MpPlanPrintKind } from '@/utils/mp-pl-plan-print'
+  import { HttpError } from '@/utils/http/error'
+  import { ApiStatus } from '@/utils/http/status'
+  import '@/styles/mp-webview.scss'
+
+  const kindOptions: { label: string; value: MpPlanPrintKind }[] = [
+    { label: '入库单', value: 'inbound' },
+    { label: '送(提)货单', value: 'delivery' },
+    { label: '分装单', value: 'subpack' },
+    { label: '空白入库单', value: 'blank-inbound' },
+    { label: '空白送(提)货单', value: 'blank-delivery' },
+    { label: '空白分装单', value: 'blank-subpack' }
+  ]
 
-<style scoped>
-  .mp-wv-placeholder {
-    margin: 24px 0 0;
-    font-size: 14px;
-    color: #6b7b74;
-    text-align: center;
+  const route = useRoute()
+  const router = useRouter()
+  const plan = ref<Plan | null>(null)
+  const kind = ref<MpPlanPrintKind>('inbound')
+  const html = ref('')
+  const loading = ref(false)
+  const forbidden = ref(false)
+  const errorText = ref('')
+  const blockReason = ref('')
+  const customerId = ref('')
+  const customers = ref<{ customerId: string; customerName: string }[]>([])
+
+  function goBack() {
+    if (window.history.length > 1) {
+      router.back()
+      return
+    }
+    const id = String(route.params.id || '')
+    void router.replace(id ? `/m/pl/plan/${id}` : '/m/pl/plan')
   }
-</style>
+
+  function slipCommand(current: MpPlanPrintKind) {
+    if (current === 'inbound' || current === 'delivery' || current === 'subpack') return current
+    return null
+  }
+
+  async function reloadPreview() {
+    const current = kind.value
+    const command = slipCommand(current)
+    if (command && plan.value) {
+      const check = canBatchPrintSlip([plan.value], command)
+      if (!check.ok) {
+        html.value = ''
+        blockReason.value = check.reason || '无法打印'
+        return
+      }
+    }
+    blockReason.value = ''
+    loading.value = true
+    try {
+      html.value = await buildMpPlanPrintHtml(current, plan.value as Plan, {
+        customerId: customerId.value
+      })
+    } catch (error) {
+      html.value = ''
+      blockReason.value = error instanceof Error ? error.message : '加载单据失败'
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function selectKind(next: MpPlanPrintKind) {
+    kind.value = next
+    void reloadPreview()
+  }
+
+  onMounted(async () => {
+    const id = String(route.params.id || '')
+    if (!id) {
+      errorText.value = '缺少计划 ID'
+      return
+    }
+    try {
+      const res = await planApi.get(id)
+      plan.value = ((res as { data?: Plan }).data ?? res) as Plan
+      customers.value = listDeliveryPrintCustomers(plan.value)
+      if (customers.value.length === 1) customerId.value = customers.value[0].customerId
+      await reloadPreview()
+    } catch (error) {
+      if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
+        forbidden.value = true
+        return
+      }
+      errorText.value = error instanceof Error ? error.message : '加载失败'
+    }
+  })
+</script>