Ver Fonte

feat(iot-sap-stock): 接入库存汇总并优化多币种展示

- 新增库存币种列及 SAP 库存汇总接口
- 展示库存总数量和分币种总金额
- 支持币种卡片动态扩展与横向滚动
- 优化统计区域的紧凑布局和响应式样式
Zimo há 1 semana atrás
pai
commit
5c8a4b5501
2 ficheiros alterados com 349 adições e 142 exclusões
  1. 15 0
      src/api/pms/iotsapstock/index.ts
  2. 334 142
      src/views/pms/iotsapstock/index.vue

+ 15 - 0
src/api/pms/iotsapstock/index.ts

@@ -14,6 +14,7 @@ export interface IotSapStockVO {
   materialGroupId: number // 物料组id
   quantity: number // 数量
   unitPrice: number // 单价
+  currency: string // 币种
   unit: string // 单位
   safetyStock: number // 安全库存
   storageAreaId: number // 库区id
@@ -29,8 +30,22 @@ export interface IotSapStockVO {
   remark: string // 备注
 }
 
+// SAP 库存汇总 VO
+export interface SapStockSummaryVO {
+  currency: string // 币种
+  totalAmount: number // 总金额
+  totalQuantity: number // 总数量
+}
+
 // PMS SAP 库存(通用库存/项目部库存) API
 export const IotSapStockApi = {
+  // 查询 SAP 库存汇总
+  getSapStockSummary: async () => {
+    return await request.get<SapStockSummaryVO[]>({
+      url: `/pms/iot-sap-stock/sapStockSummary`
+    })
+  },
+
   // 查询PMS SAP 库存(通用库存/项目部库存)分页
   getIotSapStockPage: async (params: PageParam) => {
     return await request.get({ url: `/pms/iot-sap-stock/page`, params })

+ 334 - 142
src/views/pms/iotsapstock/index.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
-import { IotSapStockApi, IotSapStockVO } from '@/api/pms/iotsapstock'
+import { IotSapStockApi, IotSapStockVO, SapStockSummaryVO } from '@/api/pms/iotsapstock'
 import * as SapOrgApi from '@/api/system/saporg'
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'
@@ -83,10 +83,10 @@ const queryParams = reactive<QueryParams>({ ...initQuery })
 const queryFormRef = ref()
 const loading = ref(false)
 const exportLoading = ref(false)
+const summaryLoading = ref(false)
 const list = ref<StockRow[]>([])
 const total = ref(0)
-const totalQuantity = ref(0)
-const totalAmount = ref(0)
+const stockSummary = ref<SapStockSummaryVO[]>([])
 const factoryList = ref<SapOrgApi.SapOrgVO[]>([])
 const storageLocationList = ref<SapOrgApi.SapOrgVO[]>([])
 
@@ -96,24 +96,31 @@ const resultOptions = computed(() => [
   { label: '否', value: 'N' }
 ])
 
-const stockStatCards = computed(() => [
-  {
-    label: t('stock.totalQuantity'),
-    value: totalQuantity.value,
-    prefix: '',
-    unit: '',
-    decimals: 0,
-    icon: 'i-tabler:packages text-sky'
-  },
-  {
-    label: t('stock.totalAmount'),
-    value: totalAmount.value,
-    prefix: '¥',
-    unit: '',
-    decimals: 2,
-    icon: 'i-material-symbols:payments-outline-rounded text-emerald'
+const totalQuantity = computed(() =>
+  stockSummary.value.reduce((totalValue, item) => totalValue + Number(item.totalQuantity || 0), 0)
+)
+
+const getCurrencySymbol = (currency: string) => {
+  const currencySymbols: Record<string, string> = {
+    CNY: '¥',
+    USD: '$'
   }
-])
+  const currencyCode = currency.trim().toUpperCase()
+  return currencySymbols[currencyCode] || currencyCode
+}
+
+const getCurrencyName = (currency: string) => {
+  const currencyNames: Record<string, string> = {
+    CNY: '人民币',
+    USD: '美元'
+  }
+  return currencyNames[currency.trim().toUpperCase()] || '其他币种'
+}
+
+const getCurrencyClass = (currency: string) => {
+  const currencyCode = currency.trim().toLowerCase()
+  return ['cny', 'usd'].includes(currencyCode) ? `currency-card--${currencyCode}` : ''
+}
 
 const getList = async () => {
   loading.value = true
@@ -121,15 +128,20 @@ const getList = async () => {
     const data = await IotSapStockApi.getIotSapStockPage(queryParams)
     list.value = data.list
     total.value = data.total
-
-    const firstRow = data.list?.[0]
-    totalQuantity.value = Number(firstRow?.totalQuantity) || 0
-    totalAmount.value = Number(firstRow?.totalAmount) || 0
   } finally {
     loading.value = false
   }
 }
 
+const getSapStockSummary = async () => {
+  summaryLoading.value = true
+  try {
+    stockSummary.value = await IotSapStockApi.getSapStockSummary()
+  } finally {
+    summaryLoading.value = false
+  }
+}
+
 const loadSapOrgOptions = async () => {
   const [factories, storageLocations] = await Promise.all([
     SapOrgApi.SapOrgApi.getSimpleSapOrgList(1),
@@ -191,20 +203,19 @@ const handleExport = async () => {
 onMounted(() => {
   loadSapOrgOptions()
   getList()
+  getSapStockSummary()
 })
 </script>
 
 <template>
   <div
-    class="iot-sap-stock-page grid grid-rows-[auto_auto_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
-  >
+    class="iot-sap-stock-page grid grid-rows-[auto_auto_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
     <el-form
       ref="queryFormRef"
       :model="queryParams"
       size="default"
       label-width="82px"
-      class="iot-sap-stock-query bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-6 py-3 min-w-0"
-    >
+      class="iot-sap-stock-query bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-6 py-3 min-w-0">
       <div class="query-row">
         <el-form-item :label="t('workOrderMaterial.factory')" prop="factoryId">
           <el-select
@@ -213,14 +224,12 @@ onMounted(() => {
             filterable
             :placeholder="t('faultForm.choose')"
             class="query-control"
-            @change="selectedFactoryChange"
-          >
+            @change="selectedFactoryChange">
             <el-option
               v-for="item in factoryList"
               :key="item.id"
               :label="item.factoryName"
-              :value="item.id!"
-            />
+              :value="item.id!" />
           </el-select>
         </el-form-item>
         <el-form-item :label="t('workOrderMaterial.storageLocation')" prop="storageLocationId">
@@ -229,14 +238,12 @@ onMounted(() => {
             clearable
             filterable
             :placeholder="t('faultForm.choose')"
-            class="query-control"
-          >
+            class="query-control">
             <el-option
               v-for="item in storageLocationList"
               :key="item.id"
               :label="item.storageLocationName"
-              :value="item.id!"
-            />
+              :value="item.id!" />
           </el-select>
         </el-form-item>
         <el-form-item :label="t('chooseMaintain.materialCode')" prop="materialCode">
@@ -245,8 +252,7 @@ onMounted(() => {
             :placeholder="t('chooseMaintain.materialCode')"
             clearable
             class="query-control"
-            @keyup.enter="handleQuery"
-          />
+            @keyup.enter="handleQuery" />
         </el-form-item>
         <el-form-item :label="t('chooseMaintain.materialName')" prop="materialName">
           <el-input
@@ -254,8 +260,7 @@ onMounted(() => {
             :placeholder="t('chooseMaintain.materialName')"
             clearable
             class="query-control"
-            @keyup.enter="handleQuery"
-          />
+            @keyup.enter="handleQuery" />
         </el-form-item>
         <el-form-item :label="t('chooseMaintain.createTime')" prop="createTime">
           <el-date-picker
@@ -265,22 +270,19 @@ onMounted(() => {
             :start-placeholder="t('info.start')"
             :end-placeholder="t('info.end')"
             :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-            class="query-control query-control--date"
-          />
+            class="query-control query-control--date" />
         </el-form-item>
         <el-form-item :label="t('route.IsItConfigured')" prop="configFlag" label-width="116px">
           <el-select
             v-model="queryParams.configFlag"
             :placeholder="t('faultForm.choose')"
             clearable
-            class="query-control query-control--small"
-          >
+            class="query-control query-control--small">
             <el-option
               v-for="dict in resultOptions"
               :key="dict.value"
               :label="dict.label"
-              :value="dict.value"
-            />
+              :value="dict.value" />
           </el-select>
         </el-form-item>
       </div>
@@ -300,26 +302,55 @@ onMounted(() => {
       </div>
     </el-form>
 
-    <div class="stock-stat-grid">
-      <div v-for="item in stockStatCards" :key="item.label" class="stock-stat-card group">
-        <div class="relative z-10 flex h-full flex-col justify-center">
-          <span class="stat-label">{{ item.label }}</span>
-          <div class="flex items-baseline gap-1">
-            <span v-if="item.prefix" class="text-[12px] text-gray-400">{{ item.prefix }}</span>
-            <count-to
-              class="stat-value"
-              :start-val="0"
-              :end-val="item.value"
-              :decimals="item.decimals"
-            />
-            <span v-if="item.unit" class="stat-unit">{{ item.unit }}</span>
+    <section class="stock-overview" v-loading="summaryLoading">
+      <div class="quantity-overview">
+        <div class="quantity-icon">
+          <Icon icon="ep:box" class="quantity-icon__glyph" />
+        </div>
+        <div class="quantity-content">
+          <div class="quantity-label">{{ t('stock.totalQuantity') }}</div>
+          <count-to class="quantity-value" :start-val="0" :end-val="totalQuantity" :decimals="2" />
+        </div>
+      </div>
+
+      <div class="currency-overview">
+        <div class="currency-overview__header">
+          <div class="currency-overview__title">
+            <Icon icon="ep:wallet" />
+            <span>库存总金额</span>
+          </div>
+          <div class="currency-overview__subtitle">按币种独立核算</div>
+        </div>
+
+        <div v-if="stockSummary.length" class="currency-grid">
+          <div
+            v-for="item in stockSummary"
+            :key="item.currency"
+            class="currency-card"
+            :class="getCurrencyClass(item.currency)">
+            <div class="currency-token">{{ getCurrencySymbol(item.currency) }}</div>
+            <div class="currency-card__content">
+              <div class="currency-card__heading">
+                <span class="currency-name">{{ getCurrencyName(item.currency) }}</span>
+                <span class="currency-code">{{ item.currency }}</span>
+              </div>
+              <div class="currency-card__amount">
+                <span class="currency-symbol">{{ getCurrencySymbol(item.currency) }}</span>
+                <count-to
+                  class="currency-value"
+                  :start-val="0"
+                  :end-val="Number(item.totalAmount) || 0"
+                  :decimals="2" />
+              </div>
+            </div>
           </div>
         </div>
-        <div class="stat-icon">
-          <div :class="item.icon" class="text-5xl"></div>
+        <div v-else-if="!summaryLoading" class="summary-empty">
+          <Icon icon="ep:coin" />
+          <span>暂无金额汇总数据</span>
         </div>
       </div>
-    </div>
+    </section>
 
     <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-4 min-w-0 min-h-0">
       <div class="flex-1 relative min-h-0">
@@ -331,50 +362,25 @@ onMounted(() => {
               :width="width"
               :height="height"
               :max-height="height"
-              show-border
-            >
+              show-border>
               <ZmTableColumn
                 type="index"
                 :label="t('monitor.serial')"
                 :width="70"
                 fixed="left"
-                hide-in-column-settings
-              />
-              <ZmTableColumn
-                prop="factory"
-                :label="t('workOrderMaterial.factory')"
-                min-width="120"
-              />
+                hide-in-column-settings />
+              <ZmTableColumn prop="factory" :label="t('workOrderMaterial.factory')" />
               <ZmTableColumn
                 prop="projectDepartment"
-                :label="t('workOrderMaterial.storageLocation')"
-                min-width="140"
-              />
-              <ZmTableColumn
-                prop="materialCode"
-                :label="t('chooseMaintain.materialCode')"
-                min-width="150"
-              />
-              <ZmTableColumn
-                prop="materialName"
-                :label="t('chooseMaintain.materialName')"
-                min-width="220"
-                align="left"
-              />
-              <ZmTableColumn prop="quantity" :label="t('route.quantity')" min-width="100" />
-              <ZmTableColumn
-                prop="unitPrice"
-                :label="t('workOrderMaterial.unitPrice')"
-                min-width="100"
-              />
-              <ZmTableColumn prop="unit" :label="t('workOrderMaterial.unit')" min-width="90" />
-              <ZmTableColumn prop="safetyStock" :label="t('route.safetyStock')" min-width="120" />
-              <ZmTableColumn
-                prop="createTime"
-                :label="t('chooseMaintain.createTime')"
-                min-width="180"
-                action
-              >
+                :label="t('workOrderMaterial.storageLocation')" />
+              <ZmTableColumn prop="materialCode" :label="t('chooseMaintain.materialCode')" />
+              <ZmTableColumn prop="materialName" :label="t('chooseMaintain.materialName')" />
+              <ZmTableColumn prop="quantity" :label="t('route.quantity')" />
+              <ZmTableColumn prop="unitPrice" :label="t('workOrderMaterial.unitPrice')" />
+              <ZmTableColumn prop="currency" label="币种" />
+              <ZmTableColumn prop="unit" :label="t('workOrderMaterial.unit')" />
+              <ZmTableColumn prop="safetyStock" :label="t('route.safetyStock')" />
+              <ZmTableColumn prop="createTime" :label="t('chooseMaintain.createTime')" action>
                 <template #default="{ row }">
                   {{ dateFormatter(row, null, row.createTime) }}
                 </template>
@@ -395,8 +401,7 @@ onMounted(() => {
           :total="total"
           layout="total, sizes, prev, pager, next, jumper"
           @size-change="handleSizeChange"
-          @current-change="handleCurrentChange"
-        />
+          @current-change="handleCurrentChange" />
       </div>
     </div>
   </div>
@@ -429,67 +434,233 @@ onMounted(() => {
   gap: 12px 18px;
 }
 
-.stat-label {
-  margin-bottom: 2px;
+.stock-overview {
+  position: relative;
+  display: grid;
+  grid-template-columns: minmax(280px, 0.72fr) minmax(0, 1.48fr);
+  min-height: 88px;
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 12px;
+  box-shadow: 0 3px 12px rgb(31 45 61 / 7%);
+}
+
+.stock-overview::before {
+  position: absolute;
+  top: 0;
+  right: 0;
+  left: 0;
+  z-index: 2;
+  height: 2px;
+  background: linear-gradient(90deg, var(--el-color-primary), var(--el-color-success));
+  content: '';
+}
+
+.quantity-overview {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  padding: 12px 22px;
+  background: linear-gradient(90deg, var(--el-color-primary-light-9), transparent 70%);
+  border-right: 1px solid var(--el-border-color-lighter);
+}
+
+.quantity-icon {
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  justify-content: center;
+  width: 40px;
+  height: 40px;
+  margin-right: 13px;
+  color: #fff;
+  background: linear-gradient(145deg, var(--el-color-primary-light-3), var(--el-color-primary));
+  border-radius: 11px;
+  box-shadow: 0 5px 12px var(--el-color-primary-light-7);
+}
+
+.quantity-icon__glyph {
+  font-size: 21px;
+}
+
+.quantity-content {
+  min-width: 0;
+}
+
+.quantity-label {
   font-size: 11px;
-  font-weight: 500;
-  letter-spacing: 0;
-  color: var(--el-text-color-regular);
+  color: var(--el-text-color-secondary);
 }
 
-.stat-value {
+.quantity-value {
+  display: block;
+  margin-top: 5px;
   font-family: var(--el-font-family);
-  font-size: 18px;
-  font-weight: 700;
+  font-size: 23px;
+  font-weight: 750;
   line-height: 1;
+  letter-spacing: -0.5px;
+  color: var(--el-text-color-primary);
+}
+
+.currency-overview {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  padding: 10px 18px;
+}
+
+.currency-overview__header {
+  flex: 0 0 108px;
+}
+
+.currency-overview__title {
+  display: flex;
+  align-items: center;
+  font-size: 12px;
+  font-weight: 650;
   color: var(--el-text-color-primary);
 }
 
-.stat-unit {
+.currency-overview__title .el-icon,
+.currency-overview__title > :first-child {
+  margin-right: 6px;
+  color: var(--el-color-success);
+}
+
+.currency-overview__subtitle {
+  margin-top: 5px;
   font-size: 10px;
-  font-weight: 400;
-  color: var(--el-text-color-secondary);
+  color: var(--el-text-color-placeholder);
 }
 
-.stock-stat-grid {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 12px;
+.currency-grid {
+  flex: 1 1 auto;
+  display: flex;
+  gap: 8px;
+  min-width: 0;
+  margin-left: 16px;
+  overflow: auto hidden;
 }
 
-.stock-stat-card {
-  position: relative;
-  min-height: 78px;
-  padding: 10px 14px;
-  overflow: hidden;
-  background-color: var(--el-bg-color);
+.currency-card {
+  --currency-color: var(--el-color-warning);
+  --currency-soft: var(--el-color-warning-light-9);
+
+  display: flex;
+  flex: 1 0 210px;
+  align-items: center;
+  min-width: 0;
+  padding: 7px 10px;
+  background: linear-gradient(120deg, var(--currency-soft), var(--el-bg-color) 72%);
   border: 1px solid var(--el-border-color-extra-light);
   border-radius: 8px;
-  box-shadow: var(--el-box-shadow-lighter);
   transition:
-    border-color 0.3s,
-    box-shadow 0.3s;
+    border-color 0.25s ease,
+    box-shadow 0.25s ease,
+    transform 0.25s ease;
 }
 
-.stock-stat-card:hover {
-  border-color: var(--el-color-primary-light-7);
-  box-shadow: var(--el-box-shadow-light);
+.currency-card:hover {
+  border-color: var(--currency-color);
+  box-shadow: 0 4px 10px rgb(31 45 61 / 7%);
 }
 
-.stat-icon {
-  position: absolute;
-  right: -8px;
-  bottom: -12px;
-  opacity: 0.4;
-  transform: rotate(-10deg);
-  transition:
-    transform 0.5s,
-    opacity 0.5s;
+.currency-card--cny {
+  --currency-color: var(--el-color-danger);
+  --currency-soft: var(--el-color-danger-light-9);
+}
+
+.currency-card--usd {
+  --currency-color: var(--el-color-primary);
+  --currency-soft: var(--el-color-primary-light-9);
 }
 
-.stock-stat-card:hover .stat-icon {
-  opacity: 0.55;
-  transform: scale(1.08) rotate(0);
+.currency-token {
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  margin-right: 9px;
+  font-size: 15px;
+  font-weight: 750;
+  color: var(--currency-color);
+  background: var(--el-bg-color);
+  border: 1px solid var(--currency-color);
+  border-radius: 9px;
+}
+
+.currency-card__content {
+  min-width: 0;
+}
+
+.currency-card__heading {
+  display: flex;
+  align-items: center;
+}
+
+.currency-name {
+  font-size: 11px;
+  color: var(--el-text-color-secondary);
+}
+
+.currency-code {
+  padding: 2px 5px;
+  margin-left: 6px;
+  font-size: 9px;
+  font-weight: 700;
+  line-height: 1;
+  color: var(--currency-color);
+  background: var(--el-bg-color);
+  border-radius: 3px;
+}
+
+.currency-card__amount {
+  display: flex;
+  align-items: baseline;
+  min-width: 0;
+  margin-top: 4px;
+}
+
+.currency-symbol {
+  margin-right: 4px;
+  font-size: 13px;
+  font-weight: 700;
+  color: var(--currency-color);
+}
+
+.currency-value {
+  font-family: var(--el-font-family);
+  font-size: 18px;
+  font-weight: 750;
+  line-height: 1;
+  letter-spacing: -0.2px;
+  color: var(--el-text-color-primary);
+}
+
+.summary-empty {
+  flex: 1 1 auto;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 44px;
+  margin-left: 16px;
+  font-size: 12px;
+  color: var(--el-text-color-placeholder);
+  background: var(--el-fill-color-extra-light);
+  border: 1px dashed var(--el-border-color);
+  border-radius: 8px;
+}
+
+.summary-empty > :first-child {
+  margin-right: 6px;
 }
 
 .query-actions {
@@ -564,6 +735,17 @@ onMounted(() => {
   }
 }
 
+@media (width <= 900px) {
+  .stock-overview {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .quantity-overview {
+    border-right: 0;
+    border-bottom: 1px solid var(--el-border-color-lighter);
+  }
+}
+
 @media (width <= 768px) {
   .iot-sap-stock-query {
     padding: 12px;
@@ -582,8 +764,18 @@ onMounted(() => {
     width: 100%;
   }
 
-  .stock-stat-grid {
-    grid-template-columns: minmax(0, 1fr);
+  .quantity-overview,
+  .currency-overview {
+    padding: 14px;
+  }
+
+  .currency-overview {
+    display: block;
+  }
+
+  .currency-grid {
+    margin-top: 10px;
+    margin-left: 0;
   }
 
   .query-actions :deep(.el-form-item__content) {