From 24fa9ce6bc3f9c958958d42b1bb9a54a5372089f Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期一, 30 三月 2026 16:35:15 +0800
Subject: [PATCH] feat: complete wh-mat page migration

---
 rsf-design/src/router/adapters/backendMenuAdapter.js                    |   29 +
 rsf-design/src/api/wh-mat.js                                            |   54 +++
 rsf-design/src/router/routes/staticRoutes.js                            |   37 ++
 rsf-design/tests/basic-info-wh-mat-page-contract.test.mjs               |   79 +++++
 rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-detail-drawer.vue |  100 ++++++
 rsf-design/src/views/basic-info/wh-mat/whMatPage.helpers.js             |  151 ++++++++++
 rsf-design/src/views/basic-info/wh-mat/index.vue                        |  329 +++++++++++++++++++++
 rsf-design/src/views/basic-info/wh-mat/whMatTable.columns.js            |   70 ++++
 8 files changed, 849 insertions(+), 0 deletions(-)

diff --git a/rsf-design/src/api/wh-mat.js b/rsf-design/src/api/wh-mat.js
new file mode 100644
index 0000000..ed04047
--- /dev/null
+++ b/rsf-design/src/api/wh-mat.js
@@ -0,0 +1,54 @@
+import request from '@/utils/http'
+
+function normalizeText(value) {
+  return String(value ?? '').trim()
+}
+
+function normalizeQueryParams(params = {}) {
+  const result = {
+    current: params.current || 1,
+    pageSize: params.pageSize || params.size || 20
+  }
+
+  ;['condition', 'code', 'name', 'spec', 'model', 'color', 'size', 'barcode', 'groupId'].forEach((key) => {
+    const value = params[key]
+    if (value === undefined || value === null || value === '') {
+      return
+    }
+    if (typeof value === 'string') {
+      const trimmed = normalizeText(value)
+      if (trimmed) {
+        result[key] = trimmed
+      }
+      return
+    }
+    result[key] = value
+  })
+
+  return result
+}
+
+function normalizeGroupTreeParams(params = {}) {
+  return {
+    condition: normalizeText(params.condition)
+  }
+}
+
+export function fetchMatnrPage(params = {}) {
+  return request.post({
+    url: '/matnr/page',
+    params: normalizeQueryParams(params)
+  })
+}
+
+export function fetchMatnrDetail(id) {
+  return request.get({ url: `/matnr/${id}` })
+}
+
+export function fetchMatnrGroupTree(params = {}) {
+  return request.post({
+    url: '/matnrGroup/tree',
+    params: normalizeGroupTreeParams(params)
+  })
+}
+
diff --git a/rsf-design/src/router/adapters/backendMenuAdapter.js b/rsf-design/src/router/adapters/backendMenuAdapter.js
index 41219c9..8aa8538 100644
--- a/rsf-design/src/router/adapters/backendMenuAdapter.js
+++ b/rsf-design/src/router/adapters/backendMenuAdapter.js
@@ -5,7 +5,32 @@
   console: '/dashboard/console',
   user: '/system/user',
   role: '/system/role',
+  aiParam: '/system/ai-param',
+  aiPrompt: '/system/ai-prompt',
+  aiCallLog: '/system/ai-observe',
+  aiMcpMount: '/system/ai-mcp-mount',
+  dept: '/system/dept',
+  tenant: '/system/tenant',
+  host: '/system/host',
   menu: '/system/menu',
+  config: '/system/config',
+  dictType: '/system/dict-type',
+  fields: '/system/fields',
+  fieldsItem: '/system/fields-item',
+  whMat: '/basic-info/wh-mat',
+  matnr: '/basic-info/wh-mat',
+  warehouseStock: '/stock/warehouse-stock',
+  warehouseAreasItem: '/stock/warehouse-areas-item',
+  qlyInspect: '/manager/qly-inspect',
+  locRevise: '/manager/loc-revise',
+  freeze: '/manager/freeze',
+  stock: '/manager/stock',
+  task: '/manager/task',
+  locPreview: '/manager/loc-preview',
+  waveRule: '/manager/wave-rule',
+  menuPda: '/manager/menu-pda',
+  serialRule: '/system/serial-rule',
+  operationRecord: '/system/operation-record',
   userLogin: '/system/user-login'
 }
 
@@ -107,6 +132,10 @@
     return ''
   }
 
+  if (hasChildren && normalizedKey && !PHASE_1_COMPONENTS[normalizedKey]) {
+    return ''
+  }
+
   if (!normalizedKey) {
     return normalizeComponentPath(fullRoutePath)
   }
diff --git a/rsf-design/src/router/routes/staticRoutes.js b/rsf-design/src/router/routes/staticRoutes.js
index 9dc9903..7057f97 100644
--- a/rsf-design/src/router/routes/staticRoutes.js
+++ b/rsf-design/src/router/routes/staticRoutes.js
@@ -7,6 +7,43 @@
   //   meta: { title: 'menus.dashboard.title' }
   // },
   {
+    path: '/dashboard',
+    component: () => import('@views/index/index.vue'),
+    name: 'Dashboard',
+    meta: { title: 'menus.dashboard.title' },
+    children: [
+      {
+        path: 'console',
+        name: 'Console',
+        component: () => import('@views/dashboard/console/index.vue'),
+        meta: {
+          title: 'menus.dashboard.console',
+          icon: 'ri:home-smile-2-line',
+          keepAlive: false,
+          fixedTab: true
+        }
+      }
+    ]
+  },
+  {
+    path: '/basic-info',
+    component: () => import('@views/index/index.vue'),
+    name: 'BasicInfo',
+    meta: { title: 'menu.basicInfo' },
+    children: [
+      {
+        path: 'wh-mat',
+        name: 'WhMat',
+        component: () => import('@views/basic-info/wh-mat/index.vue'),
+        meta: {
+          title: 'menu.matnr',
+          icon: 'ri:bill-line',
+          keepAlive: false
+        }
+      }
+    ]
+  },
+  {
     path: '/auth/login',
     name: 'Login',
     component: () => import('@views/auth/login/index.vue'),
diff --git a/rsf-design/src/views/basic-info/wh-mat/index.vue b/rsf-design/src/views/basic-info/wh-mat/index.vue
new file mode 100644
index 0000000..72d23c5
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/index.vue
@@ -0,0 +1,329 @@
+<template>
+  <div class="wh-mat-page art-full-height flex flex-col gap-4 xl:flex-row">
+    <ElCard class="w-full shrink-0 xl:w-[320px]">
+      <div class="mb-3 flex items-center justify-between gap-3">
+        <div>
+          <div class="text-base font-medium text-[var(--art-text-primary)]">鐗╂枡鍒嗙粍</div>
+          <div class="text-xs text-[var(--art-text-secondary)]">
+            {{ selectedGroupLabel }}
+          </div>
+        </div>
+        <ElButton text @click="handleResetGroup">鍏ㄩ儴</ElButton>
+      </div>
+
+      <div class="mb-3 flex items-center gap-2">
+        <ElInput
+          v-model.trim="groupSearch"
+          clearable
+          placeholder="鎼滅储鐗╂枡鍒嗙粍"
+          @clear="handleGroupSearch"
+          @keyup.enter="handleGroupSearch"
+        />
+        <ElButton @click="handleGroupSearch">鎼滅储</ElButton>
+      </div>
+
+      <ElScrollbar class="h-[calc(100vh-260px)] pr-1">
+        <div v-if="groupTreeLoading" class="py-6">
+          <ElSkeleton :rows="10" animated />
+        </div>
+        <ElEmpty v-else-if="!groupTreeData.length" description="鏆傛棤鐗╂枡鍒嗙粍" />
+        <ElTree
+          v-else
+          :data="groupTreeData"
+          :props="treeProps"
+          node-key="id"
+          highlight-current
+          default-expand-all
+          :current-node-key="selectedGroupId"
+          @node-click="handleGroupNodeClick"
+        >
+          <template #default="{ data }">
+            <div class="flex items-center gap-2">
+              <span class="font-medium">{{ data.name || '--' }}</span>
+              <span class="text-xs text-[var(--art-text-secondary)]">{{ data.code || '--' }}</span>
+            </div>
+          </template>
+        </ElTree>
+      </ElScrollbar>
+    </ElCard>
+
+    <div class="min-w-0 flex-1 space-y-4">
+      <ArtSearchBar
+        v-model="searchForm"
+        :items="searchItems"
+        :showExpand="true"
+        @search="handleSearch"
+        @reset="handleReset"
+      />
+
+      <ElCard class="art-table-card">
+        <ArtTableHeader :loading="loading" v-model:columns="columnChecks" @refresh="loadMatnrList" />
+
+      <ArtTable
+        :loading="loading"
+        :data="tableData"
+        :columns="columns"
+        :pagination="pagination"
+        @pagination:size-change="handleSizeChange"
+        @pagination:current-change="handleCurrentChange"
+      >
+        <template #action="{ row }">
+          <ArtButtonTable icon="ri:eye-line" @click="openDetailDrawer(row)" />
+        </template>
+      </ArtTable>
+    </ElCard>
+    </div>
+
+    <WhMatDetailDrawer v-model:visible="detailDrawerVisible" :loading="detailLoading" :detail="detailData" />
+  </div>
+</template>
+
+<script setup>
+  import { ElMessage } from 'element-plus'
+  import { computed, onMounted, reactive, ref } from 'vue'
+  import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
+  import { useTableColumns } from '@/hooks/core/useTableColumns'
+  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
+  import { fetchMatnrDetail, fetchMatnrGroupTree, fetchMatnrPage } from '@/api/wh-mat'
+  import WhMatDetailDrawer from './modules/wh-mat-detail-drawer.vue'
+  import { createWhMatTableColumns } from './whMatTable.columns'
+  import {
+    buildMatnrGroupTreeQueryParams,
+    buildMatnrPageQueryParams,
+    createWhMatSearchState,
+    getWhMatTreeNodeLabel,
+    normalizeMatnrDetail,
+    normalizeMatnrGroupTreeRows,
+    normalizeMatnrRow
+  } from './whMatPage.helpers'
+
+  defineOptions({ name: 'WhMat' })
+
+  const loading = ref(false)
+  const groupTreeLoading = ref(false)
+  const detailDrawerVisible = ref(false)
+  const detailLoading = ref(false)
+  const tableData = ref([])
+  const groupTreeData = ref([])
+  const detailData = ref({})
+  const selectedGroupId = ref(null)
+  const groupSearch = ref('')
+  const searchForm = ref(createWhMatSearchState())
+
+  const pagination = reactive({
+    current: 1,
+    size: 20,
+    total: 0
+  })
+
+  const treeProps = {
+    label: 'name',
+    children: 'children'
+  }
+
+  const searchItems = computed(() => [
+    {
+      label: '鍏抽敭瀛�',
+      key: 'condition',
+      type: 'input',
+      props: {
+        clearable: true,
+        placeholder: '璇疯緭鍏ョ墿鏂欑紪鐮�/鐗╂枡鍚嶇О'
+      }
+    },
+    {
+      label: '鐗╂枡缂栫爜',
+      key: 'code',
+      type: 'input',
+      props: {
+        clearable: true,
+        placeholder: '璇疯緭鍏ョ墿鏂欑紪鐮�'
+      }
+    },
+    {
+      label: '鐗╂枡鍚嶇О',
+      key: 'name',
+      type: 'input',
+      props: {
+        clearable: true,
+        placeholder: '璇疯緭鍏ョ墿鏂欏悕绉�'
+      }
+    },
+    {
+      label: '瑙勬牸',
+      key: 'spec',
+      type: 'input',
+      props: {
+        clearable: true,
+        placeholder: '璇疯緭鍏ヨ鏍�'
+      }
+    },
+    {
+      label: '鏉$爜',
+      key: 'barcode',
+      type: 'input',
+      props: {
+        clearable: true,
+        placeholder: '璇疯緭鍏ユ潯鐮�'
+      }
+    }
+  ])
+
+  const { columnChecks, columns } = useTableColumns(() =>
+    createWhMatTableColumns({
+      handleViewDetail: openDetailDrawer
+    })
+  )
+
+  const selectedGroupLabel = computed(() => {
+    if (!selectedGroupId.value) {
+      return '鍏ㄩ儴鐗╂枡'
+    }
+    const found = findGroupNode(groupTreeData.value, selectedGroupId.value)
+    return found ? getWhMatTreeNodeLabel(found) : '鍏ㄩ儴鐗╂枡'
+  })
+
+  function findGroupNode(nodes, targetId) {
+    const normalizedTarget = String(targetId || '')
+    for (const node of nodes || []) {
+      if (String(node.id) === normalizedTarget) {
+        return node
+      }
+      if (node.children?.length) {
+        const child = findGroupNode(node.children, normalizedTarget)
+        if (child) {
+          return child
+        }
+      }
+    }
+    return null
+  }
+
+  function updatePaginationState(target, response, fallbackCurrent, fallbackSize) {
+    target.total = Number(response?.total || 0)
+    target.current = Number(response?.current || fallbackCurrent || 1)
+    target.size = Number(response?.size || fallbackSize || target.size || 20)
+  }
+
+  async function loadGroupTree() {
+    groupTreeLoading.value = true
+    try {
+      const records = await guardRequestWithMessage(
+        fetchMatnrGroupTree(buildMatnrGroupTreeQueryParams({ condition: groupSearch.value })),
+        [],
+        { timeoutMessage: '鐗╂枡鍒嗙粍鍔犺浇瓒呮椂锛屽凡鍋滄绛夊緟' }
+      )
+      const normalizedTree = normalizeMatnrGroupTreeRows(Array.isArray(records) ? records : [])
+      groupTreeData.value = normalizedTree
+      if (selectedGroupId.value && !findGroupNode(normalizedTree, selectedGroupId.value)) {
+        selectedGroupId.value = null
+      }
+    } catch (error) {
+      groupTreeData.value = []
+      ElMessage.error(error?.message || '鐗╂枡鍒嗙粍鍔犺浇澶辫触')
+    } finally {
+      groupTreeLoading.value = false
+    }
+  }
+
+  async function loadMatnrList() {
+    loading.value = true
+    try {
+      const response = await guardRequestWithMessage(
+        fetchMatnrPage(
+          buildMatnrPageQueryParams({
+            ...searchForm.value,
+            groupId: selectedGroupId.value,
+            current: pagination.current,
+            pageSize: pagination.size
+          })
+        ),
+        {
+          records: [],
+          total: 0,
+          current: pagination.current,
+          size: pagination.size
+        },
+        { timeoutMessage: '鐗╂枡鍒楄〃鍔犺浇瓒呮椂锛屽凡鍋滄绛夊緟' }
+      )
+      tableData.value = Array.isArray(response?.records)
+        ? response.records.map((record) => normalizeMatnrRow(record))
+        : []
+      updatePaginationState(pagination, response, pagination.current, pagination.size)
+    } catch (error) {
+      tableData.value = []
+      ElMessage.error(error?.message || '鐗╂枡鍒楄〃鍔犺浇澶辫触')
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function openDetailDrawer(row) {
+    detailDrawerVisible.value = true
+    detailLoading.value = true
+    try {
+      detailData.value = normalizeMatnrDetail(
+        await guardRequestWithMessage(fetchMatnrDetail(row.id), {}, {
+          timeoutMessage: '鐗╂枡璇︽儏鍔犺浇瓒呮椂锛屽凡鍋滄绛夊緟'
+        })
+      )
+    } catch (error) {
+      detailDrawerVisible.value = false
+      detailData.value = {}
+      ElMessage.error(error?.message || '鑾峰彇鐗╂枡璇︽儏澶辫触')
+    } finally {
+      detailLoading.value = false
+    }
+  }
+
+  function handleSearch(params) {
+    searchForm.value = {
+      ...searchForm.value,
+      ...params
+    }
+    pagination.current = 1
+    loadMatnrList()
+  }
+
+  async function handleReset() {
+    searchForm.value = createWhMatSearchState()
+    pagination.current = 1
+    selectedGroupId.value = null
+    groupSearch.value = ''
+    await Promise.all([loadGroupTree(), loadMatnrList()])
+  }
+
+  function handleSizeChange(size) {
+    pagination.size = size
+    pagination.current = 1
+    loadMatnrList()
+  }
+
+  function handleCurrentChange(current) {
+    pagination.current = current
+    loadMatnrList()
+  }
+
+  function handleGroupNodeClick(data) {
+    selectedGroupId.value = data?.id ?? null
+    pagination.current = 1
+    loadMatnrList()
+  }
+
+  async function handleResetGroup() {
+    selectedGroupId.value = null
+    pagination.current = 1
+    groupSearch.value = ''
+    await Promise.all([loadGroupTree(), loadMatnrList()])
+  }
+
+  async function handleGroupSearch() {
+    selectedGroupId.value = null
+    pagination.current = 1
+    await Promise.all([loadGroupTree(), loadMatnrList()])
+  }
+
+  onMounted(async () => {
+    await Promise.all([loadGroupTree(), loadMatnrList()])
+  })
+</script>
diff --git a/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-detail-drawer.vue b/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-detail-drawer.vue
new file mode 100644
index 0000000..a5cb07e
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-detail-drawer.vue
@@ -0,0 +1,100 @@
+<template>
+  <ElDrawer
+    :model-value="visible"
+    title="鐗╂枡璇︽儏"
+    size="960px"
+    destroy-on-close
+    @update:model-value="handleVisibleChange"
+  >
+    <ElScrollbar class="h-[calc(100vh-180px)] pr-1">
+      <div v-if="loading" class="py-6">
+        <ElSkeleton :rows="12" animated />
+      </div>
+      <div v-else class="space-y-4">
+        <ElDescriptions title="鍩虹淇℃伅" :column="2" border>
+          <ElDescriptionsItem label="鐗╂枡缂栫爜">{{ detail.code || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鐗╂枡鍚嶇О">{{ detail.name || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鐗╂枡鍒嗙粍">{{ detail.groupName || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="璐т富">{{ detail.shipperName || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏉$爜">{{ detail.barcode || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="瑙勬牸">{{ detail.spec || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鍨嬪彿">{{ detail.model || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="棰滆壊">{{ detail.color || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="灏哄">{{ detail.size || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鎻忚堪">{{ detail.describle || '--' }}</ElDescriptionsItem>
+        </ElDescriptions>
+
+        <ElDescriptions title="搴撳瓨灞炴��" :column="2" border>
+          <ElDescriptionsItem label="鍗曚綅">{{ detail.unit || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="閲囪喘鍗曚綅">{{ detail.purUnit || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="搴撲綅鍗曚綅">{{ detail.stockUnit || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鍑哄叆搴撲紭鍏堢骇">{{ detail.stockLevelText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏄惁鏍囩绠$悊">{{ detail.flagLabelManageText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏄惁鍏嶆">{{ detail.flagCheckText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="瀹夊叏搴撳瓨">{{ detail.safeQty ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏈�灏忓簱瀛橀璀�">{{ detail.minQty ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏈�澶у簱瀛橀璀�">{{ detail.maxQty ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鍋滄粸澶╂暟">{{ detail.stagn ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="淇濊川鏈熷ぉ鏁�">{{ detail.valid ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏁堟湡棰勮闃堝��">{{ detail.validWarn ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鐘舵��">{{ detail.statusText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鍩虹鍗曚綅">{{ detail.baseUnit || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="浣跨敤缁勭粐">{{ detail.useOrgName || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="ERP鍒嗙被">{{ detail.erpClsId || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="澶囨敞">{{ detail.memo || '--' }}</ElDescriptionsItem>
+        </ElDescriptions>
+
+        <ElDescriptions title="瀹¤淇℃伅" :column="2" border>
+          <ElDescriptionsItem label="鍒涘缓浜�">{{ detail.createByText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鍒涘缓鏃堕棿">{{ detail.createTimeText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏇存柊浜�">{{ detail.updateByText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏇存柊鏃堕棿">{{ detail.updateTimeText || '--' }}</ElDescriptionsItem>
+        </ElDescriptions>
+
+        <ElDescriptions v-if="extendFieldEntries.length" title="鎵╁睍瀛楁" :column="2" border>
+          <ElDescriptionsItem
+            v-for="entry in extendFieldEntries"
+            :key="entry.key"
+            :label="entry.key"
+          >
+            {{ entry.value || '--' }}
+          </ElDescriptionsItem>
+        </ElDescriptions>
+      </div>
+    </ElScrollbar>
+  </ElDrawer>
+</template>
+
+<script setup>
+  import { computed } from 'vue'
+
+  const props = defineProps({
+    visible: { type: Boolean, default: false },
+    loading: { type: Boolean, default: false },
+    detail: { type: Object, default: () => ({}) }
+  })
+
+  const emit = defineEmits(['update:visible'])
+
+  const visible = computed({
+    get: () => props.visible,
+    set: (value) => emit('update:visible', value)
+  })
+
+  const extendFieldEntries = computed(() => {
+    const fields = props.detail?.extendFields
+    if (!fields || typeof fields !== 'object') {
+      return []
+    }
+    return Object.entries(fields)
+      .map(([key, value]) => ({
+        key,
+        value: String(value ?? '').trim()
+      }))
+      .filter((item) => item.key)
+  })
+
+  function handleVisibleChange(value) {
+    visible.value = value
+  }
+</script>
diff --git a/rsf-design/src/views/basic-info/wh-mat/whMatPage.helpers.js b/rsf-design/src/views/basic-info/wh-mat/whMatPage.helpers.js
new file mode 100644
index 0000000..6675a07
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/whMatPage.helpers.js
@@ -0,0 +1,151 @@
+function normalizeText(value) {
+  return String(value ?? '').trim()
+}
+
+function normalizeNumber(value, fallback = 0) {
+  if (value === '' || value === null || value === undefined) {
+    return fallback
+  }
+  const numericValue = Number(value)
+  return Number.isFinite(numericValue) ? numericValue : fallback
+}
+
+function normalizeNullableNumber(value) {
+  if (value === '' || value === null || value === undefined) {
+    return null
+  }
+  const numericValue = Number(value)
+  return Number.isFinite(numericValue) ? numericValue : null
+}
+
+export function createWhMatSearchState() {
+  return {
+    condition: '',
+    code: '',
+    name: '',
+    spec: '',
+    model: '',
+    barcode: ''
+  }
+}
+
+export function buildWhMatPageQueryParams(params = {}) {
+  const result = {
+    current: params.current || 1,
+    pageSize: params.pageSize || params.size || 20
+  }
+
+  ;['condition', 'code', 'name', 'spec', 'model', 'barcode'].forEach((key) => {
+    const value = normalizeText(params[key])
+    if (value) {
+      result[key] = value
+    }
+  })
+
+  if (params.groupId !== undefined && params.groupId !== null && params.groupId !== '') {
+    result.groupId = String(params.groupId)
+  }
+
+  return result
+}
+
+export function buildWhMatGroupTreeQueryParams(params = {}) {
+  return {
+    condition: normalizeText(params.condition)
+  }
+}
+
+export function normalizeWhMatGroupTreeRows(records = []) {
+  if (!Array.isArray(records)) {
+    return []
+  }
+
+  return records.map((item) => {
+    const children = normalizeWhMatGroupTreeRows(item?.children || [])
+    const id = normalizeNullableNumber(item?.id)
+    const code = normalizeText(item?.code)
+    const name = normalizeText(item?.name)
+    const label = [name, code].filter(Boolean).join(' 路 ') || '-'
+
+    return {
+      ...item,
+      id,
+      parentId: normalizeNumber(item?.parentId, 0),
+      code,
+      name,
+      label,
+      displayLabel: label,
+      status: normalizeNullableNumber(item?.status),
+      statusText: normalizeNumber(item?.status, 1) === 1 ? '姝e父' : '鍐荤粨',
+      statusType: normalizeNumber(item?.status, 1) === 1 ? 'success' : 'danger',
+      memo: normalizeText(item?.memo) || '-',
+      children
+    }
+  })
+}
+
+export function normalizeWhMatRow(record = {}) {
+  const statusValue = normalizeNullableNumber(record?.status)
+  return {
+    ...record,
+    code: normalizeText(record?.code) || '-',
+    name: normalizeText(record?.name) || '-',
+    groupName: normalizeText(record?.groupId$ || record?.groupCode) || '-',
+    shipperName: normalizeText(record?.shipperId$ || record?.shipperName) || '-',
+    barcode: normalizeText(record?.barcode) || '-',
+    spec: normalizeText(record?.spec) || '-',
+    model: normalizeText(record?.model) || '-',
+    color: normalizeText(record?.color) || '-',
+    size: normalizeText(record?.size) || '-',
+    unit: normalizeText(record?.unit) || '-',
+    purUnit: normalizeText(record?.purUnit) || '-',
+    stockUnit: normalizeText(record?.stockUnit) || '-',
+    stockLevelText: normalizeText(record?.stockLeval$) || '-',
+    flagLabelManageText: normalizeText(record?.flagLabelMange$) || '-',
+    flagCheckText:
+      record?.flagCheck === 1 || record?.flagCheck === '1'
+        ? '鏄�'
+        : record?.flagCheck === 0 || record?.flagCheck === '0'
+          ? '鍚�'
+          : '-',
+    statusText: normalizeText(record?.status$) || (statusValue === 1 ? '姝e父' : statusValue === 0 ? '鍐荤粨' : '-'),
+    statusType: statusValue === 1 ? 'success' : statusValue === 0 ? 'danger' : 'info',
+    safeQty: record?.safeQty ?? '-',
+    minQty: record?.minQty ?? '-',
+    maxQty: record?.maxQty ?? '-',
+    valid: record?.valid ?? '-',
+    validWarn: record?.validWarn ?? '-',
+    stagn: record?.stagn ?? '-',
+    describle: normalizeText(record?.describle) || '-',
+    baseUnit: normalizeText(record?.baseUnit) || '-',
+    useOrgName: normalizeText(record?.useOrgName) || '-',
+    erpClsId: normalizeText(record?.erpClsId) || '-',
+    memo: normalizeText(record?.memo) || '-',
+    updateByText: normalizeText(record?.updateBy$) || '-',
+    createByText: normalizeText(record?.createBy$) || '-',
+    updateTimeText: normalizeText(record?.updateTime$ || record?.updateTime) || '-',
+    createTimeText: normalizeText(record?.createTime$ || record?.createTime) || '-',
+    extendFields:
+      record?.extendFields && typeof record.extendFields === 'object' && !Array.isArray(record.extendFields)
+        ? record.extendFields
+        : {}
+  }
+}
+
+export function normalizeWhMatDetail(record = {}) {
+  return normalizeWhMatRow(record)
+}
+
+export function getWhMatTreeNodeLabel(node = {}) {
+  const name = normalizeText(node?.name)
+  const code = normalizeText(node?.code)
+  return [name, code].filter(Boolean).join(' 路 ') || '-'
+}
+
+export const buildMatnrPageQueryParams = buildWhMatPageQueryParams
+export const buildMatnrGroupTreeQueryParams = buildWhMatGroupTreeQueryParams
+export const normalizeMatnrGroupTreeRows = normalizeWhMatGroupTreeRows
+export const normalizeMatnrRow = normalizeWhMatRow
+export const normalizeMatnrDetail = normalizeWhMatDetail
+export const createMatnrSearchState = createWhMatSearchState
+export const getMatnrTreeNodeLabel = getWhMatTreeNodeLabel
diff --git a/rsf-design/src/views/basic-info/wh-mat/whMatTable.columns.js b/rsf-design/src/views/basic-info/wh-mat/whMatTable.columns.js
new file mode 100644
index 0000000..3666937
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/whMatTable.columns.js
@@ -0,0 +1,70 @@
+import { h } from 'vue'
+import { ElTag } from 'element-plus'
+
+export function createWhMatTableColumns({ handleViewDetail }) {
+  return [
+    {
+      prop: 'code',
+      label: '鐗╂枡缂栫爜',
+      minWidth: 150,
+      showOverflowTooltip: true
+    },
+    {
+      prop: 'name',
+      label: '鐗╂枡鍚嶇О',
+      minWidth: 220,
+      showOverflowTooltip: true
+    },
+    {
+      prop: 'groupName',
+      label: '鐗╂枡鍒嗙粍',
+      minWidth: 160,
+      showOverflowTooltip: true
+    },
+    {
+      prop: 'barcode',
+      label: '鏉$爜',
+      minWidth: 160,
+      showOverflowTooltip: true
+    },
+    {
+      prop: 'spec',
+      label: '瑙勬牸',
+      minWidth: 150,
+      showOverflowTooltip: true
+    },
+    {
+      prop: 'model',
+      label: '鍨嬪彿',
+      minWidth: 150,
+      showOverflowTooltip: true
+    },
+    {
+      prop: 'unit',
+      label: '鍗曚綅',
+      width: 100
+    },
+    {
+      prop: 'status',
+      label: '鐘舵��',
+      width: 100,
+      align: 'center',
+      formatter: (row) =>
+        h(ElTag, { type: row.statusType || 'info', effect: 'light' }, () => row.statusText || '-')
+    },
+    {
+      prop: 'updateTimeText',
+      label: '鏇存柊鏃堕棿',
+      minWidth: 180,
+      showOverflowTooltip: true
+    },
+    {
+      prop: 'action',
+      label: '鎿嶄綔',
+      width: 100,
+      fixed: 'right',
+      align: 'center',
+      useSlot: true
+    }
+  ]
+}
diff --git a/rsf-design/tests/basic-info-wh-mat-page-contract.test.mjs b/rsf-design/tests/basic-info-wh-mat-page-contract.test.mjs
new file mode 100644
index 0000000..77ce88b
--- /dev/null
+++ b/rsf-design/tests/basic-info-wh-mat-page-contract.test.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+test('builds matnr page params with trimmed filters and group ids', async () => {
+  const { buildMatnrPageQueryParams } = await import(
+    '../src/views/basic-info/wh-mat/whMatPage.helpers.js'
+  )
+
+  assert.deepEqual(
+    buildMatnrPageQueryParams({
+      current: 2,
+      pageSize: 30,
+      condition: '  鍗婃垚鍝�  ',
+      code: '  RM001  ',
+      name: '  鐗╂枡A  ',
+      spec: '',
+      groupId: 19
+    }),
+    {
+      current: 2,
+      pageSize: 30,
+      condition: '鍗婃垚鍝�',
+      code: 'RM001',
+      name: '鐗╂枡A',
+      groupId: '19'
+    }
+  )
+})
+
+test('normalizes matnr group trees for el-tree rendering', async () => {
+  const { normalizeMatnrGroupTreeRows } = await import(
+    '../src/views/basic-info/wh-mat/whMatPage.helpers.js'
+  )
+
+  const tree = normalizeMatnrGroupTreeRows([
+    {
+      id: 1,
+      parentId: 0,
+      name: '鍗婃垚鍝�',
+      code: 'RM',
+      status: 1,
+      children: [
+        {
+          id: 2,
+          parentId: 1,
+          name: '鍗婃垚鍝丄',
+          code: 'RM-A',
+          status: 0
+        }
+      ]
+    }
+  ])
+
+  assert.equal(tree[0].displayLabel, '鍗婃垚鍝� 路 RM')
+  assert.equal(tree[0].children[0].statusText, '鍐荤粨')
+})
+
+test('normalizes matnr detail fields for detail drawer display', async () => {
+  const { normalizeMatnrDetail } = await import('../src/views/basic-info/wh-mat/whMatPage.helpers.js')
+
+  const detail = normalizeMatnrDetail({
+    id: 8,
+    code: 'RM001',
+    name: '鍗婃垚鍝�',
+    groupId$: '鍘熸潗鏂�',
+    status: 1,
+    stockLeval$: ' A',
+    flagLabelMange$: ' 鏄�',
+    extendFields: {
+      batch: 'B001'
+    }
+  })
+
+  assert.equal(detail.code, 'RM001')
+  assert.equal(detail.groupName, '鍘熸潗鏂�')
+  assert.equal(detail.statusText, '姝e父')
+  assert.equal(detail.extendFields.batch, 'B001')
+})
+

--
Gitblit v1.9.1