zlwu преди 7 месеца
родител
ревизия
ad7f61dd1e

+ 45 - 0
art-design-pro/src/api/dashboardApi.ts

@@ -0,0 +1,45 @@
+import api from '@/utils/http'
+
+// ==================== 仪表盘 API ====================
+
+export interface DashboardCargoStat {
+  label: string
+  planned: number
+  actual: number
+}
+
+export interface DashboardPartnerCargoStat {
+  label: string
+  planned: number
+  actual: number
+}
+
+export const dashboardApi = {
+  // 获取今日入库量按货物统计
+  getInboundByCargo: async (): Promise<DashboardCargoStat[]> => {
+    return api.get<DashboardCargoStat[]>({
+      url: '/dashboard/inbound/by-cargo'
+    })
+  },
+
+  // 获取今日入库量按往来单位+货物统计
+  getInboundByPartnerCargo: async (): Promise<DashboardPartnerCargoStat[]> => {
+    return api.get<DashboardPartnerCargoStat[]>({
+      url: '/dashboard/inbound/by-partner-cargo'
+    })
+  },
+
+  // 获取今日出库量按货物统计
+  getOutboundByCargo: async (): Promise<DashboardCargoStat[]> => {
+    return api.get<DashboardCargoStat[]>({
+      url: '/dashboard/outbound/by-cargo'
+    })
+  },
+
+  // 获取今日出库量按往来单位+货物统计
+  getOutboundByPartnerCargo: async (): Promise<DashboardPartnerCargoStat[]> => {
+    return api.get<DashboardPartnerCargoStat[]>({
+      url: '/dashboard/outbound/by-partner-cargo'
+    })
+  }
+}

+ 9 - 0
art-design-pro/src/api/plansApi.ts

@@ -25,6 +25,10 @@ export interface Plan {
   createdAt: string
   /** 生成链接交三方填写时对应的出入库单 id,有则显示「复制URL链接」 */
   fillLinkInventoryId?: number
+  // 自动生成配置
+  autoGenerateEnabled: number
+  autoGenerateEndMonth?: string
+  autoGenerateLastMonth?: string
 }
 
 export interface PlanCreate {
@@ -40,6 +44,8 @@ export interface PlanCreate {
   customerName?: string
   remarks?: string
   executionStatus?: string
+  autoGenerateEnabled?: number
+  autoGenerateEndMonth?: string
 }
 
 export interface PlanUpdate {
@@ -58,6 +64,9 @@ export interface PlanUpdate {
   executedAt?: string
   lastWeighingSlipId?: number
   executedQuantity?: number
+  autoGenerateEnabled?: number
+  autoGenerateEndMonth?: string
+  autoGenerateLastMonth?: string
 }
 
 export interface PlanRelatedData {

+ 170 - 0
art-design-pro/src/components/Charts/DualBarChart.vue

@@ -0,0 +1,170 @@
+<template>
+  <el-card>
+    <template #header>
+      <div class="card-header">
+        <span>{{ title }}</span>
+      </div>
+    </template>
+    <el-empty v-if="data.length === 0" description="今日无数据" />
+    <div v-else class="chart-container">
+      <div class="legend">
+        <span class="legend-item">
+          <span class="legend-color planned"></span>
+          计划量
+        </span>
+        <span class="legend-item">
+          <span class="legend-color actual"></span>
+          实际量
+        </span>
+      </div>
+      <div v-for="item in data" :key="item.label" class="chart-item">
+        <span class="chart-label">{{ item.label }}</span>
+        <div class="chart-bar-group">
+          <div class="chart-bar-wrapper">
+            <div
+              class="chart-bar planned"
+              :style="{
+                width: maxValue ? (item.planned / maxValue) * 100 + '%' : '0%',
+                minWidth: '4px'
+              }"
+            />
+          </div>
+          <div class="chart-bar-wrapper">
+            <div
+              class="chart-bar actual"
+              :style="{
+                width: maxValue ? (item.actual / maxValue) * 100 + '%' : '0%',
+                minWidth: '4px'
+              }"
+            />
+          </div>
+        </div>
+        <div class="chart-values">
+          <span class="value planned">{{ item.planned.toFixed(2) }}</span>
+          <span class="value actual">{{ item.actual.toFixed(2) }}</span>
+        </div>
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+  import { computed } from 'vue'
+
+  interface DualBarItem {
+    label: string
+    planned: number
+    actual: number
+  }
+
+  const props = defineProps<{
+    title: string
+    data: DualBarItem[]
+  }>()
+
+  const maxValue = computed(() => {
+    if (!props.data.length) return 0
+    const maxPlanned = Math.max(...props.data.map((item) => item.planned))
+    const maxActual = Math.max(...props.data.map((item) => item.actual))
+    return Math.max(maxPlanned, maxActual) || 0
+  })
+</script>
+
+<style lang="scss" scoped>
+  .card-header {
+    font-weight: 600;
+  }
+
+  .legend {
+    display: flex;
+    gap: 16px;
+    margin-bottom: 12px;
+
+    &-item {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      font-size: 12px;
+      color: #666;
+    }
+
+    &-color {
+      width: 12px;
+      height: 12px;
+      border-radius: 2px;
+
+      &.planned {
+        background: #1677ff;
+      }
+
+      &.actual {
+        background: #52c41a;
+      }
+    }
+  }
+
+  .chart-container {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  .chart-item {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    width: 100%;
+  }
+
+  .chart-label {
+    min-width: 80px;
+    font-size: 13px;
+  }
+
+  .chart-bar-group {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+  }
+
+  .chart-bar-wrapper {
+    background: #f5f5f5;
+    border-radius: 4px;
+    overflow: hidden;
+    height: 12px;
+  }
+
+  .chart-bar {
+    height: 100%;
+    border-radius: 4px;
+    transition: width 0.3s ease;
+
+    &.planned {
+      background: #1677ff;
+    }
+
+    &.actual {
+      background: #52c41a;
+    }
+  }
+
+  .chart-values {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    min-width: 90px;
+    text-align: right;
+    font-size: 12px;
+
+    .value {
+      &.planned {
+        color: #1677ff;
+      }
+
+      &.actual {
+        color: #52c41a;
+      }
+    }
+  }
+</style>

+ 9 - 1
art-design-pro/src/components/Table/ArtTable.vue

@@ -12,6 +12,8 @@
       :show-header="showHeader"
       :highlight-current-row="highlightCurrentRow"
       :table-layout="tableLayout"
+      :show-summary="showSummary"
+      :summary-method="summaryMethod"
       @selection-change="handleSelectionChange"
       @row-click="handleRowClick"
       @sort-change="handleSortChange"
@@ -108,6 +110,10 @@
     showHeaderBackground?: boolean
     /** 表格布局,控制列宽表现(拖拽时常用 fixed) */
     tableLayout?: 'fixed' | 'auto'
+    /** 是否显示合计行 */
+    showSummary?: boolean
+    /** 自定义合计行计算方法 */
+    summaryMethod?: (param: { columns: string[]; data: any[] }) => string[]
   }
 
   const props = withDefaults(defineProps<TableProps>(), {
@@ -131,7 +137,9 @@
     paginationSize: 'default',
     paginationLayout: 'total, sizes, prev, pager, next, jumper',
     showHeaderBackground: false,
-    tableLayout: 'fixed'
+    tableLayout: 'fixed',
+    showSummary: false,
+    summaryMethod: undefined
   })
 
   const emit = defineEmits([

+ 1 - 0
art-design-pro/src/types/components.d.ts

@@ -23,6 +23,7 @@ declare module 'vue' {
     DataListCard: typeof import('./../components/Widgets/Cards/DataListCard.vue')['default']
     DonutChartCard: typeof import('./../components/Widgets/Cards/DonutChartCard.vue')['default']
     DragVerify: typeof import('./../components/Form/DragVerify.vue')['default']
+    DualBarChart: typeof import('./../components/Charts/DualBarChart.vue')['default']
     DualBarCompareChart: typeof import('./../components/Widgets/Charts/DualBarCompareChart.vue')['default']
     Editor: typeof import('./../components/Form/Editor.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']

+ 60 - 47
art-design-pro/src/views/dashboard/console/index.vue

@@ -8,7 +8,7 @@
       </template>
 
       <el-row :gutter="16">
-        <el-col :xs="24" :md="12" v-for="stat in statsList" :key="stat.title">
+        <el-col :xs="12" :sm="6" v-for="stat in statsList" :key="stat.title">
           <el-card :loading="isLoading">
             <div class="stat-card">
               <div class="stat-title">{{ stat.title }}</div>
@@ -21,11 +21,20 @@
       </el-row>
 
       <el-row :gutter="16" style="margin-top: 16px">
-        <el-col :xs="24" :md="12">
-          <SimpleBarChart title="今日入库量(按货物,t)" :data="inboundByCargo" />
+        <el-col :xs="24" :lg="12">
+          <DualBarChart title="今日入库量(按货物,t)" :data="inboundByCargo" />
         </el-col>
-        <el-col :xs="24" :md="12">
-          <SimpleBarChart title="今日出库量(按货物,t)" :data="outboundByCargo" />
+        <el-col :xs="24" :lg="12">
+          <DualBarChart title="今日出库量(按货物,t)" :data="outboundByCargo" />
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="16" style="margin-top: 16px">
+        <el-col :xs="24" :lg="12">
+          <DualBarChart title="今日入库量(按往来单位+货物,t)" :data="inboundByPartnerCargo" />
+        </el-col>
+        <el-col :xs="24" :lg="12">
+          <DualBarChart title="今日出库量(按往来单位+货物,t)" :data="outboundByPartnerCargo" />
         </el-col>
       </el-row>
     </el-card>
@@ -34,21 +43,30 @@
 
 <script setup lang="ts">
   import { ref, computed, onMounted } from 'vue'
-  import { weighingApi, type WeighingRecord } from '@/api/weighingApi'
   import dayjs from 'dayjs'
-  import SimpleBarChart from '@/components/Charts/SimpleBarChart.vue'
+  import { dashboardApi, type DashboardCargoStat, type DashboardPartnerCargoStat } from '@/api/dashboardApi'
+  import DualBarChart from '@/components/Charts/DualBarChart.vue'
 
   const today = dayjs().format('YYYY-MM-DD')
   const isLoading = ref(false)
-  const todayWeighing = ref<WeighingRecord[]>([])
+  const inboundByCargo = ref<DashboardCargoStat[]>([])
+  const outboundByCargo = ref<DashboardCargoStat[]>([])
+  const inboundByPartnerCargo = ref<DashboardPartnerCargoStat[]>([])
+  const outboundByPartnerCargo = ref<DashboardPartnerCargoStat[]>([])
 
   onMounted(async () => {
     isLoading.value = true
     try {
-      todayWeighing.value = await weighingApi.list({
-        dateFrom: today,
-        dateTo: today
-      })
+      const [inCargo, outCargo, inPartner, outPartner] = await Promise.all([
+        dashboardApi.getInboundByCargo(),
+        dashboardApi.getOutboundByCargo(),
+        dashboardApi.getInboundByPartnerCargo(),
+        dashboardApi.getOutboundByPartnerCargo()
+      ])
+      inboundByCargo.value = inCargo
+      outboundByCargo.value = outCargo
+      inboundByPartnerCargo.value = inPartner
+      outboundByPartnerCargo.value = outPartner
     } catch (error) {
       console.error('获取数据失败', error)
     } finally {
@@ -56,47 +74,42 @@
     }
   })
 
-  // 过滤掉作废 / 异常单据
-  const validRecords = computed(() =>
-    todayWeighing.value.filter((record) => record.isVoided === 0)
-  )
-
-  const inboundRecords = computed(() =>
-    validRecords.value.filter((item) => item.businessType === '入库')
-  )
-  const outboundRecords = computed(() =>
-    validRecords.value.filter((item) => item.businessType === '出库')
-  )
-
-  const todayInboundVehicles = computed(
-    () => new Set(inboundRecords.value.map((item) => item.vehicleNo)).size
-  )
-  const todayOutboundVehicles = computed(
-    () => new Set(outboundRecords.value.map((item) => item.vehicleNo)).size
-  )
-
-  // 按货物名称汇总当天出入库量
-  interface SimpleBarItem {
-    label: string
-    value: number
-  }
+  const todayInboundVehicles = computed(() => {
+    // 从入库数据中统计车数
+    const cargoSet = new Set<string>()
+    inboundByCargo.value.forEach(item => {
+      if (item.planned > 0) cargoSet.add(item.label)
+    })
+    return cargoSet.size
+  })
 
-  const aggregateByCargo = (records: WeighingRecord[]): SimpleBarItem[] => {
-    const map = new Map<string, number>()
-    records.forEach((item) => {
-      const key = item.cargoName || '未填写货物'
-      const prev = map.get(key) ?? 0
-      map.set(key, prev + item.netWeight / 1000)
+  const todayOutboundVehicles = computed(() => {
+    const cargoSet = new Set<string>()
+    outboundByCargo.value.forEach(item => {
+      if (item.planned > 0) cargoSet.add(item.label)
     })
-    return Array.from(map.entries()).map(([label, value]) => ({ label, value }))
-  }
+    return cargoSet.size
+  })
 
-  const inboundByCargo = computed(() => aggregateByCargo(inboundRecords.value))
-  const outboundByCargo = computed(() => aggregateByCargo(outboundRecords.value))
+  // 今日入库总量(实际量 / 计划量)
+  const todayInboundTotal = computed(() => {
+    const planned = inboundByCargo.value.reduce((sum, item) => sum + item.planned, 0)
+    const actual = inboundByCargo.value.reduce((sum, item) => sum + item.actual, 0)
+    return { actual, planned }
+  })
+
+  // 今日出库总量(实际量 / 计划量)
+  const todayOutboundTotal = computed(() => {
+    const planned = outboundByCargo.value.reduce((sum, item) => sum + item.planned, 0)
+    const actual = outboundByCargo.value.reduce((sum, item) => sum + item.actual, 0)
+    return { actual, planned }
+  })
 
   const statsList = computed(() => [
     { title: '今日入库', value: todayInboundVehicles.value, suffix: '车' },
-    { title: '今日出库', value: todayOutboundVehicles.value, suffix: '车' }
+    { title: '今日入库总量', value: `${todayInboundTotal.value.actual.toFixed(2)} / ${todayInboundTotal.value.planned.toFixed(2)}`, suffix: '吨' },
+    { title: '今日出库', value: todayOutboundVehicles.value, suffix: '车' },
+    { title: '今日出库总量', value: `${todayOutboundTotal.value.actual.toFixed(2)} / ${todayOutboundTotal.value.planned.toFixed(2)}`, suffix: '吨' }
   ])
 </script>
 

+ 356 - 80
art-design-pro/src/views/inventory/index.vue

@@ -8,77 +8,183 @@
       </template>
 
       <div class="toolbar">
-        <!-- 日期范围筛选 -->
-        <div class="date-filter">
-          <el-date-picker
-            v-model="dateRange"
-            type="daterange"
-            range-separator="至"
-            start-placeholder="开始日期"
-            end-placeholder="结束日期"
-            format="YYYY-MM-DD"
-            value-format="YYYY-MM-DD"
-            @change="handleDateRangeChange"
-            style="width: 280px"
-          />
-        </div>
-        <div class="search-bar">
-          <el-input
-            v-model="searchKeyword"
-            placeholder="搜索往来单位 / 产品"
-            clearable
-            @clear="handleSearch"
-            @keyup.enter="handleSearch"
-          >
-            <template #prefix>
-              <el-icon><Search /></el-icon>
-            </template>
-            <template #append>
-              <el-button @click="handleSearch">搜索</el-button>
-            </template>
-          </el-input>
-        </div>
-        <el-button type="primary" @click="handleAdd">
-          <el-icon><Plus /></el-icon>
-          新增记录
-        </el-button>
-        <el-button @click="handleExport">
-          <el-icon><Document /></el-icon>
-          导出 Excel
-        </el-button>
-        <el-button-group>
-          <el-button
-            :type="dateFilterType === 'all' ? 'primary' : 'default'"
-            @click="setDateFilter('all')"
-          >
-            全部
-          </el-button>
-          <el-button
-            :type="dateFilterType === 'today' ? 'primary' : 'default'"
-            @click="setDateFilter('today')"
-          >
-            今日
-          </el-button>
-          <el-button
-            :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
-            @click="setDateFilter('tomorrow')"
-          >
-            明日
+        <div class="toolbar-row date-row">
+          <div class="date-filter">
+            <el-date-picker
+              v-model="dateRange"
+              type="daterange"
+              range-separator="至"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              format="YYYY-MM-DD"
+              value-format="YYYY-MM-DD"
+              @change="handleDateRangeChange"
+              style="width: 260px"
+            />
+          </div>
+          <div class="search-bar">
+            <el-input
+              v-model="searchKeyword"
+              placeholder="搜索计划号 / 出入库单号 / 往来单位 / 产品 / 车牌号"
+              clearable
+              @clear="handleQuickSearch"
+              @keyup.enter="handleQuickSearch"
+            >
+              <template #prefix>
+                <el-icon><Search /></el-icon>
+              </template>
+              <template #append>
+                <el-button @click="handleQuickSearch">搜索</el-button>
+              </template>
+            </el-input>
+          </div>
+          <el-button type="primary" @click="handleAdd">
+            <el-icon><Plus /></el-icon>
+            新增记录
           </el-button>
-          <el-button
-            :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
-            @click="setDateFilter('yesterday')"
-          >
-            昨日
+          <el-button @click="handleExport">
+            <el-icon><Document /></el-icon>
+            导出 Excel
           </el-button>
-          <el-button
-            :type="dateFilterType === 'week' ? 'primary' : 'default'"
-            @click="setDateFilter('week')"
-          >
-            本周
+          <el-button-group class="date-quick-group">
+            <el-button
+              :type="dateFilterType === 'all' ? 'primary' : 'default'"
+              @click="setDateFilter('all')"
+            >
+              全部
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'today' ? 'primary' : 'default'"
+              @click="setDateFilter('today')"
+            >
+              今日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
+              @click="setDateFilter('tomorrow')"
+            >
+              明日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
+              @click="setDateFilter('yesterday')"
+            >
+              昨日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'week' ? 'primary' : 'default'"
+              @click="setDateFilter('week')"
+            >
+              本周
+            </el-button>
+          </el-button-group>
+          <div class="toolbar-spacer" />
+          <el-button text type="primary" @click="showAdvancedFilters = !showAdvancedFilters">
+            {{ showAdvancedFilters ? '收起高级筛选' : '展开高级筛选' }}
           </el-button>
-        </el-button-group>
+        </div>
       </div>
+      <!-- 高级筛选区域 -->
+      <transition enter-active-class="animate__animated animate__fadeIn" leave-active-class="animate__animated animate__fadeOut">
+        <div v-show="showAdvancedFilters" class="advanced-search-wrapper">
+          <el-card shadow="hover">
+            <el-form :model="filters" class="filter-form" label-position="right" label-width="100px" @submit.prevent>
+              <el-row :gutter="20">
+                <el-col :span="6">
+                  <el-form-item label="计划号">
+                    <el-input
+                      v-model="filters.planNo"
+                      placeholder="计划号"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="出入库单号">
+                    <el-input
+                      v-model="filters.batchNo"
+                      placeholder="出入库单号"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="往来单位">
+                    <el-input
+                      v-model="filters.partnerCompany"
+                      placeholder="往来单位"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="业务类型">
+                    <el-select
+                      v-model="filters.businessType"
+                      placeholder="业务类型"
+                      clearable
+                      style="width: 100%"
+                    >
+                      <el-option
+                        v-for="item in businessTypeOptions"
+                        :key="item"
+                        :label="item"
+                        :value="item"
+                      />
+                    </el-select>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="状态">
+                    <el-select
+                      v-model="filters.status"
+                      placeholder="状态"
+                      clearable
+                      style="width: 100%"
+                    >
+                      <el-option
+                        v-for="item in statusOptions"
+                        :key="item"
+                        :label="item"
+                        :value="item"
+                      />
+                    </el-select>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="产品">
+                    <el-input
+                      v-model="filters.product"
+                      placeholder="产品"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="车牌号">
+                    <el-input
+                      v-model="filters.vehicleNo"
+                      placeholder="车牌号"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item>
+                    <el-button type="primary" @click="handleSearch">筛选</el-button>
+                    <el-button @click="handleResetFilters">重置</el-button>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </el-form>
+          </el-card>
+        </div>
+      </transition>
 
       <art-table
         :data="tableData"
@@ -88,6 +194,8 @@
         :index="true"
         :border="true"
         table-layout="fixed"
+        show-summary
+        :summary-method="getSummary"
         v-model:current-page="currentPage"
         v-model:page-size="pageSize"
         @current-change="handlePageChange"
@@ -99,7 +207,7 @@
         <el-table-column prop="batchNo" label="出入库单号" min-width="140" sortable="custom" :sort-order="getSortOrder('batchNo')" />
         <el-table-column prop="date" label="日期" min-width="120" sortable="custom" :sort-order="getSortOrder('date')" />
         <el-table-column prop="partnerCompany" label="往来单位" min-width="150" sortable="custom" :sort-order="getSortOrder('partnerCompany')" />
-        <el-table-column prop="businessType" label="业务类型" min-width="100" sortable="custom" :sort-order="getSortOrder('businessType')">
+        <el-table-column prop="businessType" label="业务类型" min-width="120" sortable="custom" :sort-order="getSortOrder('businessType')">
           <template #default="{ row }">
             <el-tag :type="row.businessType === '入库' ? 'success' : 'warning'">
               {{ row.businessType }}
@@ -431,8 +539,8 @@
   const total = ref(0)
   const currentPage = ref(1)
   const pageSize = ref(10)
-  const searchKeyword = ref('')
   const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('today')
+  const searchKeyword = ref('')
   const sortProp = ref<string>('date')
   const sortOrder = ref<'ascending' | 'descending' | null>('descending')
 
@@ -451,6 +559,21 @@
   const loadingRelated = ref(false)
   const relatedData = ref<{ plan: Plan | null, weighing: WeighingRecord | null }>({ plan: null, weighing: null })
 
+  const filters = reactive({
+    planNo: '',
+    batchNo: '',
+    partnerCompany: '',
+    businessType: '' as '' | '入库' | '出库',
+    status: '',
+    product: '',
+    vehicleNo: ''
+  })
+
+  const showAdvancedFilters = ref(false)
+
+  const businessTypeOptions: Array<'入库' | '出库'> = ['入库', '出库']
+  const statusOptions = ['待处理', '待入库', '待出库', '已完成']
+
   // 标志位:是否是从计划页面跳转过来的(用于控制是否预填表单)
   const isFromPlanJump = ref(false)
 
@@ -537,6 +660,39 @@
     return sortProp.value === prop ? sortOrder.value : null
   }
 
+  // 表格合计行
+  const getSummary = (param: { columns: string[]; data: any[] }) => {
+    const { columns } = param
+    const sums: string[] = []
+
+    columns.forEach((column, index) => {
+      if (index === 0) {
+        sums[index] = '合计'
+        return
+      }
+      // 数量在第11列(索引10),净重在第12列(索引11)
+      if (index === 10) {
+        // 数量合计
+        const totalQuantity = tableData.value.reduce((sum, row) => {
+          return sum + (Number(row.quantity) || 0)
+        }, 0)
+        sums[index] = totalQuantity.toString()
+        return
+      }
+      if (index === 11) {
+        // 净重合计(需要除以1000并保留2位小数)
+        const totalNetWeight = tableData.value.reduce((sum, row) => {
+          return sum + ((row.netWeight || 0) / 1000)
+        }, 0)
+        sums[index] = totalNetWeight.toFixed(2)
+        return
+      }
+      sums[index] = ''
+    })
+
+    return sums
+  }
+
   const compare = (a: InventoryRecord, b: InventoryRecord, prop: string, order: 'ascending' | 'descending'): number => {
     const av = (a as any)[prop]
     const bv = (b as any)[prop]
@@ -565,6 +721,40 @@
     tableData.value = list.slice(start, end)
   }
 
+  const filterRecords = (records: InventoryRecord[]) => {
+    const planNo = filters.planNo.trim()
+    const batchNo = filters.batchNo.trim()
+    const partnerCompany = filters.partnerCompany.trim()
+    const product = filters.product.trim()
+    const vehicleNo = filters.vehicleNo.trim()
+
+    return records.filter((r) => {
+      if (planNo && !(r.planNo || '').includes(planNo)) return false
+      if (batchNo && !(r.batchNo || '').includes(batchNo)) return false
+      if (partnerCompany && !(r.partnerCompany || '').includes(partnerCompany)) return false
+      if (product && !(r.product || '').includes(product)) return false
+      if (vehicleNo && !(r.vehicleNo || '').includes(vehicleNo)) return false
+      if (filters.businessType && r.businessType !== filters.businessType) return false
+      if (filters.status && (r.status || '待处理') !== filters.status) return false
+      return true
+    })
+  }
+
+  const applyKeywordFilter = (records: InventoryRecord[]) => {
+    const keyword = searchKeyword.value.trim().toLowerCase()
+    if (!keyword) return records
+    return records.filter((r) => {
+      const fields = [
+        r.planNo || '',
+        r.batchNo || '',
+        r.partnerCompany || '',
+        r.product || '',
+        r.vehicleNo || ''
+      ]
+      return fields.some((f) => f.toLowerCase().includes(keyword))
+    })
+  }
+
   const handleSortChange = (payload: { prop: string; order: string | null }) => {
     sortProp.value = payload.prop || ''
     sortOrder.value = payload.order === 'ascending' || payload.order === 'descending' ? payload.order : null
@@ -575,9 +765,7 @@
   const loadData = async () => {
     loading.value = true
     try {
-      const params: any = {
-        q: searchKeyword.value || undefined
-      }
+      const params: any = {}
 
       // 日期范围筛选(按出入库日期)
       if (dateRange.value && dateRange.value[0] && dateRange.value[1]) {
@@ -585,7 +773,23 @@
         params.dateTo = dateRange.value[1]
       }
 
-      const records = await inventoryApi.list(params)
+      if (showAdvancedFilters.value) {
+        if (filters.businessType) {
+          params.businessType = filters.businessType
+        }
+        if (filters.status) {
+          params.status = filters.status
+        }
+      }
+
+      let records = await inventoryApi.list(params)
+      // 快速搜索时使用关键字筛选
+      if (!showAdvancedFilters.value && searchKeyword.value) {
+        records = applyKeywordFilter(records)
+      }
+      if (showAdvancedFilters.value) {
+        records = filterRecords(records)
+      }
       allRecords.value = records
       applySortAndPage()
     } catch (error) {
@@ -596,6 +800,26 @@
   }
 
   const handleSearch = () => {
+    // 筛选:日期 + 高级筛选区域内容
+    currentPage.value = 1
+    loadData()
+  }
+
+  // 快速搜索:日期 + 搜索框关键字
+  const handleQuickSearch = () => {
+    currentPage.value = 1
+    loadData()
+  }
+
+  const handleResetFilters = () => {
+    filters.planNo = ''
+    filters.batchNo = ''
+    filters.partnerCompany = ''
+    filters.businessType = ''
+    filters.status = ''
+    filters.product = ''
+    filters.vehicleNo = ''
+    searchKeyword.value = ''
     currentPage.value = 1
     loadData()
   }
@@ -624,8 +848,8 @@
       dateRange.value = [monday, sunday]
     }
 
-    currentPage.value = 1
-    loadData()
+    // 设置日期后自动触发搜索
+    handleSearch()
   }
 
   // 日期范围变化处理(手动调整日期时,取消快捷筛选高亮)
@@ -883,9 +1107,6 @@
   const handleExport = async () => {
     try {
       const params: any = {}
-      if (searchKeyword.value) {
-        params.q = searchKeyword.value
-      }
 
       // 日期范围筛选(按出入库日期)
       if (dateRange.value && dateRange.value[0] && dateRange.value[1]) {
@@ -893,7 +1114,19 @@
         params.dateTo = dateRange.value[1]
       }
 
-      const records = await inventoryApi.list(params)
+      if (showAdvancedFilters.value) {
+        if (filters.businessType) {
+          params.businessType = filters.businessType
+        }
+        if (filters.status) {
+          params.status = filters.status
+        }
+      }
+
+      let records = await inventoryApi.list(params)
+      if (showAdvancedFilters.value) {
+        records = filterRecords(records)
+      }
       if (records.length === 0) {
         ElMessage.warning('没有数据可导出')
         return
@@ -944,9 +1177,52 @@
 <style lang="scss" scoped>
   .toolbar {
     margin-bottom: 16px;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    align-items: stretch;
+  }
+
+  .toolbar-row {
     display: flex;
     gap: 8px;
     align-items: center;
+    flex-wrap: wrap;
+  }
+
+  .toolbar-spacer {
+    flex: 1;
+  }
+
+  .filter-row {
+    align-items: flex-start;
+  }
+
+  .filter-form {
+    flex: 1;
+  }
+
+  .advanced-search-wrapper {
+    margin-bottom: 16px;
+  }
+
+  :deep(.advanced-search-wrapper .el-form-item) {
+    display: flex;
+    margin-bottom: 12px;
+  }
+
+  :deep(.advanced-search-wrapper .el-form-item__label) {
+    justify-content: flex-end;
+    flex-shrink: 0;
+  }
+
+  :deep(.advanced-search-wrapper .el-input),
+  :deep(.advanced-search-wrapper .el-select) {
+    width: 100%;
+  }
+
+  .date-quick-group {
+    margin-left: 8px;
   }
 
   .date-filter {
@@ -954,7 +1230,7 @@
   }
 
   .search-bar {
-    width: 400px;
+    width: 480px;
   }
 
   .card-header {

+ 375 - 97
art-design-pro/src/views/plans/index.vue

@@ -7,90 +7,167 @@
         </div>
       </template>
 
-      <!-- 工具栏 -->
+      <!-- 工具栏 + 高级筛选 -->
       <div class="toolbar">
-        <!-- 日期范围筛选 -->
-        <div class="date-filter">
-          <el-date-picker
-            v-model="dateRange"
-            type="daterange"
-            range-separator="至"
-            start-placeholder="开始日期"
-            end-placeholder="结束日期"
-            format="YYYY-MM-DD"
-            value-format="YYYY-MM-DD"
-            @change="handleDateRangeChange"
-            style="width: 280px"
-          />
-        </div>
-        <!-- 搜索栏 -->
-        <div class="search-bar">
-          <el-input
-            v-model="searchKeyword"
-            placeholder="搜索往来单位 / 计划号"
-            clearable
-            @clear="handleSearch"
-            @keyup.enter="handleSearch"
-          >
-            <template #prefix>
-              <el-icon><Search /></el-icon>
-            </template>
-            <template #append>
-              <el-button @click="handleSearch">搜索</el-button>
-            </template>
-          </el-input>
-        </div>
-        <el-button type="primary" @click="handleAdd">
-          <el-icon><Plus /></el-icon>
-          新增计划
-        </el-button>
-        <!-- <el-upload
-          :show-file-list="false"
-          accept=".xlsx,.xls,.csv"
-          :before-upload="handleImport"
-        >
-          <el-button>
-            <el-icon><Upload /></el-icon>
-            导入 Excel
-          </el-button>
-        </el-upload> -->
-        <el-button @click="handleExport">
-          <el-icon><Document /></el-icon>
-          导出 Excel
-        </el-button>
-        <el-button-group>
-          <el-button
-            :type="dateFilterType === 'all' ? 'primary' : 'default'"
-            @click="setDateFilter('all')"
-          >
-            全部
-          </el-button>
-          <el-button
-            :type="dateFilterType === 'today' ? 'primary' : 'default'"
-            @click="setDateFilter('today')"
-          >
-            今日
-          </el-button>
-          <el-button
-            :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
-            @click="setDateFilter('tomorrow')"
-          >
-            明日
+        <div class="toolbar-row date-row">
+          <!-- 日期范围筛选 -->
+          <div class="date-filter">
+            <el-date-picker
+              v-model="dateRange"
+              type="daterange"
+              range-separator="至"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              format="YYYY-MM-DD"
+              value-format="YYYY-MM-DD"
+              @change="handleDateRangeChange"
+              style="width: 280px"
+            />
+          </div>
+          <!-- 搜索栏 -->
+          <div class="search-bar">
+            <el-input
+              v-model="searchKeyword"
+              placeholder="搜索往来单位 / 计划号 / 产品"
+              clearable
+              @clear="handleSearch"
+              @keyup.enter="handleSearch"
+            >
+              <template #prefix>
+                <el-icon><Search /></el-icon>
+              </template>
+              <template #append>
+                <el-button @click="handleSearch">搜索</el-button>
+              </template>
+            </el-input>
+          </div>
+          <el-button type="primary" @click="handleAdd">
+            <el-icon><Plus /></el-icon>
+            新增计划
           </el-button>
-          <el-button
-            :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
-            @click="setDateFilter('yesterday')"
-          >
-            昨日
+          <el-button @click="handleExport">
+            <el-icon><Document /></el-icon>
+            导出 Excel
           </el-button>
-          <el-button
-            :type="dateFilterType === 'week' ? 'primary' : 'default'"
-            @click="setDateFilter('week')"
-          >
-            本周
+          <el-button-group class="date-quick-group">
+            <el-button
+              :type="dateFilterType === 'all' ? 'primary' : 'default'"
+              @click="setDateFilter('all')"
+            >
+              全部
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'today' ? 'primary' : 'default'"
+              @click="setDateFilter('today')"
+            >
+              今日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
+              @click="setDateFilter('tomorrow')"
+            >
+              明日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
+              @click="setDateFilter('yesterday')"
+            >
+              昨日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'week' ? 'primary' : 'default'"
+              @click="setDateFilter('week')"
+            >
+              本周
+            </el-button>
+          </el-button-group>
+          <div class="toolbar-spacer" />
+          <el-button text type="primary" @click="showAdvancedFilters = !showAdvancedFilters">
+            {{ showAdvancedFilters ? '收起高级筛选' : '展开高级筛选' }}
           </el-button>
-        </el-button-group>
+        </div>
       </div>
+      <!-- 高级筛选区域 -->
+      <transition enter-active-class="animate__animated animate__fadeIn" leave-active-class="animate__animated animate__fadeOut">
+        <div v-show="showAdvancedFilters" class="advanced-search-wrapper">
+          <el-card shadow="hover">
+            <el-form :model="filters" class="filter-form" label-position="right" label-width="100px" @submit.prevent>
+              <el-row :gutter="20">
+                <el-col :span="6">
+                  <el-form-item label="计划号">
+                    <el-input
+                      v-model="filters.planNo"
+                      placeholder="计划号"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="往来单位">
+                    <el-input
+                      v-model="filters.customerName"
+                      placeholder="往来单位"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="产品">
+                    <el-input
+                      v-model="filters.product"
+                      placeholder="产品"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="业务类型">
+                    <el-select
+                      v-model="filters.businessType"
+                      placeholder="业务类型"
+                      clearable
+                      style="width: 100%"
+                    >
+                      <el-option
+                        v-for="item in businessTypeOptions"
+                        :key="item"
+                        :label="item"
+                        :value="item"
+                      />
+                    </el-select>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="执行状态">
+                    <el-select
+                      v-model="filters.executionStatus"
+                      placeholder="执行状态"
+                      clearable
+                      style="width: 100%"
+                    >
+                      <el-option
+                        v-for="item in executionStatusOptions"
+                        :key="item"
+                        :label="item"
+                        :value="item"
+                      />
+                    </el-select>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item>
+                    <el-button type="primary" @click="handleSearch">筛选</el-button>
+                    <el-button @click="handleResetFilters">重置</el-button>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </el-form>
+          </el-card>
+        </div>
+      </transition>
 
       <!-- 表格 -->
       <art-table
@@ -101,6 +178,8 @@
         :index="true"
         :border="true"
         table-layout="fixed"
+        show-summary
+        :summary-method="getSummary"
         v-model:current-page="currentPage"
         v-model:page-size="pageSize"
         @current-change="handlePageChange"
@@ -139,6 +218,21 @@
           </template>
         </el-table-column>
         <el-table-column prop="remarks" label="备注" min-width="150" show-overflow-tooltip />
+        <el-table-column label="自动生成" min-width="120">
+          <template #default="{ row }">
+            <el-switch
+              v-model="row.autoGenerateEnabled"
+              :active-value="1"
+              :inactive-value="0"
+              active-text="开"
+              inactive-text="关"
+              @change="val => handleToggleAutoGenerate(row, val)"
+            />
+            <div v-if="row.autoGenerateEndMonth" style="font-size: 12px; color: #909399; margin-top: 4px;">
+              结束:{{ row.autoGenerateEndMonth }}
+            </div>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="360" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleView(row)">
@@ -191,6 +285,32 @@
             </el-form-item>
           </el-col>
         </el-row>
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="自动按月生成">
+              <el-switch
+                v-model="formData.autoGenerateEnabled"
+                :active-value="1"
+                :inactive-value="0"
+                active-text="开启"
+                inactive-text="关闭"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="结束月份">
+              <el-date-picker
+                v-model="formData.autoGenerateEndMonth"
+                type="month"
+                placeholder="不选表示一直生成"
+                style="width: 100%"
+                format="YYYY-MM"
+                value-format="YYYY-MM"
+                :disabled="!formData.autoGenerateEnabled"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
         <el-row :gutter="16">
           <el-col :span="12">
             <el-form-item label="往来单位" prop="customerName">
@@ -380,7 +500,20 @@
   const currentPage = ref(1)
   const pageSize = ref(10)
   const searchKeyword = ref('')
-  const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
+  const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week' | ''>('')
+
+  const filters = reactive({
+    planNo: '',
+    customerName: '',
+    product: '',
+    businessType: '' as '' | '入库' | '出库',
+    executionStatus: ''
+  })
+
+  const showAdvancedFilters = ref(false)
+
+  const businessTypeOptions: Array<'入库' | '出库'> = ['入库', '出库']
+  const executionStatusOptions = ['待入库', '入库中', '已入库', '待出库', '出库中', '已出库']
 
   // 日期范围筛选
   const dateRange = ref<[string, string] | []>([
@@ -405,7 +538,9 @@
     plannedQuantity: 0,
     vehicleNo: '',
     customerName: '',
-    remarks: ''
+    remarks: '',
+    autoGenerateEnabled: 0,
+    autoGenerateEndMonth: ''
   })
 
   const formRules = {
@@ -414,6 +549,30 @@
     plannedQuantity: [{ required: true, message: '请输入计划量', trigger: 'blur' }]
   }
 
+  const filterRecords = (records: Plan[]) => {
+    const planNo = filters.planNo.trim()
+    const customerName = filters.customerName.trim()
+    const product = filters.product.trim()
+
+    return records.filter((r) => {
+      if (planNo && !(r.planNo || '').includes(planNo)) return false
+      if (customerName && !(r.customerName || '').includes(customerName)) return false
+      if (product && !(r.product || '').includes(product)) return false
+      if (filters.businessType && r.businessType !== filters.businessType) return false
+      if (filters.executionStatus && getCorrectExecutionStatus(r) !== filters.executionStatus) return false
+      return true
+    })
+  }
+
+  const applyKeywordFilter = (records: Plan[]) => {
+    const keyword = searchKeyword.value.trim().toLowerCase()
+    if (!keyword) return records
+    return records.filter((r) => {
+      const fields = [r.planNo || '', r.customerName || '', r.product || '']
+      return fields.some((f) => f.toLowerCase().includes(keyword))
+    })
+  }
+
   // 根据业务类型获取正确的执行状态
   const getCorrectExecutionStatus = (row: Plan): string => {
     // 如果执行状态为空或不符合业务类型,根据业务类型返回正确的状态
@@ -458,12 +617,48 @@
     return 'info' // 默认灰色
   }
 
+  // 表格合计行
+  const getSummary = (param: { columns: string[]; data: any[] }) => {
+    const { columns } = param
+    const sums: string[] = []
+
+    columns.forEach((column, index) => {
+      if (index === 0) {
+        sums[index] = '合计'
+        return
+      }
+      // 计划量在第8列(索引7),已执行量在第9列(索引8)
+      if (index === 7) {
+        // 计划量合计
+        const totalPlanned = tableData.value.reduce((sum, row) => {
+          return sum + (Number(row.plannedQuantity) || 0)
+        }, 0)
+        sums[index] = totalPlanned.toString()
+        return
+      }
+      if (index === 8) {
+        // 已执行量合计(需要除以1000并保留2位小数)
+        const totalExecuted = tableData.value.reduce((sum, row) => {
+          return sum + ((row.executedQuantity || 0) / 1000)
+        }, 0)
+        sums[index] = totalExecuted.toFixed(2)
+        return
+      }
+      sums[index] = ''
+    })
+
+    return sums
+  }
+
   // 加载数据
   const loadData = async () => {
     loading.value = true
     try {
-      const params: any = {
-        q: searchKeyword.value || undefined
+      const params: any = {}
+
+      // 快速搜索模式(未展开高级筛选):日期 + 搜索框关键字
+      if (!showAdvancedFilters.value && searchKeyword.value) {
+        params.q = searchKeyword.value
       }
 
       // 日期范围筛选(按计划起始日期)
@@ -472,12 +667,25 @@
         params.startDateTo = dateRange.value[1]
       }
 
+      if (showAdvancedFilters.value) {
+        if (filters.businessType) {
+          params.businessType = filters.businessType
+        }
+        if (filters.executionStatus) {
+          params.executionStatus = filters.executionStatus
+        }
+      }
+
       const plans = await planApi.list(params)
-      total.value = plans.length
+      let records = plans
+      if (showAdvancedFilters.value) {
+        records = filterRecords(records)
+      }
+      total.value = records.length
       // 根据当前页和每页大小进行数据切片
       const start = (currentPage.value - 1) * pageSize.value
       const end = start + pageSize.value
-      tableData.value = plans.slice(start, end)
+      tableData.value = records.slice(start, end)
     } catch (error) {
       ElMessage.error('加载数据失败')
     } finally {
@@ -509,6 +717,17 @@
     loadData()
   }
 
+  const handleResetFilters = () => {
+    filters.planNo = ''
+    filters.customerName = ''
+    filters.product = ''
+    filters.businessType = ''
+    filters.executionStatus = ''
+    searchKeyword.value = ''
+    currentPage.value = 1
+    loadData()
+  }
+
   const setDateFilter = (type: 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week') => {
     dateFilterType.value = type
 
@@ -533,13 +752,13 @@
       dateRange.value = [monday, sunday]
     }
 
-    currentPage.value = 1
-    loadData()
+    // 设置日期后自动触发搜索
+    handleSearch()
   }
 
   // 日期范围变化处理(手动调整日期时,取消快捷筛选高亮)
   const handleDateRangeChange = () => {
-    dateFilterType.value = 'all'
+    dateFilterType.value = ''
     currentPage.value = 1
     loadData()
   }
@@ -570,7 +789,9 @@
       plannedQuantity: 0,
       vehicleNo: '',
       customerName: '',
-      remarks: ''
+      remarks: '',
+      autoGenerateEnabled: 0,
+      autoGenerateEndMonth: ''
     })
     modalOpen.value = true
   }
@@ -589,7 +810,9 @@
       plannedQuantity: record.plannedQuantity,
       vehicleNo: record.vehicleNo || '',
       customerName: record.customerName || '',
-      remarks: record.remarks || ''
+      remarks: record.remarks || '',
+      autoGenerateEnabled: record.autoGenerateEnabled ?? 0,
+      autoGenerateEndMonth: record.autoGenerateEndMonth || ''
     })
     modalOpen.value = true
   }
@@ -724,6 +947,22 @@
     }
   }
 
+  // 列表开关:切换自动生成
+  const handleToggleAutoGenerate = async (record: Plan, value: number) => {
+    try {
+      await planApi.update(record.id, {
+        autoGenerateEnabled: value,
+        autoGenerateEndMonth: record.autoGenerateEndMonth
+      })
+      ElMessage.success(value === 1 ? '已开启自动生成' : '已关闭自动生成')
+      loadData()
+    } catch (e: any) {
+      ElMessage.error(e?.response?.data?.detail || e?.message || '更新失败')
+      // 回滚开关显示
+      record.autoGenerateEnabled = value === 1 ? 0 : 1
+    }
+  }
+
   // 提交表单
   const handleSubmit = async () => {
     if (!formRef.value) return
@@ -761,10 +1000,6 @@
   const handleExport = async () => {
     try {
       const params: any = {}
-      // 和列表保持一致:支持关键字和日期筛选
-      if (searchKeyword.value) {
-        params.q = searchKeyword.value
-      }
 
       // 日期范围筛选(按计划起始日期)
       if (dateRange.value && dateRange.value[0] && dateRange.value[1]) {
@@ -773,7 +1008,11 @@
       }
 
       const planList = await planApi.list(params)
-      if (planList.length === 0) {
+      let records = planList
+      if (showAdvancedFilters.value) {
+        records = filterRecords(planList)
+      }
+      if (records.length === 0) {
         ElMessage.warning('没有数据可导出')
         return
       }
@@ -793,7 +1032,7 @@
       ]
       const csvContent = [
         headers.join(','),
-        ...planList.map((r) =>
+        ...records.map((r) =>
           [
             r.planNo,
             r.customerName,
@@ -842,9 +1081,48 @@
 <style lang="scss" scoped>
   .toolbar {
     margin-bottom: 16px;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    align-items: stretch;
+  }
+
+  .toolbar-row {
     display: flex;
     gap: 8px;
     align-items: center;
+    flex-wrap: wrap;
+  }
+
+  .toolbar-spacer {
+    flex: 1;
+  }
+
+  .filter-row {
+    align-items: flex-start;
+  }
+
+  .filter-form {
+    flex: 1;
+  }
+
+  .advanced-search-wrapper {
+    margin-bottom: 16px;
+  }
+
+  :deep(.advanced-search-wrapper .el-form-item) {
+    display: flex;
+    margin-bottom: 12px;
+  }
+
+  :deep(.advanced-search-wrapper .el-form-item__label) {
+    justify-content: flex-end;
+    flex-shrink: 0;
+  }
+
+  :deep(.advanced-search-wrapper .el-input),
+  :deep(.advanced-search-wrapper .el-select) {
+    width: 100%;
   }
 
   .date-filter {
@@ -852,7 +1130,7 @@
   }
 
   .search-bar {
-    width: 400px;
+    width: 480px;
   }
 
   .card-header {

+ 340 - 78
art-design-pro/src/views/weighing/index.vue

@@ -8,74 +8,176 @@
       </template>
 
       <div class="toolbar">
-        <!-- 时间范围筛选 -->
-        <div class="time-filter">
-          <el-date-picker
-            v-model="timeRange"
-            type="daterange"
-            range-separator="至"
-            start-placeholder="开始日期"
-            end-placeholder="结束日期"
-            format="YYYY-MM-DD"
-            value-format="YYYY-MM-DD"
-            @change="handleTimeRangeChange"
-            style="width: 280px"
-          />
-        </div>
-        <el-input
-          v-model="searchKeyword"
-          placeholder="搜索车牌号/货物名称"
-          clearable
-          style="width: 300px"
-          @clear="handleSearch"
-          @keyup.enter="handleSearch"
-        >
-          <template #prefix>
-            <el-icon><Search /></el-icon>
-          </template>
-        </el-input>
-        <el-button type="primary" @click="handleSearch" style="margin-left: 8px">搜索</el-button>
-        <el-button type="primary" @click="handleAdd">
-          <el-icon><Plus /></el-icon>
-          新增地磅单
-        </el-button>
-        <el-button @click="handleExport">
-          <el-icon><Document /></el-icon>
-          导出 Excel
-        </el-button>
-        <el-button-group>
-          <el-button
-            :type="dateFilterType === 'all' ? 'primary' : 'default'"
-            @click="setDateFilter('all')"
-          >
-            全部
-          </el-button>
-          <el-button
-            :type="dateFilterType === 'today' ? 'primary' : 'default'"
-            @click="setDateFilter('today')"
-          >
-            今日
-          </el-button>
-          <el-button
-            :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
-            @click="setDateFilter('tomorrow')"
-          >
-            明日
+        <div class="toolbar-row date-row">
+          <!-- 时间范围筛选 -->
+          <div class="time-filter">
+            <el-date-picker
+              v-model="timeRange"
+              type="daterange"
+              range-separator="至"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              format="YYYY-MM-DD"
+              value-format="YYYY-MM-DD"
+              @change="handleTimeRangeChange"
+              style="width: 280px"
+            />
+          </div>
+          <div class="search-bar">
+            <el-input
+              v-model="searchKeyword"
+              placeholder="搜索车牌号 / 货物名称 / 计划号 / 出入库单号"
+              clearable
+              @clear="handleSearch"
+              @keyup.enter="handleSearch"
+              style="width: 320px"
+            >
+              <template #prefix>
+                <el-icon><Search /></el-icon>
+              </template>
+            </el-input>
+          </div>
+          <el-button type="primary" @click="handleSearch">搜索</el-button>
+          <el-button type="primary" @click="handleAdd">
+            <el-icon><Plus /></el-icon>
+            新增地磅单
           </el-button>
-          <el-button
-            :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
-            @click="setDateFilter('yesterday')"
-          >
-            昨日
+          <el-button @click="handleExport">
+            <el-icon><Document /></el-icon>
+            导出 Excel
           </el-button>
-          <el-button
-            :type="dateFilterType === 'week' ? 'primary' : 'default'"
-            @click="setDateFilter('week')"
-          >
-            本周
+          <el-button-group class="date-quick-group">
+            <el-button
+              :type="dateFilterType === 'all' ? 'primary' : 'default'"
+              @click="setDateFilter('all')"
+            >
+              全部
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'today' ? 'primary' : 'default'"
+              @click="setDateFilter('today')"
+            >
+              今日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
+              @click="setDateFilter('tomorrow')"
+            >
+              明日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
+              @click="setDateFilter('yesterday')"
+            >
+              昨日
+            </el-button>
+            <el-button
+              :type="dateFilterType === 'week' ? 'primary' : 'default'"
+              @click="setDateFilter('week')"
+            >
+              本周
+            </el-button>
+          </el-button-group>
+          <div class="toolbar-spacer" />
+          <el-button text type="primary" @click="showAdvancedFilters = !showAdvancedFilters">
+            {{ showAdvancedFilters ? '收起高级筛选' : '展开高级筛选' }}
           </el-button>
-        </el-button-group>
+        </div>
       </div>
+      <!-- 高级筛选区域 -->
+      <transition enter-active-class="animate__animated animate__fadeIn" leave-active-class="animate__animated animate__fadeOut">
+        <div v-show="showAdvancedFilters" class="advanced-search-wrapper">
+          <el-card shadow="hover">
+            <el-form :model="filters" class="filter-form" label-position="right" label-width="100px" @submit.prevent>
+              <el-row :gutter="20">
+                <el-col :span="6">
+                  <el-form-item label="计划号">
+                    <el-input
+                      v-model="filters.planNo"
+                      placeholder="计划号"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="出入库单号">
+                    <el-input
+                      v-model="filters.batchNo"
+                      placeholder="出入库单号"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="车牌号">
+                    <el-input
+                      v-model="filters.vehicleNo"
+                      placeholder="车牌号"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="货物名称">
+                    <el-input
+                      v-model="filters.cargoName"
+                      placeholder="货物名称"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="业务类型">
+                    <el-select
+                      v-model="filters.businessType"
+                      placeholder="业务类型"
+                      clearable
+                      style="width: 100%"
+                    >
+                      <el-option
+                        v-for="item in businessTypeOptions"
+                        :key="item"
+                        :label="item"
+                        :value="item"
+                      />
+                    </el-select>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="供货单位">
+                    <el-input
+                      v-model="filters.supplier"
+                      placeholder="供货单位"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item label="收货单位">
+                    <el-input
+                      v-model="filters.consignee"
+                      placeholder="收货单位"
+                      clearable
+                      @keyup.enter="handleSearch"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="6">
+                  <el-form-item>
+                    <el-button type="primary" @click="handleSearch">筛选</el-button>
+                    <el-button @click="handleResetFilters">重置</el-button>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </el-form>
+          </el-card>
+        </div>
+      </transition>
 
       <art-table
         :data="paginatedData"
@@ -85,6 +187,8 @@
         :index="true"
         :border="true"
         table-layout="fixed"
+        show-summary
+        :summary-method="getSummary"
         v-model:current-page="currentPage"
         v-model:page-size="pageSize"
         @current-change="handlePageChange"
@@ -440,13 +544,24 @@
   const currentPage = ref(1)
   const pageSize = ref(10)
   const searchKeyword = ref('')
-  const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
+  const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week' | ''>('today')
+
+  const filters = reactive({
+    planNo: '',
+    batchNo: '',
+    vehicleNo: '',
+    cargoName: '',
+    supplier: '',
+    consignee: '',
+    businessType: '' as '' | '入库' | '出库'
+  })
+
+  const showAdvancedFilters = ref(false)
+  const businessTypeOptions: Array<'入库' | '出库'> = ['入库', '出库']
 
-  // 时间范围筛选
-  const timeRange = ref<[string, string] | []>([
-    dayjs().startOf('month').format('YYYY-MM-DD'),
-    dayjs().endOf('month').format('YYYY-MM-DD')
-  ])
+  // 时间范围筛选 - 默认当天
+  const todayStr = dayjs().format('YYYY-MM-DD')
+  const timeRange = ref<[string, string] | []>([todayStr, todayStr])
   const companies = ref<Company[]>([])
   const cargos = ref<Cargo[]>([])
   const route = useRoute()
@@ -495,6 +610,42 @@
     return Number((gross - tare - deduction).toFixed(2))
   })
 
+  const filterRecords = (records: WeighingRecord[]) => {
+    const planNo = filters.planNo.trim()
+    const batchNo = filters.batchNo.trim()
+    const vehicleNo = filters.vehicleNo.trim()
+    const cargoName = filters.cargoName.trim()
+    const supplier = filters.supplier.trim()
+    const consignee = filters.consignee.trim()
+
+    return records.filter((r) => {
+      if (planNo && !(r.planNo || '').includes(planNo)) return false
+      if (batchNo && !(r.batchNo || '').includes(batchNo)) return false
+      if (vehicleNo && !(r.vehicleNo || '').includes(vehicleNo)) return false
+      if (cargoName && !(r.cargoName || '').includes(cargoName)) return false
+      if (supplier && !(r.supplier || '').includes(supplier)) return false
+      if (consignee && !(r.consignee || '').includes(consignee)) return false
+      if (filters.businessType && r.businessType !== filters.businessType) return false
+      return true
+    })
+  }
+
+  const applyKeywordFilter = (records: WeighingRecord[]) => {
+    const keyword = searchKeyword.value.trim().toLowerCase()
+    if (!keyword) return records
+    return records.filter((r) => {
+      const fields = [
+        r.planNo || '',
+        r.batchNo || '',
+        r.vehicleNo || '',
+        r.cargoName || '',
+        r.supplier || '',
+        r.consignee || ''
+      ]
+      return fields.some((f) => f.toLowerCase().includes(keyword))
+    })
+  }
+
   // 本次量(吨)- 从 kg 转换为吨显示
   const currentQuantityInTons = computed(() => {
     if (formData.currentQuantity !== undefined && formData.currentQuantity !== null) {
@@ -568,32 +719,81 @@
       timeRange.value = [monday, sunday]
     }
 
-    currentPage.value = 1
-    loadData()
+    // 设置日期后自动触发搜索
+    handleSearch()
   }
 
   // 时间范围变化处理(手动调整日期时,取消快捷筛选高亮)
   const handleTimeRangeChange = () => {
-    dateFilterType.value = 'all'
+    dateFilterType.value = ''
     currentPage.value = 1
     loadData()
   }
 
+  // 表格合计行
+  const getSummary = (param: { columns: string[]; data: any[] }) => {
+    const { columns } = param
+    const sums: string[] = []
+
+    columns.forEach((column, index) => {
+      if (index === 0) {
+        sums[index] = '合计'
+        return
+      }
+      // 本次量在第10列(索引9),净重在第11列(索引10)
+      if (index === 9) {
+        // 本次量合计(需要除以1000并保留2位小数)
+        const totalCurrentQuantity = paginatedData.value.reduce((sum, row) => {
+          return sum + ((row.currentQuantity || 0) / 1000)
+        }, 0)
+        sums[index] = totalCurrentQuantity.toFixed(2)
+        return
+      }
+      if (index === 10) {
+        // 净重合计(需要除以1000并保留2位小数)
+        const totalNetWeight = paginatedData.value.reduce((sum, row) => {
+          return sum + ((row.netWeight || 0) / 1000)
+        }, 0)
+        sums[index] = totalNetWeight.toFixed(2)
+        return
+      }
+      sums[index] = ''
+    })
+
+    return sums
+  }
+
   const loadData = async () => {
     loading.value = true
     try {
       const params: any = {}
-      if (searchKeyword.value) {
+      // 快速搜索模式(未展开高级筛选):时间范围 + 搜索框关键字
+      if (!showAdvancedFilters.value && searchKeyword.value) {
         params.vehicleNo = searchKeyword.value
       }
 
+      if (showAdvancedFilters.value) {
+        if (filters.businessType) {
+          params.businessType = filters.businessType
+        }
+        if (filters.vehicleNo) {
+          params.vehicleNo = filters.vehicleNo
+        }
+        if (filters.cargoName) {
+          params.cargoName = filters.cargoName
+        }
+      }
+
       // 时间范围筛选(按毛重时间)
       if (timeRange.value && timeRange.value[0] && timeRange.value[1]) {
         params.dateFrom = timeRange.value[0]
         params.dateTo = timeRange.value[1]
       }
 
-      const records = await weighingApi.list(params)
+      let records = await weighingApi.list(params)
+      if (showAdvancedFilters.value) {
+        records = filterRecords(records)
+      }
       // 按日期倒序排列(日期取 inventoryDate | 毛重日期 | 创建日期)
       const getRecordDate = (r: WeighingRecord) =>
         r.inventoryDate ||
@@ -619,6 +819,19 @@
     loadData()
   }
 
+  const handleResetFilters = () => {
+    filters.planNo = ''
+    filters.batchNo = ''
+    filters.vehicleNo = ''
+    filters.cargoName = ''
+    filters.supplier = ''
+    filters.consignee = ''
+    filters.businessType = ''
+    searchKeyword.value = ''
+    currentPage.value = 1
+    loadData()
+  }
+
   const handlePageChange = (page: number) => {
     currentPage.value = page
   }
@@ -816,7 +1029,9 @@
   const handleExport = async () => {
     try {
       const params: any = {}
-      if (searchKeyword.value) {
+
+      // 快速搜索模式(未展开高级筛选):时间范围 + 搜索框关键字
+      if (!showAdvancedFilters.value && searchKeyword.value) {
         params.vehicleNo = searchKeyword.value
       }
 
@@ -827,7 +1042,11 @@
       }
 
       const records = await weighingApi.list(params)
-      if (records.length === 0) {
+      let exportRecords = records
+      if (showAdvancedFilters.value) {
+        exportRecords = filterRecords(records)
+      }
+      if (exportRecords.length === 0) {
         ElMessage.warning('没有数据可导出')
         return
       }
@@ -845,7 +1064,7 @@
       ]
       const csvContent = [
         headers.join(','),
-        ...records.map((r) =>
+        ...exportRecords.map((r) =>
           [
             r.vehicleNo,
             r.cargoName,
@@ -962,15 +1181,58 @@
 <style lang="scss" scoped>
   .toolbar {
     margin-bottom: 16px;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    align-items: stretch;
+  }
+
+  .toolbar-row {
     display: flex;
     gap: 8px;
     align-items: center;
+    flex-wrap: wrap;
+  }
+
+  .toolbar-spacer {
+    flex: 1;
+  }
+
+  .filter-row {
+    align-items: flex-start;
+  }
+
+  .filter-form {
+    flex: 1;
+  }
+
+  .advanced-search-wrapper {
+    margin-bottom: 16px;
+  }
+
+  :deep(.advanced-search-wrapper .el-form-item) {
+    display: flex;
+    margin-bottom: 12px;
+  }
+
+  :deep(.advanced-search-wrapper .el-form-item__label) {
+    justify-content: flex-end;
+    flex-shrink: 0;
+  }
+
+  :deep(.advanced-search-wrapper .el-input),
+  :deep(.advanced-search-wrapper .el-select) {
+    width: 100%;
   }
 
   .time-filter {
     flex-shrink: 0;
   }
 
+  .search-bar {
+    width: 340px;
+  }
+
   .card-header {
     font-weight: 600;
   }

+ 230 - 0
auto-create-plan/auto_create_monthly_plan.py

@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+"""
+按月自动生成采购计划单。
+
+运行方式(示例):
+    cd auto-create-plan
+    python auto_create_monthly_plan.py
+
+建议在服务器上用守护进程(如 systemd、supervisor)长期运行本脚本,
+不依赖 crontab,由脚本内部每小时自行判断是否需要执行当月生成逻辑。
+"""
+
+import configparser
+import logging
+import sys
+import time
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Optional
+
+from sqlalchemy import and_, or_, select
+from sqlalchemy.orm import Session
+from sqlmodel import SQLModel, create_engine
+
+# 为了避免直接依赖 backend 包的相对导入,这里动态拼出项目根目录,
+# 然后从 backend.app.models.plans 导入 PurchasePlan 模型。
+ROOT_DIR = Path(__file__).resolve().parents[1]
+BACKEND_PATH = ROOT_DIR / "backend"
+if str(BACKEND_PATH) not in sys.path:
+    sys.path.insert(0, str(BACKEND_PATH))
+
+from app.models.plans import PurchasePlan  # type: ignore  # noqa: E402
+
+
+@dataclass
+class JobConfig:
+    db_url: str
+    loop_interval_seconds: int
+    tz: timezone
+    log_file: Optional[Path]
+
+
+def load_config(conf_path: Path) -> JobConfig:
+    cfg = configparser.ConfigParser()
+    if not conf_path.exists():
+        raise FileNotFoundError(f"配置文件不存在: {conf_path}")
+
+    cfg.read(conf_path, encoding="utf-8")
+
+    db_url = cfg.get("database", "url", fallback="").strip()
+    if not db_url:
+        raise ValueError("配置 [database] url 不能为空")
+
+    loop_interval_seconds = cfg.getint("job", "loop_interval_seconds", fallback=3600)
+
+    tz_name = cfg.get("job", "timezone", fallback="Asia/Shanghai").strip()
+    # 简单处理:目前只考虑 Asia/Shanghai,其他时区可以按需扩展
+    if tz_name == "Asia/Shanghai":
+        tz = timezone(timedelta(hours=8))
+    else:
+        # 回退为 UTC
+        tz = timezone.utc
+
+    log_file_raw = cfg.get("job", "log_file", fallback="").strip()
+    log_file = ROOT_DIR / "auto-create-plan" / log_file_raw if log_file_raw else None
+
+    return JobConfig(
+        db_url=db_url,
+        loop_interval_seconds=loop_interval_seconds,
+        tz=tz,
+        log_file=log_file,
+    )
+
+
+def setup_logging(log_file: Optional[Path]) -> None:
+    handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
+    if log_file:
+        log_file.parent.mkdir(parents=True, exist_ok=True)
+        handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
+
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(message)s",
+        handlers=handlers,
+    )
+
+
+def get_now(cfg: JobConfig) -> datetime:
+    return datetime.now(tz=cfg.tz)
+
+
+def month_range(dt: datetime) -> tuple[str, str]:
+    """返回 dt 所在月份的首日、末日(字符串 YYYY-MM-DD)。"""
+    first = dt.replace(day=1)
+    # 下月一号减一天
+    if first.month == 12:
+        next_month = first.replace(year=first.year + 1, month=1, day=1)
+    else:
+        next_month = first.replace(month=first.month + 1, day=1)
+    last = next_month - timedelta(days=1)
+    return first.strftime("%Y-%m-%d"), last.strftime("%Y-%m-%d")
+
+
+def get_last_month_range(now: datetime) -> tuple[str, str]:
+    """返回上个月的首日、末日(字符串 YYYY-MM-DD)。"""
+    first_this, _ = month_range(now)
+    first_this_dt = datetime.strptime(first_this, "%Y-%m-%d").replace(tzinfo=now.tzinfo)
+    last_prev = first_this_dt - timedelta(days=1)
+    return month_range(last_prev)
+
+
+def generate_plan_no() -> str:
+    """生成计划号:格式与后端 _generate_plan_no 一致:SX-YYYYMMDDHHMMSS001。"""
+    now = datetime.now()
+    return f"SX-{now.strftime('%Y%m%d%H%M%S')}001"
+
+
+def default_execution_status(business_type: str) -> str:
+    return "待入库" if business_type == "入库" else "待出库"
+
+
+def run_job_once(session: Session, cfg: JobConfig) -> None:
+    now = get_now(cfg)
+
+    # 仅在每月 1 号执行
+    if now.day != 1:
+        logging.info("当前不是每月 1 日,跳过本次执行")
+        return
+
+    current_month = now.strftime("%Y-%m")
+    this_month_first, this_month_last = month_range(now)
+    last_month_first, last_month_last = get_last_month_range(now)
+
+    logging.info(
+        "开始执行自动生成计划任务,current_month=%s, last_month=[%s, %s], this_month=[%s, %s]",
+        current_month,
+        last_month_first,
+        last_month_last,
+        this_month_first,
+        this_month_last,
+    )
+
+    # 选出“上个月的计划”,且开启了自动生成,且本月尚未生成过,且未超过结束月份
+    stmt = (
+        select(PurchasePlan)
+        .where(
+            and_(
+                PurchasePlan.is_deleted == 0,
+                PurchasePlan.auto_generate_enabled == 1,
+                or_(
+                    PurchasePlan.auto_generate_last_month.is_(None),
+                    PurchasePlan.auto_generate_last_month != current_month,
+                ),
+                or_(
+                    PurchasePlan.auto_generate_end_month.is_(None),
+                    PurchasePlan.auto_generate_end_month >= current_month,
+                ),
+                PurchasePlan.start_date == last_month_first,
+                PurchasePlan.end_date == last_month_last,
+            )
+        )
+    )
+
+    plans = list(session.execute(stmt).scalars())
+    if not plans:
+        logging.info("未找到需要自动生成下月计划的记录")
+        return
+
+    logging.info("找到 %d 条可自动生成的上月计划", len(plans))
+
+    created_count = 0
+    for p in plans:
+        new_plan = PurchasePlan(
+            plan_no=generate_plan_no(),
+            plan_date=this_month_first,
+            start_date=this_month_first,
+            end_date=this_month_last,
+            business_type=p.business_type,
+            product=p.product,
+            specification=p.specification,
+            planned_quantity=p.planned_quantity,
+            vehicle_no=p.vehicle_no or "",
+            customer_name=p.customer_name,
+            remarks=p.remarks,
+            is_deleted=0,
+            execution_status=default_execution_status(p.business_type),
+            executed_at=None,
+            last_weighing_slip_id=None,
+            executed_quantity=0,
+            created_at=now.strftime("%Y-%m-%d %H:%M:%S"),
+            auto_generate_enabled=p.auto_generate_enabled,
+            auto_generate_end_month=p.auto_generate_end_month,
+            auto_generate_last_month=None,
+        )
+        session.add(new_plan)
+
+        # 标记老计划这个月已经处理过
+        p.auto_generate_last_month = current_month
+        created_count += 1
+
+    session.commit()
+    logging.info("本次自动生成完成,共生成 %d 条新计划", created_count)
+
+
+def main() -> None:
+    conf_path = ROOT_DIR / "auto-create-plan" / "setting.conf"
+    cfg = load_config(conf_path)
+    setup_logging(cfg.log_file)
+
+    logging.info("启动自动生成月度计划任务,使用数据库:%s", cfg.db_url)
+
+    engine = create_engine(cfg.db_url, echo=False, future=True)
+
+    # 确保元数据已加载(只针对 PurchasePlan 即可)
+    SQLModel.metadata.create_all(engine, tables=[PurchasePlan.__table__])
+
+    while True:
+        try:
+            with Session(engine) as session:
+                run_job_once(session, cfg)
+        except Exception:
+            logging.exception("执行自动生成计划任务时发生异常")
+        time.sleep(cfg.loop_interval_seconds)
+
+
+if __name__ == "__main__":
+    main()
+

+ 18 - 0
auto-create-plan/setting.conf

@@ -0,0 +1,18 @@
+; Auto create monthly purchase plans
+
+[database]
+; 使用同步驱动的数据库连接串(注意与后端 asyncmy 区分)
+; 示例(请按实际生产环境修改):
+; mysql+pymysql://user:password@host:3306/dibang-test?charset=utf8mb4
+url = mysql+pymysql://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang-test?charset=utf8mb4
+
+[job]
+; 检查间隔(秒),默认每小时检查一次
+loop_interval_seconds = 3600
+
+; 时区标识,用于计算“当前时间”,建议与生产环境一致
+timezone = Asia/Shanghai
+
+; 日志文件路径(相对当前脚本运行目录)
+log_file = auto_create_plan.log
+

+ 1 - 0
backend/.dibang-backend.pid

@@ -0,0 +1 @@
+59010

+ 129 - 2
backend/app/api/routes/dashboard.py

@@ -1,16 +1,17 @@
 from __future__ import annotations
 
 from datetime import date
+from typing import List
 
 from fastapi import APIRouter, Depends
-from sqlalchemy import func
+from sqlalchemy import func, text
 from sqlmodel import select
 from sqlmodel.ext.asyncio.session import AsyncSession
 
 from app.db.session import get_async_session
 from app.models.inventory import InboundOutbound
 from app.models.weighing import WeighingSlip
-from app.schemas.dashboard import DashboardStats
+from app.schemas.dashboard import DashboardStats, DashboardCargoStat, DashboardPartnerCargoStat
 
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
@@ -52,3 +53,129 @@ async def get_dashboard_stats(
         today_outbound=today_outbound,
         abnormal_orders=abnormal_orders,
     )
+
+
+@router.get("/inbound/by-cargo", response_model=List[DashboardCargoStat])
+async def get_inbound_by_cargo(
+    session: AsyncSession = Depends(get_async_session),
+) -> List[DashboardCargoStat]:
+    """获取今日入库量按货物统计(计划量 vs 实际量)"""
+    today = date.today().strftime("%Y-%m-%d")
+
+    # 出入库单表左联地磅单表,按货物统计
+    # 计划量来自出入库单表的quantity,实际量来自地磅单表的net_weight(转换为吨)
+    query = """
+        SELECT
+            COALESCE(io.product, ws.cargo_name, '未填写货物') as label,
+            COALESCE(SUM(io.quantity), 0) as planned,
+            COALESCE(SUM(ws.net_weight) / 1000.0, 0) as actual
+        FROM inbound_outbound io
+        LEFT JOIN weighing_slips ws ON ws.inbound_outbound_id = io.id
+            AND ws.is_voided = 0
+        WHERE io.date = :today
+            AND io.business_type = '入库'
+            AND io.is_deleted = 0
+        GROUP BY COALESCE(io.product, ws.cargo_name, '未填写货物')
+        ORDER BY planned DESC
+    """
+
+    result = await session.execute(text(query), {"today": today})
+    rows = result.all()
+
+    return [
+        DashboardCargoStat(label=row.label, planned=float(row.planned), actual=float(row.actual))
+        for row in rows
+    ]
+
+
+@router.get("/inbound/by-partner-cargo", response_model=List[DashboardPartnerCargoStat])
+async def get_inbound_by_partner_cargo(
+    session: AsyncSession = Depends(get_async_session),
+) -> List[DashboardPartnerCargoStat]:
+    """获取今日入库量按往来单位+货物统计(计划量 vs 实际量)"""
+    today = date.today().strftime("%Y-%m-%d")
+
+    query = """
+        SELECT
+            CONCAT(COALESCE(io.partner_company, ''), ' - ', COALESCE(io.product, ws.cargo_name, '未填写货物')) as label,
+            COALESCE(SUM(io.quantity), 0) as planned,
+            COALESCE(SUM(ws.net_weight) / 1000.0, 0) as actual
+        FROM inbound_outbound io
+        LEFT JOIN weighing_slips ws ON ws.inbound_outbound_id = io.id
+            AND ws.is_voided = 0
+        WHERE io.date = :today
+            AND io.business_type = '入库'
+            AND io.is_deleted = 0
+        GROUP BY io.partner_company, io.product, ws.cargo_name
+        ORDER BY io.partner_company, planned DESC
+    """
+
+    result = await session.execute(text(query), {"today": today})
+    rows = result.all()
+
+    return [
+        DashboardPartnerCargoStat(label=row.label, planned=float(row.planned), actual=float(row.actual))
+        for row in rows
+    ]
+
+
+@router.get("/outbound/by-cargo", response_model=List[DashboardCargoStat])
+async def get_outbound_by_cargo(
+    session: AsyncSession = Depends(get_async_session),
+) -> List[DashboardCargoStat]:
+    """获取今日出库量按货物统计(计划量 vs 实际量)"""
+    today = date.today().strftime("%Y-%m-%d")
+
+    query = """
+        SELECT
+            COALESCE(io.product, ws.cargo_name, '未填写货物') as label,
+            COALESCE(SUM(io.quantity), 0) as planned,
+            COALESCE(SUM(ws.net_weight) / 1000.0, 0) as actual
+        FROM inbound_outbound io
+        LEFT JOIN weighing_slips ws ON ws.inbound_outbound_id = io.id
+            AND ws.is_voided = 0
+        WHERE io.date = :today
+            AND io.business_type = '出库'
+            AND io.is_deleted = 0
+        GROUP BY COALESCE(io.product, ws.cargo_name, '未填写货物')
+        ORDER BY planned DESC
+    """
+
+    result = await session.execute(text(query), {"today": today})
+    rows = result.all()
+
+    return [
+        DashboardCargoStat(label=row.label, planned=float(row.planned), actual=float(row.actual))
+        for row in rows
+    ]
+
+
+@router.get("/outbound/by-partner-cargo", response_model=List[DashboardPartnerCargoStat])
+async def get_outbound_by_partner_cargo(
+    session: AsyncSession = Depends(get_async_session),
+) -> List[DashboardPartnerCargoStat]:
+    """获取今日出库量按往来单位+货物统计(计划量 vs 实际量)"""
+    today = date.today().strftime("%Y-%m-%d")
+
+    query = """
+        SELECT
+            CONCAT(COALESCE(io.partner_company, ''), ' - ', COALESCE(io.product, ws.cargo_name, '未填写货物')) as label,
+            COALESCE(SUM(io.quantity), 0) as planned,
+            COALESCE(SUM(ws.net_weight) / 1000.0, 0) as actual
+        FROM inbound_outbound io
+        LEFT JOIN weighing_slips ws ON ws.inbound_outbound_id = io.id
+            AND ws.is_voided = 0
+        WHERE io.date = :today
+            AND io.business_type = '出库'
+            AND io.is_deleted = 0
+        GROUP BY io.partner_company, io.product, ws.cargo_name
+        ORDER BY io.partner_company, planned DESC
+    """
+
+    result = await session.execute(text(query), {"today": today})
+    rows = result.all()
+
+    return [
+        DashboardPartnerCargoStat(label=row.label, planned=float(row.planned), actual=float(row.actual))
+        for row in rows
+    ]

+ 3 - 0
backend/app/api/routes/plans.py

@@ -180,6 +180,9 @@ async def create_plan(
         last_weighing_slip_id=None,
         executed_quantity=0,
         created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+        auto_generate_enabled=payload.auto_generate_enabled or 0,
+        auto_generate_end_month=payload.auto_generate_end_month,
+        auto_generate_last_month=None,
     )
     session.add(plan)
     await session.commit()

+ 7 - 0
backend/app/models/plans.py

@@ -25,6 +25,13 @@ class PurchasePlan(SQLModel, table=True):
     last_weighing_slip_id: int | None = Field(default=None)  # 最后关联的地磅单ID
     executed_quantity: float | None = Field(default=0)  # 已执行量(kg)
     created_at: str = Field(max_length=255)  # 创建时间
+    auto_generate_enabled: int = Field(default=0, index=True)  # 是否自动按月生成(0=否,1=是)
+    auto_generate_end_month: str | None = Field(
+        default=None, max_length=7, index=True
+    )  # 自动生成结束月份(YYYY-MM,None=一直生成)
+    auto_generate_last_month: str | None = Field(
+        default=None, max_length=7
+    )  # 最后一次自动生成的月份(YYYY-MM,用于防重复)
 
 
 # 保留别名以兼容

+ 16 - 0
backend/app/schemas/dashboard.py

@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+from typing import List
+
 from pydantic import BaseModel, Field
 
 
@@ -10,3 +12,17 @@ class DashboardStats(BaseModel):
 
     model_config = {"populate_by_name": True, "ser_json_by_alias": True}
 
+
+class DashboardCargoStat(BaseModel):
+    """按货物统计的数据项"""
+    label: str
+    planned: float
+    actual: float
+
+
+class DashboardPartnerCargoStat(BaseModel):
+    """按往来单位和货物统计的数据项"""
+    label: str
+    planned: float
+    actual: float
+

+ 18 - 0
backend/app/schemas/plans.py

@@ -19,6 +19,13 @@ class PurchasePlanBase(BaseModel):
     executed_at: str | None = Field(default=None, max_length=255, alias="executedAt")  # 执行时间
     last_weighing_slip_id: int | None = Field(default=None, alias="lastWeighingSlipId")  # 最后关联的地磅单ID
     executed_quantity: float | None = Field(default=0, alias="executedQuantity")  # 已执行量(kg)
+    auto_generate_enabled: int = Field(default=0, alias="autoGenerateEnabled")  # 是否自动按月生成(0=否,1=是)
+    auto_generate_end_month: str | None = Field(
+        default=None, max_length=7, alias="autoGenerateEndMonth"
+    )  # 自动生成结束月份(YYYY-MM,None=一直生成)
+    auto_generate_last_month: str | None = Field(
+        default=None, max_length=7, alias="autoGenerateLastMonth"
+    )  # 最后一次自动生成的月份(YYYY-MM,用于防重复)
 
     model_config = {"from_attributes": True, "populate_by_name": True}
 
@@ -36,6 +43,10 @@ class PurchasePlanCreate(BaseModel):
     customer_name: str | None = Field(default=None, max_length=255, alias="customerName")
     remarks: str | None = Field(default=None)
     execution_status: str | None = Field(default=None, max_length=50, alias="executionStatus")
+    auto_generate_enabled: int | None = Field(default=0, alias="autoGenerateEnabled")
+    auto_generate_end_month: str | None = Field(
+        default=None, max_length=7, alias="autoGenerateEndMonth"
+    )
 
     model_config = {"populate_by_name": True}
 
@@ -56,6 +67,13 @@ class PurchasePlanUpdate(BaseModel):
     executed_at: str | None = Field(default=None, max_length=255, alias="executedAt")
     last_weighing_slip_id: int | None = Field(default=None, alias="lastWeighingSlipId")
     executed_quantity: float | None = Field(default=None, alias="executedQuantity")
+    auto_generate_enabled: int | None = Field(default=None, alias="autoGenerateEnabled")
+    auto_generate_end_month: str | None = Field(
+        default=None, max_length=7, alias="autoGenerateEndMonth"
+    )
+    auto_generate_last_month: str | None = Field(
+        default=None, max_length=7, alias="autoGenerateLastMonth"
+    )
 
     model_config = {"populate_by_name": True}
 

+ 77 - 5
dibang-test.sql

@@ -11,7 +11,7 @@
  Target Server Version : 50736 (5.7.36)
  File Encoding         : 65001
 
- Date: 23/02/2026 22:34:34
+ Date: 06/03/2026 18:37:13
 */
 
 SET NAMES utf8mb4;
@@ -31,7 +31,23 @@ CREATE TABLE `cargos` (
   UNIQUE KEY `name` (`name`),
   KEY `idx_name` (`name`),
   KEY `idx_cargo_is_deleted` (`is_deleted`)
-) ENGINE=InnoDB AUTO_INCREMENT=29 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物名称表';
+) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物名称表';
+
+-- ----------------------------
+-- Table structure for companies
+-- ----------------------------
+DROP TABLE IF EXISTS `companies`;
+CREATE TABLE `companies` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '公司名称',
+  `remarks` text COLLATE utf8mb4_unicode_ci COMMENT '备注',
+  `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除(0=否,1=是)',
+  `created_at` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`),
+  KEY `idx_name` (`name`),
+  KEY `idx_company_is_deleted` (`is_deleted`)
+) ENGINE=InnoDB AUTO_INCREMENT=80 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公司名称表';
 
 -- ----------------------------
 -- Table structure for inbound_outbound
@@ -59,7 +75,29 @@ CREATE TABLE `inbound_outbound` (
   KEY `idx_is_deleted` (`is_deleted`),
   KEY `idx_status` (`status`),
   KEY `idx_vehicle_no` (`vehicle_no`)
-) ENGINE=InnoDB AUTO_INCREMENT=897 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出入库单表';
+) ENGINE=InnoDB AUTO_INCREMENT=1095 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出入库单表';
+
+-- ----------------------------
+-- Table structure for operation_logs
+-- ----------------------------
+DROP TABLE IF EXISTS `operation_logs`;
+CREATE TABLE `operation_logs` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `user_id` int(11) NOT NULL,
+  `username` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
+  `operation_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
+  `resource_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
+  `resource_id` int(11) DEFAULT NULL,
+  `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `ip_address` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `user_agent` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `ix_operation_logs_operation_type` (`operation_type`),
+  KEY `ix_operation_logs_resource_type` (`resource_type`),
+  KEY `ix_operation_logs_user_id` (`user_id`),
+  KEY `ix_operation_logs_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
 -- ----------------------------
 -- Table structure for purchase_plans
@@ -93,7 +131,41 @@ CREATE TABLE `purchase_plans` (
   KEY `idx_product` (`product`),
   KEY `idx_is_deleted` (`is_deleted`),
   KEY `idx_execution_status` (`execution_status`)
-) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='计划表';
+) ENGINE=InnoDB AUTO_INCREMENT=66 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='计划表';
+
+-- ----------------------------
+-- Table structure for users
+-- ----------------------------
+DROP TABLE IF EXISTS `users`;
+CREATE TABLE `users` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `username` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户名',
+  `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
+  `role` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'user' COMMENT '角色(admin/user)',
+  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
+  `is_active` tinyint(1) NOT NULL DEFAULT '1',
+  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE KEY `username` (`username`) USING BTREE,
+  KEY `idx_username` (`username`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
+
+-- ----------------------------
+-- Table structure for vehicles
+-- ----------------------------
+DROP TABLE IF EXISTS `vehicles`;
+CREATE TABLE `vehicles` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `vehicle_no` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+  `remarks` text COLLATE utf8mb4_unicode_ci,
+  `is_deleted` int(11) NOT NULL DEFAULT '0',
+  `created_at` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `id` (`id`),
+  UNIQUE KEY `uq_vehicles_vehicle_no` (`vehicle_no`),
+  KEY `ix_vehicles_vehicle_no` (`vehicle_no`),
+  KEY `ix_vehicles_is_deleted` (`is_deleted`)
+) ENGINE=InnoDB AUTO_INCREMENT=142 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
 -- ----------------------------
 -- Table structure for weighing_slips
@@ -131,6 +203,6 @@ CREATE TABLE `weighing_slips` (
   KEY `idx_is_voided` (`is_voided`),
   KEY `idx_purchase_plan_id` (`purchase_plan_id`),
   KEY `idx_inbound_outbound_id` (`inbound_outbound_id`)
-) ENGINE=InnoDB AUTO_INCREMENT=675 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='地磅单表';
+) ENGINE=InnoDB AUTO_INCREMENT=801 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='地磅单表';
 
 SET FOREIGN_KEY_CHECKS = 1;

+ 45 - 0
mobile-h5/src/api/dashboardApi.ts

@@ -0,0 +1,45 @@
+import api from '@/utils/http'
+
+// ==================== 仪表盘 API ====================
+
+export interface DashboardCargoStat {
+  label: string
+  planned: number
+  actual: number
+}
+
+export interface DashboardPartnerCargoStat {
+  label: string
+  planned: number
+  actual: number
+}
+
+export const dashboardApi = {
+  // 获取今日入库量按货物统计
+  getInboundByCargo: async (): Promise<DashboardCargoStat[]> => {
+    return api.get<DashboardCargoStat[]>({
+      url: '/dashboard/inbound/by-cargo'
+    })
+  },
+
+  // 获取今日入库量按往来单位+货物统计
+  getInboundByPartnerCargo: async (): Promise<DashboardPartnerCargoStat[]> => {
+    return api.get<DashboardPartnerCargoStat[]>({
+      url: '/dashboard/inbound/by-partner-cargo'
+    })
+  },
+
+  // 获取今日出库量按货物统计
+  getOutboundByCargo: async (): Promise<DashboardCargoStat[]> => {
+    return api.get<DashboardCargoStat[]>({
+      url: '/dashboard/outbound/by-cargo'
+    })
+  },
+
+  // 获取今日出库量按往来单位+货物统计
+  getOutboundByPartnerCargo: async (): Promise<DashboardPartnerCargoStat[]> => {
+    return api.get<DashboardPartnerCargoStat[]>({
+      url: '/dashboard/outbound/by-partner-cargo'
+    })
+  }
+}

+ 192 - 0
mobile-h5/src/components/Charts/DualBarChart.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="chart-card">
+    <div class="chart-header">
+      <span class="chart-title">{{ title }}</span>
+      <div class="legend">
+        <span class="legend-item">
+          <span class="legend-color planned"></span>
+          计划量
+        </span>
+        <span class="legend-item">
+          <span class="legend-color actual"></span>
+          实际量
+        </span>
+      </div>
+    </div>
+    <div v-if="data.length === 0" class="chart-empty">
+      <van-empty description="今日无数据" />
+    </div>
+    <div v-else class="chart-container">
+      <div v-for="item in data" :key="item.label" class="chart-item">
+        <span class="chart-label">{{ item.label }}</span>
+        <div class="chart-bar-group">
+          <div class="chart-bar-wrapper">
+            <div
+              class="chart-bar planned"
+              :style="{
+                width: maxValue ? (item.planned / maxValue) * 100 + '%' : '0%',
+                minWidth: '4px'
+              }"
+            />
+          </div>
+          <div class="chart-bar-wrapper">
+            <div
+              class="chart-bar actual"
+              :style="{
+                width: maxValue ? (item.actual / maxValue) * 100 + '%' : '0%',
+                minWidth: '4px'
+              }"
+            />
+          </div>
+        </div>
+        <div class="chart-values">
+          <span class="value planned">{{ item.planned.toFixed(2) }}</span>
+          <span class="value actual">{{ item.actual.toFixed(2) }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+interface DualBarItem {
+  label: string
+  planned: number
+  actual: number
+}
+
+const props = defineProps<{
+  title: string
+  data: DualBarItem[]
+}>()
+
+const maxValue = computed(() => {
+  if (!props.data.length) return 0
+  const maxPlanned = Math.max(...props.data.map((item) => item.planned))
+  const maxActual = Math.max(...props.data.map((item) => item.actual))
+  return Math.max(maxPlanned, maxActual) || 0
+})
+</script>
+
+<style lang="scss" scoped>
+.chart-card {
+  background: #fff;
+  border-radius: 16px;
+  padding: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  margin-bottom: 16px;
+}
+
+.chart-header {
+  margin-bottom: 12px;
+}
+
+.chart-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #111827;
+  display: block;
+  margin-bottom: 8px;
+}
+
+.legend {
+  display: flex;
+  gap: 16px;
+
+  &-item {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    font-size: 12px;
+    color: #666;
+  }
+
+  &-color {
+    width: 12px;
+    height: 12px;
+    border-radius: 2px;
+
+    &.planned {
+      background: #1677ff;
+    }
+
+    &.actual {
+      background: #52c41a;
+    }
+  }
+}
+
+.chart-empty {
+  padding: 20px 0;
+}
+
+.chart-container {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.chart-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+}
+
+.chart-label {
+  min-width: 70px;
+  font-size: 13px;
+  color: #111827;
+  flex-shrink: 0;
+}
+
+.chart-bar-group {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.chart-bar-wrapper {
+  background: #f3f4f6;
+  border-radius: 4px;
+  overflow: hidden;
+  height: 14px;
+}
+
+.chart-bar {
+  height: 100%;
+  border-radius: 4px;
+  transition: width 0.3s ease;
+
+  &.planned {
+    background: linear-gradient(90deg, #1677ff 0%, #4096ff 100%);
+  }
+
+  &.actual {
+    background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
+  }
+}
+
+.chart-values {
+  display: flex;
+  flex-direction: column;
+  gap: 1px;
+  min-width: 80px;
+  text-align: right;
+  font-size: 12px;
+  flex-shrink: 0;
+
+  .value {
+    &.planned {
+      color: #1677ff;
+    }
+
+    &.actual {
+      color: #52c41a;
+    }
+  }
+}
+</style>

+ 34 - 44
mobile-h5/src/views/dashboard/index.vue

@@ -30,8 +30,10 @@
     </div>
 
     <div class="charts-section">
-      <SimpleBarChart title="今日入库量(按货物,t)" :data="inboundByCargo" />
-      <SimpleBarChart title="今日出库量(按货物,t)" :data="outboundByCargo" />
+      <DualBarChart title="今日入库量(按货物,t)" :data="inboundByCargo" />
+      <DualBarChart title="今日出库量(按货物,t)" :data="outboundByCargo" />
+      <DualBarChart title="今日入库量(按往来单位+货物,t)" :data="inboundByPartnerCargo" />
+      <DualBarChart title="今日出库量(按往来单位+货物,t)" :data="outboundByPartnerCargo" />
     </div>
   </div>
 </template>
@@ -40,25 +42,34 @@
 import { ref, computed, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useUserStore } from '@/store/modules/user'
-import { weighingApi, type WeighingRecord } from '@/api/weighingApi'
 import dayjs from 'dayjs'
 import { showDialog } from 'vant'
-import SimpleBarChart from '@/components/Charts/SimpleBarChart.vue'
+import { dashboardApi, type DashboardCargoStat, type DashboardPartnerCargoStat } from '@/api/dashboardApi'
+import DualBarChart from '@/components/Charts/DualBarChart.vue'
 
 const router = useRouter()
 const userStore = useUserStore()
 
 const today = dayjs().format('YYYY-MM-DD')
 const isLoading = ref(false)
-const todayWeighing = ref<WeighingRecord[]>([])
+const inboundByCargo = ref<DashboardCargoStat[]>([])
+const outboundByCargo = ref<DashboardCargoStat[]>([])
+const inboundByPartnerCargo = ref<DashboardPartnerCargoStat[]>([])
+const outboundByPartnerCargo = ref<DashboardPartnerCargoStat[]>([])
 
 onMounted(async () => {
   isLoading.value = true
   try {
-    todayWeighing.value = await weighingApi.list({
-      dateFrom: today,
-      dateTo: today
-    })
+    const [inCargo, outCargo, inPartner, outPartner] = await Promise.all([
+      dashboardApi.getInboundByCargo(),
+      dashboardApi.getOutboundByCargo(),
+      dashboardApi.getInboundByPartnerCargo(),
+      dashboardApi.getOutboundByPartnerCargo()
+    ])
+    inboundByCargo.value = inCargo
+    outboundByCargo.value = outCargo
+    inboundByPartnerCargo.value = inPartner
+    outboundByPartnerCargo.value = outPartner
   } catch (error) {
     console.error('获取数据失败', error)
   } finally {
@@ -66,48 +77,27 @@ onMounted(async () => {
   }
 })
 
-const validRecords = computed(() =>
-  todayWeighing.value.filter((record) => record.isVoided === 0)
-)
-
-const inboundRecords = computed(() =>
-  validRecords.value.filter((item) => item.businessType === '入库')
-)
-const outboundRecords = computed(() =>
-  validRecords.value.filter((item) => item.businessType === '出库')
-)
+const todayInboundVehicles = computed(() => {
+  const cargoSet = new Set<string>()
+  inboundByCargo.value.forEach(item => {
+    if (item.planned > 0) cargoSet.add(item.label)
+  })
+  return cargoSet.size
+})
 
-const todayInboundVehicles = computed(
-  () => new Set(inboundRecords.value.map((item) => item.vehicleNo)).size
-)
-const todayOutboundVehicles = computed(
-  () => new Set(outboundRecords.value.map((item) => item.vehicleNo)).size
-)
+const todayOutboundVehicles = computed(() => {
+  const cargoSet = new Set<string>()
+  outboundByCargo.value.forEach(item => {
+    if (item.planned > 0) cargoSet.add(item.label)
+  })
+  return cargoSet.size
+})
 
 const statsList = computed(() => [
   { title: '今日入库', value: todayInboundVehicles.value, suffix: '车' },
   { title: '今日出库', value: todayOutboundVehicles.value, suffix: '车' }
 ])
 
-// 按货物名称汇总当天出入库量
-interface SimpleBarItem {
-  label: string
-  value: number
-}
-
-const aggregateByCargo = (records: WeighingRecord[]): SimpleBarItem[] => {
-  const map = new Map<string, number>()
-  records.forEach((item) => {
-    const key = item.cargoName || '未填写货物'
-    const prev = map.get(key) ?? 0
-    map.set(key, prev + item.netWeight / 1000)
-  })
-  return Array.from(map.entries()).map(([label, value]) => ({ label, value }))
-}
-
-const inboundByCargo = computed(() => aggregateByCargo(inboundRecords.value))
-const outboundByCargo = computed(() => aggregateByCargo(outboundRecords.value))
-
 const menuList = computed(() => {
   return [
     { path: '/plans', label: '计划管理', icon: '📋' },