wuzilong 1 月之前
父节点
当前提交
8e87c7d576
共有 33 个文件被更改,包括 1446 次插入 和 1069 次删除
  1. 1 1
      backend/app.pid
  2. 19 20
      backend/app/api/bx/reimburse.py
  3. 12 1
      backend/app/services/bx/reimburse.py
  4. 2 1
      backend/app/services/pl_dibang_bridge_service.py
  5. 14 2
      backend/app/services/pl_plan_stop_rules.py
  6. 464 475
      backend/graphify-out/GRAPH_REPORT.md
  7. 0 0
      backend/graphify-out/cache/ast/0395194695d13ef27746ed230ed6d1aedac178556679807fce1dcb67febbaca7.json
  8. 0 0
      backend/graphify-out/cache/ast/1ec88d2445572aef85e75ee4ab017f96aa45f0fb4f47dfa9bd33e88b0185c0f1.json
  9. 0 0
      backend/graphify-out/cache/ast/3312cde24b58395f9b53e03c392d2720f15628a44fd56b935d0e1af3138fd01b.json
  10. 0 0
      backend/graphify-out/cache/ast/809dab2828d80bde3fdee5074aba39b66078ec9a7f2b44adf93a032cdd3a9348.json
  11. 0 0
      backend/graphify-out/cache/ast/8960e423f186dea2d6dc532be28eade4372aa76548292a84246119130ad75ea6.json
  12. 0 0
      backend/graphify-out/cache/ast/9025b38e2b0f422a7f74161f1945f060268788e20d891af5887cfcfeefcc319e.json
  13. 0 0
      backend/graphify-out/cache/ast/e8c40672d86647f65f44c0014182612122797ea1b82966c3c6652cd839b6c811.json
  14. 112 112
      backend/graphify-out/graph.json
  15. 12 0
      backend/tests/test_bx_reimburse_list_filters.py
  16. 3 3
      backend/tests/test_pl_dibang_upsert_on_arrange.py
  17. 45 0
      backend/tests/test_pl_plan_stop_rules.py
  18. 117 2
      bx-mobile/src/views/approval/ApprovalDetailPage.vue
  19. 113 1
      bx-mobile/src/views/reimburse/ReimburseApprovePage.vue
  20. 102 13
      bx-mobile/src/views/reimburse/ReimburseDetailPage.vue
  21. 11 11
      graphify-out/manifest.json
  22. 276 306
      web-admin/graphify-out/GRAPH_REPORT.md
  23. 0 0
      web-admin/graphify-out/cache/ast/2219818b0040b424694855e25cbcb4989d19dea4fb1e1ff3968614bdee314650.json
  24. 1 0
      web-admin/graphify-out/cache/ast/8d864ce312d071306fa7ca438433586ed862bf03b06b484c9e6afd870c8705cc.json
  25. 0 0
      web-admin/graphify-out/cache/ast/964bdf42aff64dbe8ad48e98e9676439e7f74644bb50e901cbcd4472765132ac.json
  26. 0 0
      web-admin/graphify-out/cache/ast/b4db7bc843cd59afc7cfb654edcecf3e276dc048024d340fe00a2d876e182864.json
  27. 0 0
      web-admin/graphify-out/cache/ast/bad47fc6e9ba3f736dc2cc87dacd307877d0c04905caee67b72fc939353d30c6.json
  28. 0 0
      web-admin/graphify-out/cache/ast/f843a0b8029ccef5d9e5683db11f568ef35d9126206b1728ca43a040b4e6c43d.json
  29. 112 112
      web-admin/graphify-out/graph.json
  30. 9 2
      web-admin/src/components/business/column-header-filter/index.vue
  31. 16 5
      web-admin/src/utils/bx/format.ts
  32. 1 0
      web-admin/src/views/bx/reimburse/apply/index.vue
  33. 4 2
      web-admin/src/views/bx/reimburse/approve/index.vue

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-24265
+29350

+ 19 - 20
backend/app/api/bx/reimburse.py

@@ -271,6 +271,7 @@ def list_reimburses(
 
 @router.get("/my", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
 def my_reimburses(
+    request: Request,
     page: int = 1,
     pageSize: int = Query(10, ge=1, le=500),
     status: Optional[str] = None,
@@ -291,32 +292,30 @@ def my_reimburses(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
+    params = _merge_query(
+        request,
+        status=status,
+        keyword=keyword or None,
+        expenseName=expenseName,
+        applyCode=applyCode,
+        title=title,
+        currentNode=currentNode,
+        statusIn=statusIn,
+        amountOp=amountOp,
+        amount=amount,
+        amountMin=amountMin,
+        amountMax=amountMax,
+        applyDate=applyDate,
+        applyDateFrom=applyDateFrom,
+        applyDateTo=applyDateTo,
+    )
     filters = [
         or_(
             BxReimburseApply.applicant_id == user.id,
             BxReimburseApply.create_by == user.id,
         )
     ]
-    filters.extend(
-        build_reimburse_column_filters(
-            db=db,
-            tenant_no=tenant_no,
-            keyword=keyword or "",
-            status=status,
-            status_in=statusIn,
-            apply_code=applyCode,
-            title=title,
-            expense_name=expenseName,
-            current_node=currentNode,
-            amount_op=amountOp,
-            amount=amount,
-            amount_min=amountMin,
-            amount_max=amountMax,
-            apply_date=applyDate,
-            apply_date_from=applyDateFrom,
-            apply_date_to=applyDateTo,
-        )
-    )
+    filters.extend(_filters_from_params(params, db=db, tenant_no=tenant_no))
     rows, total = ReimburseService.get_paged(
         db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user, order_by=_reimburse_order(params)
     )

+ 12 - 1
backend/app/services/bx/reimburse.py

@@ -998,6 +998,17 @@ class ReimburseService:
             .all()
         )
 
+    @staticmethod
+    def detail_expense_keyword_match(name_kw: str):
+        """明细行:费用项目名称或发生日期(YYYY-MM-DD)模糊包含。"""
+        return or_(
+            BxReimburseDetail.expense_name.contains(name_kw),
+            and_(
+                BxReimburseDetail.occur_date.isnot(None),
+                func.date_format(BxReimburseDetail.occur_date, "%Y-%m-%d").contains(name_kw),
+            ),
+        )
+
     @staticmethod
     def build_expense_name_condition(
         db: Session, tenant_no: str, expense_name: Optional[str]
@@ -1010,7 +1021,7 @@ class ReimburseService:
             .filter(
                 BxReimburseDetail.tenant_no == tenant_no,
                 BxReimburseDetail.del_flag == 0,
-                BxReimburseDetail.expense_name.contains(name_kw),
+                ReimburseService.detail_expense_keyword_match(name_kw),
             )
         )
         return or_(

+ 2 - 1
backend/app/services/pl_dibang_bridge_service.py

@@ -1328,7 +1328,8 @@ def upsert_stop_dibang_links(
         option = option_map.get((stop_type, stop.id))
         if option is None:
             return
-        if not option.generate_dibang:
+        # 以 apply_stop_dibang_options 归一化后的段字段为准(外部客户会被强制清勾选)
+        if not int(getattr(stop, "generate_dibang", 0) or 0):
             _cancel_stop_dibang_links(
                 db,
                 tenant_no=tenant_no,

+ 14 - 2
backend/app/services/pl_plan_stop_rules.py

@@ -29,9 +29,21 @@ def normalize_stop_dibang_fields(
     generate_dibang: Optional[int],
     dibang_direction: Optional[str],
 ) -> Tuple[int, str]:
-    """归一化段次地磅勾选与方向:勾选但方向缺失/none 时按本段客户补默认方向。"""
+    """归一化段次地磅勾选与方向。
+
+    - 非内部客户:强制不生成地磅单
+    - 内部客户:尊重显式勾选值(含可选车取消勾选传 0);仅 None 时用默认
+    - 勾选但方向缺失/none 时按本段客户补默认方向
+    """
+    if not is_internal_customer_name(customer_name):
+        return 0, "none"
+
     default_gen, default_dir = resolve_stop_dibang_defaults(customer_name, stop_type)
-    gen = 1 if int(generate_dibang or 0) == 1 else (1 if default_gen else 0)
+    if generate_dibang is None:
+        gen = 1 if default_gen else 0
+    else:
+        gen = 1 if int(generate_dibang) == 1 else 0
+
     direction = (dibang_direction or "").strip()
     if gen:
         if direction not in ("outbound", "inbound"):

文件差异内容过多而无法显示
+ 464 - 475
backend/graphify-out/GRAPH_REPORT.md


文件差异内容过多而无法显示
+ 0 - 0
backend/graphify-out/cache/ast/0395194695d13ef27746ed230ed6d1aedac178556679807fce1dcb67febbaca7.json


文件差异内容过多而无法显示
+ 0 - 0
backend/graphify-out/cache/ast/1ec88d2445572aef85e75ee4ab017f96aa45f0fb4f47dfa9bd33e88b0185c0f1.json


文件差异内容过多而无法显示
+ 0 - 0
backend/graphify-out/cache/ast/3312cde24b58395f9b53e03c392d2720f15628a44fd56b935d0e1af3138fd01b.json


文件差异内容过多而无法显示
+ 0 - 0
backend/graphify-out/cache/ast/809dab2828d80bde3fdee5074aba39b66078ec9a7f2b44adf93a032cdd3a9348.json


文件差异内容过多而无法显示
+ 0 - 0
backend/graphify-out/cache/ast/8960e423f186dea2d6dc532be28eade4372aa76548292a84246119130ad75ea6.json


文件差异内容过多而无法显示
+ 0 - 0
backend/graphify-out/cache/ast/9025b38e2b0f422a7f74161f1945f060268788e20d891af5887cfcfeefcc319e.json


文件差异内容过多而无法显示
+ 0 - 0
backend/graphify-out/cache/ast/e8c40672d86647f65f44c0014182612122797ea1b82966c3c6652cd839b6c811.json


文件差异内容过多而无法显示
+ 112 - 112
backend/graphify-out/graph.json


+ 12 - 0
backend/tests/test_bx_reimburse_list_filters.py

@@ -86,3 +86,15 @@ def test_keyword_includes_expense_name():
     assert "apply_code" in sql
     assert "title" in sql
     assert "expense_name" in sql
+
+
+def test_detail_expense_keyword_matches_name_and_occur_date():
+    """费用项目列模糊检索应同时命中项目名与发生日期(YYYY-MM-DD)。"""
+    from app.services.bx.reimburse import ReimburseService
+
+    clause = ReimburseService.detail_expense_keyword_match("2026-03")
+    sql = _compile(clause)
+    assert "expense_name" in sql
+    assert "occur_date" in sql
+    assert "2026-03" in sql
+    assert "date_format" in sql or "%y-%m-%d" in sql

+ 3 - 3
backend/tests/test_pl_dibang_upsert_on_arrange.py

@@ -146,7 +146,7 @@ def test_upsert_reuses_existing_inventory_id(
     load = PlPlanLoad(
         id="load-1",
         plan_id="plan-1",
-        customer_name="客户A",
+        customer_name="安庆市山峡化工有限公司",
         generate_dibang=1,
         dibang_direction="outbound",
         load_time=datetime(2026, 9, 1, 0, 0),
@@ -306,7 +306,7 @@ def test_upsert_updates_inventory_and_weighing_plate_when_already_weighed(
     load = PlPlanLoad(
         id="load-1",
         plan_id="plan-1",
-        customer_name="山峡",
+        customer_name="安庆市山峡化工有限公司",
         generate_dibang=1,
         dibang_direction="outbound",
         load_time=datetime(2026, 9, 1, 0, 0),
@@ -511,7 +511,7 @@ def test_upsert_remounts_monthly_plan_when_partner_changes(
     load = PlPlanLoad(
         id="load-1",
         plan_id="plan-1",
-        customer_name="山峡",
+        customer_name="安庆市山峡化工有限公司",
         generate_dibang=1,
         dibang_direction="outbound",
         load_time=datetime(2026, 9, 1, 0, 0),

+ 45 - 0
backend/tests/test_pl_plan_stop_rules.py

@@ -53,6 +53,51 @@ def test_normalize_stop_dibang_fields_repairs_none_direction():
     assert direction == "inbound"
 
 
+def test_normalize_respects_explicit_uncheck_for_internal_customer():
+    """内部客户显式传 0(如可选车皖H21522 取消勾选)时须保留,不得用默认值强制回 1。"""
+    gen, direction = normalize_stop_dibang_fields(
+        "安庆市山峡化工有限公司",
+        "load",
+        0,
+        "none",
+    )
+    assert gen == 0
+    assert direction == "none"
+
+
+def test_normalize_respects_explicit_uncheck_for_internal_delivery():
+    gen, direction = normalize_stop_dibang_fields(
+        "安徽云栋化工有限公司",
+        "delivery",
+        0,
+        "inbound",
+    )
+    assert gen == 0
+    assert direction == "none"
+
+
+def test_normalize_forces_off_for_external_even_when_checked():
+    gen, direction = normalize_stop_dibang_fields(
+        "外部客户有限公司",
+        "load",
+        1,
+        "outbound",
+    )
+    assert gen == 0
+    assert direction == "none"
+
+
+def test_normalize_keeps_explicit_check_for_internal():
+    gen, direction = normalize_stop_dibang_fields(
+        "安庆市山峡化工有限公司",
+        "load",
+        1,
+        "outbound",
+    )
+    assert gen == 1
+    assert direction == "outbound"
+
+
 def test_external_empty_name_no_dibang():
     assert resolve_stop_dibang_defaults(None, "load") == (False, "none")
     assert resolve_stop_dibang_defaults("  ", "delivery") == (False, "none")

+ 117 - 2
bx-mobile/src/views/approval/ApprovalDetailPage.vue

@@ -136,6 +136,47 @@
                   <div class="adv-field__value adv-field__value--multiline">{{ detail.remark }}</div>
                 </div>
               </section>
+              <section
+                v-if="invoiceDisplayList.length"
+                class="detail-info-card detail-info-card--media"
+                aria-label="关联发票"
+              >
+                <div class="detail-info-card__title">关联发票</div>
+                <div
+                  v-for="row in invoiceDisplayList"
+                  :key="row.inv.id"
+                  class="approve-invoice-item"
+                >
+                  <div class="list-card-kv" style="padding: 6px 0">
+                    <span class="list-card-kv__k">金额 / 备注</span>
+                    <span class="list-card-kv__v">
+                      ¥{{ Number(row.inv.amount || 0).toFixed(2) }} · {{ row.inv.remark || '-' }}
+                    </span>
+                  </div>
+                  <template v-if="row.file">
+                    <div class="advance-detail-att-grid att-grid advance-att-grid">
+                      <div
+                        class="att-img advance-att-tile"
+                        :title="row.file.fileName"
+                        role="button"
+                        tabindex="0"
+                        @click="openInvoiceFile(row.file)"
+                        @keydown.enter.prevent="openInvoiceFile(row.file)"
+                      >
+                        <img
+                          v-if="isImage(row.file.fileType) && row.file.previewUrl"
+                          :src="row.file.previewUrl"
+                          :alt="row.file.fileName"
+                        />
+                        <div v-else class="att-file-icon att-doc-thumb">
+                          <span class="att-doc-thumb__ext">{{ fileExt(row.file.fileName) }}</span>
+                          <span class="att-doc-thumb__name">{{ row.file.fileName }}</span>
+                        </div>
+                      </div>
+                    </div>
+                  </template>
+                </div>
+              </section>
               <section class="detail-info-card detail-info-card--media" aria-label="申请附件">
                 <div class="detail-info-card__title">申请附件</div>
                 <template v-if="attachments.length > 0">
@@ -349,7 +390,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { showToast } from 'vant'
 import { PRIMARY_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
-import { advanceApi, reimburseApi, paymentApi } from '@/api'
+import { advanceApi, reimburseApi, paymentApi, attachmentApi } from '@/api'
 import { statusLabel, ticketHeaderStatusToneClass, formatAmount, formatDateTime, formatApplyDateTime } from '@/utils/format'
 import { useAttachment } from '@/hooks/useAttachment'
 import { useExpenseItemTree, resolveExpenseDisplay } from '@/hooks/useExpenseItemTree'
@@ -381,6 +422,24 @@ const issueAtt = useAttachment()
 const issueAttachments = issueAtt.files
 
 const advanceOffsetRecords = ref<any[]>([])
+const invoiceAttachments = ref<any[]>([])
+
+const linkedInvoices = computed(() => {
+  if (bizType !== 'reimburse') return []
+  const list = detail.value?.invoices
+  return Array.isArray(list) ? list : []
+})
+
+const invoiceDisplayList = computed(() => {
+  const attMap = new Map(invoiceAttachments.value.map((f) => [String(f.id), f]))
+  return linkedInvoices.value.map((inv: any) => {
+    const aid = String(inv.attachmentId || inv.attachment_id || '').trim()
+    return {
+      inv,
+      file: aid ? attMap.get(aid) || null : null,
+    }
+  })
+})
 
 const reimburseExpenseSummary = computed(() => {
   const d = detail.value
@@ -388,6 +447,47 @@ const reimburseExpenseSummary = computed(() => {
   return resolveExpenseDisplay(d, flatList.value)
 })
 
+async function loadInvoiceAttachmentsFromDetail(invoices: any[]) {
+  const files = (invoices || [])
+    .map((inv: any) => {
+      const attId = String(inv.attachmentId || inv.attachment_id || '').trim()
+      if (!attId) return null
+      return {
+        id: attId,
+        fileName: inv.fileName || inv.file_name || '发票附件',
+        fileType: inv.fileType || inv.file_type || '',
+        previewUrl: '',
+      }
+    })
+    .filter(Boolean) as any[]
+
+  const seen = new Set<string>()
+  const unique = files.filter((f) => {
+    const key = String(f.id)
+    if (seen.has(key)) return false
+    seen.add(key)
+    return true
+  })
+
+  await Promise.all(
+    unique
+      .filter((a) => isImage(a.fileType || ''))
+      .map(async (a) => {
+        try {
+          const pRes: any = await attachmentApi.preview(a.id)
+          a.previewUrl = pRes.data?.url || pRes.url || ''
+        } catch {
+          /* skip */
+        }
+      }),
+  )
+  return unique
+}
+
+function openInvoiceFile(file: { id: string; fileName: string; fileType: string; previewUrl?: string }) {
+  open(file as any)
+}
+
 const isAdvanceIssuedApprovalDetail = computed(() => {
   const d = detail.value
   return !!(d && bizType === 'advance' && (d.issueStatus === 'issued' || d.status === 'issued'))
@@ -420,6 +520,7 @@ const api = bizType === 'advance' ? advanceApi : reimburseApi
 async function loadDetail() {
   loading.value = true
   advanceOffsetRecords.value = []
+  invoiceAttachments.value = []
   issueAtt.clear()
   try {
     const res: any = await api.getById(id)
@@ -453,7 +554,16 @@ async function loadDetail() {
     }
 
     // Load attachments(申请)
-    await loadAttachments(bizType, id)
+    await loadAttachments(bizType === 'reimburse' ? 'reimburse_apply' : bizType, id)
+
+    if (bizType === 'reimburse') {
+      try {
+        const invoices = Array.isArray(detail.value?.invoices) ? detail.value.invoices : []
+        invoiceAttachments.value = await loadInvoiceAttachmentsFromDetail(invoices)
+      } catch {
+        invoiceAttachments.value = []
+      }
+    }
 
     if (bizType === 'advance') {
       try {
@@ -525,6 +635,11 @@ onMounted(loadDetail)
 </script>
 
 <style scoped>
+.approve-invoice-item + .approve-invoice-item {
+  margin-top: 8px;
+  padding-top: 8px;
+  border-top: 1px solid var(--border);
+}
 .advance-ticket-shell {
   --ticket-bg: var(--primary);
   --ticket-skin: var(--primary-light);

+ 113 - 1
bx-mobile/src/views/reimburse/ReimburseApprovePage.vue

@@ -256,6 +256,48 @@
               </div>
             </section>
 
+            <section
+              v-if="invoiceDisplayList.length"
+              class="detail-info-card detail-info-card--media"
+              aria-label="关联发票"
+            >
+              <div class="detail-info-card__title">关联发票</div>
+              <div
+                v-for="row in invoiceDisplayList"
+                :key="row.inv.id"
+                class="approve-invoice-item"
+              >
+                <div class="list-card-kv" style="padding: 6px 0">
+                  <span class="list-card-kv__k">金额 / 备注</span>
+                  <span class="list-card-kv__v">
+                    ¥{{ Number(row.inv.amount || 0).toFixed(2) }} · {{ row.inv.remark || '-' }}
+                  </span>
+                </div>
+                <template v-if="row.file">
+                  <div class="advance-detail-att-grid att-grid advance-att-grid">
+                    <div
+                      class="att-img advance-att-tile"
+                      :title="row.file.fileName"
+                      role="button"
+                      tabindex="0"
+                      @click="onPreviewPopupFile(row.file, invoiceAttachments)"
+                      @keydown.enter.prevent="onPreviewPopupFile(row.file, invoiceAttachments)"
+                    >
+                      <img
+                        v-if="isImageAtt(row.file.fileType) && row.file.previewUrl"
+                        :src="row.file.previewUrl"
+                        :alt="row.file.fileName"
+                      />
+                      <div v-else class="att-file-icon att-doc-thumb">
+                        <span class="att-doc-thumb__ext">{{ fileExtAtt(row.file.fileName) }}</span>
+                        <span class="att-doc-thumb__name">{{ row.file.fileName }}</span>
+                      </div>
+                    </div>
+                  </div>
+                </template>
+              </div>
+            </section>
+
             <section class="detail-info-card detail-info-card--media" aria-label="申请附件">
               <div class="detail-info-card__title">申请附件</div>
               <template v-if="viewAttachments.length > 0">
@@ -319,7 +361,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive } from 'vue'
+import { ref, reactive, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import { showToast, showImagePreview } from 'vant'
 import { PRIMARY_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
@@ -369,8 +411,26 @@ const showDetail = ref(false)
 const detailData = ref<any>(null)
 const flowNodes = ref<any[]>([])
 const viewAttachments = ref<any[]>([])
+const invoiceAttachments = ref<any[]>([])
 const loadingMap = reactive<Record<string, boolean>>({})
 
+const linkedInvoices = computed(() => {
+  const list = detailData.value?.invoices
+  return Array.isArray(list) ? list : []
+})
+
+/** 关联发票 + 可预览附件(按 attachmentId 对齐) */
+const invoiceDisplayList = computed(() => {
+  const attMap = new Map(invoiceAttachments.value.map((f) => [String(f.id), f]))
+  return linkedInvoices.value.map((inv: any) => {
+    const aid = String(inv.attachmentId || inv.attachment_id || '').trim()
+    return {
+      inv,
+      file: aid ? attMap.get(aid) || null : null,
+    }
+  })
+})
+
 function reimburseLineSubtitle(item: Record<string, any>) {
   const ex = resolveExpenseDisplay(item, flatList.value)
   return item.title || (ex !== '-' ? ex : '-')
@@ -414,6 +474,44 @@ async function loadAttWithPreview(biz: string, id: string): Promise<any[]> {
   return files
 }
 
+/** 从详情 invoices 组装可预览附件列表 */
+async function loadInvoiceAttachmentsFromDetail(invoices: any[]): Promise<any[]> {
+  const files = (invoices || [])
+    .map((inv: any) => {
+      const id = String(inv.attachmentId || inv.attachment_id || '').trim()
+      if (!id) return null
+      return {
+        id,
+        fileName: inv.fileName || inv.file_name || '发票附件',
+        fileType: inv.fileType || inv.file_type || '',
+        previewUrl: '',
+      }
+    })
+    .filter(Boolean) as any[]
+
+  const seen = new Set<string>()
+  const unique = files.filter((f) => {
+    const key = String(f.id)
+    if (seen.has(key)) return false
+    seen.add(key)
+    return true
+  })
+
+  await Promise.all(
+    unique
+      .filter((a) => isImageAtt(a.fileType || ''))
+      .map(async (a) => {
+        try {
+          const pRes: any = await attachmentApi.preview(a.id)
+          a.previewUrl = pRes.data?.url || pRes.url || ''
+        } catch {
+          /* skip */
+        }
+      }),
+  )
+  return unique
+}
+
 async function onPreviewPopupFile(file: { id: string | number; fileName: string; fileType: string; previewUrl?: string }, list: any[]) {
   if (isImageAtt(file.fileType) && file.previewUrl) {
     const images = list
@@ -530,6 +628,7 @@ async function openDetail(item: any) {
   detailData.value = item
   flowNodes.value = []
   viewAttachments.value = []
+  invoiceAttachments.value = []
   showDetail.value = true
 
   // Load full detail
@@ -554,11 +653,24 @@ async function openDetail(item: any) {
   } catch {
     viewAttachments.value = []
   }
+
+  try {
+    const invoices = Array.isArray(detailData.value?.invoices) ? detailData.value.invoices : []
+    invoiceAttachments.value = await loadInvoiceAttachmentsFromDetail(invoices)
+  } catch {
+    invoiceAttachments.value = []
+  }
 }
 
 </script>
 
 <style scoped>
+.approve-invoice-item + .approve-invoice-item {
+  margin-top: 8px;
+  padding-top: 8px;
+  border-top: 1px solid var(--border);
+}
+
 .reimburse-list-card__title-wrap {
   flex: 1;
   min-width: 0;

+ 102 - 13
bx-mobile/src/views/reimburse/ReimburseDetailPage.vue

@@ -127,21 +127,44 @@
             </section>
 
             <section
-              v-if="linkedInvoices.length"
-              class="detail-info-card"
+              v-if="invoiceDisplayList.length"
+              class="detail-info-card detail-info-card--media"
               aria-label="关联发票"
             >
               <div class="detail-info-card__title">关联发票</div>
               <div
-                v-for="inv in linkedInvoices"
-                :key="inv.id"
-                class="list-card-kv"
-                style="padding: 6px 0"
+                v-for="row in invoiceDisplayList"
+                :key="row.inv.id"
+                class="approve-invoice-item"
               >
-                <span class="list-card-kv__k">金额 / 备注</span>
-                <span class="list-card-kv__v">
-                  ¥{{ Number(inv.amount || 0).toFixed(2) }} · {{ inv.remark || '-' }}
-                </span>
+                <div class="list-card-kv" style="padding: 6px 0">
+                  <span class="list-card-kv__k">金额 / 备注</span>
+                  <span class="list-card-kv__v">
+                    ¥{{ Number(row.inv.amount || 0).toFixed(2) }} · {{ row.inv.remark || '-' }}
+                  </span>
+                </div>
+                <template v-if="row.file">
+                  <div class="advance-detail-att-grid att-grid advance-att-grid">
+                    <div
+                      class="att-img advance-att-tile"
+                      :title="row.file.fileName"
+                      role="button"
+                      tabindex="0"
+                      @click="onPreviewFile(row.file, invoiceAttachments)"
+                      @keydown.enter.prevent="onPreviewFile(row.file, invoiceAttachments)"
+                    >
+                      <img
+                        v-if="isImageFile(row.file.fileType) && row.file.previewUrl"
+                        :src="row.file.previewUrl"
+                        :alt="row.file.fileName"
+                      />
+                      <div v-else class="att-file-icon att-doc-thumb">
+                        <span class="att-doc-thumb__ext">{{ fileExt(row.file.fileName) }}</span>
+                        <span class="att-doc-thumb__name">{{ row.file.fileName }}</span>
+                      </div>
+                    </div>
+                  </div>
+                </template>
               </div>
             </section>
 
@@ -499,6 +522,17 @@ const linkedInvoices = computed(() => {
   const list = detail.value?.invoices
   return Array.isArray(list) ? list : []
 })
+const invoiceAttachments = ref<any[]>([])
+const invoiceDisplayList = computed(() => {
+  const attMap = new Map(invoiceAttachments.value.map((f) => [String(f.id), f]))
+  return linkedInvoices.value.map((inv: any) => {
+    const aid = String(inv.attachmentId || inv.attachment_id || '').trim()
+    return {
+      inv,
+      file: aid ? attMap.get(aid) || null : null,
+    }
+  })
+})
 const detailTab = ref(0)
 const approvals = ref<any[]>([])
 const applyAttachments = ref<any[]>([])
@@ -519,6 +553,44 @@ function fileExt(name: string): string {
   return ext ? ext.toUpperCase() : 'FILE'
 }
 
+/** 从详情 invoices 组装可预览附件列表 */
+async function loadInvoiceAttachmentsFromDetail(invoices: any[]): Promise<any[]> {
+  const files = (invoices || [])
+    .map((inv: any) => {
+      const id = String(inv.attachmentId || inv.attachment_id || '').trim()
+      if (!id) return null
+      return {
+        id,
+        fileName: inv.fileName || inv.file_name || '发票附件',
+        fileType: inv.fileType || inv.file_type || '',
+        previewUrl: '',
+      }
+    })
+    .filter(Boolean) as any[]
+
+  const seen = new Set<string>()
+  const unique = files.filter((f) => {
+    const key = String(f.id)
+    if (seen.has(key)) return false
+    seen.add(key)
+    return true
+  })
+
+  await Promise.all(
+    unique
+      .filter((a) => isImageFile(a.fileType || ''))
+      .map(async (a) => {
+        try {
+          const pRes: any = await attachmentApi.preview(a.id)
+          a.previewUrl = pRes.data?.url || pRes.url || ''
+        } catch {
+          /* skip */
+        }
+      }),
+  )
+  return unique
+}
+
 const totalAmount = computed(() => {
   if (!detail.value?.details?.length) return formatAmount(detail.value?.amount || 0)
   const total = detail.value.details.reduce((sum: number, d: any) => sum + Number(d.amount || 0), 0)
@@ -642,16 +714,19 @@ async function loadDetail() {
     }
 
     // Load all attachment categories in parallel + 冲抵明细(备用金订单)
-    const [apply, pay, offset, offRes] = await Promise.all([
+    const invoices = Array.isArray(detail.value?.invoices) ? detail.value.invoices : []
+    const [apply, pay, offset, offRes, invoiceAtts] = await Promise.all([
       loadAttachments('reimburse_apply', id),
       loadAttachments('reimburse_pay', id),
       loadAttachments('reimburse_offset', id),
       paymentApi.reimburseOffsetRecords(id).catch(() => ({ data: [] })),
+      loadInvoiceAttachmentsFromDetail(invoices),
     ])
     applyAttachments.value = apply
     payAttachments.value = pay
     offsetAttachments.value = offset
     offsetRecords.value = (offRes as any)?.data ?? []
+    invoiceAttachments.value = invoiceAtts
   } catch (e: any) {
     showToast(e?.message || '加载失败')
   }
@@ -719,9 +794,17 @@ async function handleWithdraw() {
   }
 }
 
-async function onPreviewFile(file: any) {
+async function onPreviewFile(file: any, list?: any[]) {
   if (isImageFile(file.fileType) && file.previewUrl) {
-    showImagePreview({ images: [file.previewUrl] })
+    const source = Array.isArray(list) && list.length ? list : [file]
+    const images = source
+      .filter((f: any) => isImageFile(f.fileType) && f.previewUrl)
+      .map((f: any) => f.previewUrl)
+    const startPosition = Math.max(0, images.indexOf(file.previewUrl))
+    showImagePreview({
+      images: images.length ? images : [file.previewUrl],
+      startPosition,
+    })
   } else {
     await downloadFile(file)
   }
@@ -780,6 +863,12 @@ onMounted(async () => {
 </script>
 
 <style scoped>
+.approve-invoice-item + .approve-invoice-item {
+  margin-top: 8px;
+  padding-top: 8px;
+  border-top: 1px solid var(--border);
+}
+
 /* ===== 票券头 / Tab / 字段 / 附件网格:与备用金详情对齐 ===== */
 .advance-ticket-shell {
   --ticket-bg: var(--primary);

+ 11 - 11
graphify-out/manifest.json

@@ -776,11 +776,11 @@
     "hash": "caab19ac3b94b1cc6b27a1a819f5235c"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/components.d.ts": {
-    "mtime": 1789032305.8491836,
-    "hash": "997f04a523f1e3f617b1fd21562d4ccb"
+    "mtime": 1789116504.6609547,
+    "hash": "3cefe3b0a411d1070ef28c09192f5703"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/auto-imports.d.ts": {
-    "mtime": 1789032262.9490685,
+    "mtime": 1789116494.9720263,
     "hash": "6a338312d64e22c3b1d2281e57938c3f"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/store/index.ts": {
@@ -1308,8 +1308,8 @@
     "hash": "11d249c7ff38dbc94c2bbfa433a00a3b"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/format.ts": {
-    "mtime": 1783747311.0,
-    "hash": "2b982baf33e862d7bb60a6b61e9f5763"
+    "mtime": 1789114389.170361,
+    "hash": "0fb974cf6fde17c66b4baefa6fdf53da"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts": {
     "mtime": 1783213253.0,
@@ -2160,8 +2160,8 @@
     "hash": "651d0310e188f856741cfbb550d6e74a"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/column-header-filter/index.vue": {
-    "mtime": 1788879304.307497,
-    "hash": "e70efe28aad17d66f91f199215a4c92f"
+    "mtime": 1789115248.9575949,
+    "hash": "0b6ab5682c078a2f879e8792bfdf2b6e"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-carrier-list-attachment-cell/index.vue": {
     "mtime": 1786076456.0,
@@ -2884,12 +2884,12 @@
     "hash": "2ac96e03f0a0c3bd152265003d82b021"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/bx/reimburse/apply/index.vue": {
-    "mtime": 1788877420.725508,
-    "hash": "55ac4c72942319fdb9ba962410fda751"
+    "mtime": 1789115272.3924701,
+    "hash": "f9dae4af5b9b4a9eb897b37cd302b64e"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/bx/reimburse/approve/index.vue": {
-    "mtime": 1788877421.9458783,
-    "hash": "ed4ed0b7996dff428a5c7b4c6bb7f70d"
+    "mtime": 1789115272.106782,
+    "hash": "fb48a1ed7ac523abb1867863e24912b6"
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/bx/reimburse/approve/columnFilterDefs.ts": {
     "mtime": 1788672377.96489,

文件差异内容过多而无法显示
+ 276 - 306
web-admin/graphify-out/GRAPH_REPORT.md


文件差异内容过多而无法显示
+ 0 - 0
web-admin/graphify-out/cache/ast/2219818b0040b424694855e25cbcb4989d19dea4fb1e1ff3968614bdee314650.json


+ 1 - 0
web-admin/graphify-out/cache/ast/8d864ce312d071306fa7ca438433586ed862bf03b06b484c9e6afd870c8705cc.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/components.d.ts", "source_location": "L1"}, {"id": "import_components_d_globalcomponents", "label": "GlobalComponents", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/components.d.ts", "source_location": "L10"}, {"id": "import_components_d_globaldirectives", "label": "GlobalDirectives", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/components.d.ts", "source_location": "L299"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_types_import_components_d_ts", "target": "import_components_d_globalcomponents", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/components.d.ts", "source_location": "L10", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_types_import_components_d_ts", "target": "import_components_d_globaldirectives", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/components.d.ts", "source_location": "L299", "weight": 1.0}], "raw_calls": []}

文件差异内容过多而无法显示
+ 0 - 0
web-admin/graphify-out/cache/ast/964bdf42aff64dbe8ad48e98e9676439e7f74644bb50e901cbcd4472765132ac.json


文件差异内容过多而无法显示
+ 0 - 0
web-admin/graphify-out/cache/ast/b4db7bc843cd59afc7cfb654edcecf3e276dc048024d340fe00a2d876e182864.json


文件差异内容过多而无法显示
+ 0 - 0
web-admin/graphify-out/cache/ast/bad47fc6e9ba3f736dc2cc87dacd307877d0c04905caee67b72fc939353d30c6.json


文件差异内容过多而无法显示
+ 0 - 0
web-admin/graphify-out/cache/ast/f843a0b8029ccef5d9e5683db11f568ef35d9126206b1728ca43a040b4e6c43d.json


文件差异内容过多而无法显示
+ 112 - 112
web-admin/graphify-out/graph.json


+ 9 - 2
web-admin/src/components/business/column-header-filter/index.vue

@@ -66,7 +66,7 @@
               @keyup.enter="confirm"
             />
           </div>
-          <p class="field-tip">整库模糊匹配本列</p>
+          <p class="field-tip">{{ textFieldTip }}</p>
         </template>
 
         <!-- 枚举:固定选项勾选 -->
@@ -224,9 +224,12 @@
       options?: Array<{ value: string; label: string; count: number }>
       /** CRM 外壳 CrmColFilterHeader 已自绘双三角时传 false,避免重复 */
       enableSort?: boolean
+      /** 文本过滤底部提示;默认「整库模糊匹配本列」 */
+      textTip?: string
     }>(),
     {
-      enableSort: true
+      enableSort: true,
+      textTip: ''
     }
   )
 
@@ -265,6 +268,10 @@
     return '时间:按日期闭区间过滤'
   })
 
+  const textFieldTip = computed(
+    () => props.textTip?.trim() || '整库模糊匹配本列'
+  )
+
   const visibleOptions = computed(() => {
     const list = props.options || []
     const q = draftKeyword.value.trim().toLowerCase()

+ 16 - 5
web-admin/src/utils/bx/format.ts

@@ -90,23 +90,34 @@ export function moneyTableFormatter(_row: unknown, _column: unknown, cellValue:
 export interface ReimburseExpenseDetailLike {
   expenseName?: string | null
   amount?: number | string | null
+  occurDate?: string | null
 }
 
-/** 列表费用项目列:按明细顺序返回「项目名 (金额元)」各行文案 */
+/** 列表费用项目列:按明细顺序返回「项目名 (金额元) 发生时间」各行文案 */
 export function getReimburseExpenseItemLines(
   details?: ReimburseExpenseDetailLike[] | null,
   fallback?: ReimburseExpenseDetailLike | null
 ): string[] {
-  const formatLine = (name: string, amount?: number | string | null) =>
-    `${name} (${formatMoneyNumber(amount)}元)`
+  const formatLine = (
+    name: string,
+    amount?: number | string | null,
+    occurDate?: string | null
+  ) => {
+    const datePart = String(occurDate ?? '').trim()
+    return datePart
+      ? `${name} (${formatMoneyNumber(amount)}元) ${datePart}`
+      : `${name} (${formatMoneyNumber(amount)}元)`
+  }
 
   const lines = (details ?? []).filter((d) => String(d?.expenseName ?? '').trim())
   if (lines.length) {
-    return lines.map((d) => formatLine(String(d.expenseName).trim(), d.amount))
+    return lines.map((d) =>
+      formatLine(String(d.expenseName).trim(), d.amount, d.occurDate)
+    )
   }
   const name = String(fallback?.expenseName ?? '').trim()
   if (name) {
-    return [formatLine(name, fallback?.amount)]
+    return [formatLine(name, fallback?.amount, fallback?.occurDate)]
   }
   return ['-']
 }

+ 1 - 0
web-admin/src/views/bx/reimburse/apply/index.vue

@@ -182,6 +182,7 @@
                     prop="expenseName"
                     label="费用项目"
                     type="text"
+                    text-tip="模糊匹配费用项目名称或发生时间(如 2026-03)"
                     :is-active="isFilterActive('expenseName')"
                     :model-value="filters.expenseName || null"
                     @update:model-value="(v) => setFilterState('expenseName', v)"

+ 4 - 2
web-admin/src/views/bx/reimburse/approve/index.vue

@@ -164,7 +164,7 @@
                     v-if="isColumnVisible('expenseName')"
                     prop="expenseName"
                     label="费用项目"
-                    min-width="260"
+                    min-width="300"
                     align="left"
                     header-align="center"
                     show-overflow-tooltip
@@ -176,6 +176,7 @@
                           prop="expenseName"
                           label="费用项目"
                           type="text"
+                          text-tip="模糊匹配费用项目名称或发生时间(如 2026-03)"
                           :is-active="isFilterActive('expenseName')"
                           :model-value="filters.expenseName || null"
                           @update:model-value="(v) => setFilterState('expenseName', v)"
@@ -428,7 +429,7 @@
                     v-if="isColumnVisible('expenseName')"
                     prop="expenseName"
                     label="费用项目"
-                    min-width="260"
+                    min-width="300"
                     align="left"
                     header-align="center"
                     show-overflow-tooltip
@@ -440,6 +441,7 @@
                           prop="expenseName"
                           label="费用项目"
                           type="text"
+                          text-tip="模糊匹配费用项目名称或发生时间(如 2026-03)"
                           :is-active="isFilterActive('expenseName')"
                           :model-value="filters.expenseName || null"
                           @update:model-value="(v) => setFilterState('expenseName', v)"

部分文件因为文件数量过多而无法显示