zlwu 11 ماه پیش
والد
کامیت
1a6600f75d
38فایلهای تغییر یافته به همراه2099 افزوده شده و 284 حذف شده
  1. 20 4
      art-design-pro/src/App.vue
  2. 20 1
      art-design-pro/src/api/inventoryApi.ts
  3. 13 1
      art-design-pro/src/api/plansApi.ts
  4. 13 1
      art-design-pro/src/api/weighingApi.ts
  5. 2 2
      art-design-pro/src/assets/styles/app.scss
  6. 2 2
      art-design-pro/src/components/Layout/TopBar/index.vue
  7. 11 0
      art-design-pro/src/components/Layout/TopBar/style.scss
  8. 2 0
      art-design-pro/src/components/Table/ArtTable.vue
  9. 2 2
      art-design-pro/src/store/modules/setting.ts
  10. 1 0
      art-design-pro/src/types/components.d.ts
  11. 56 9
      art-design-pro/src/utils/http/index.ts
  12. 2 2
      art-design-pro/src/utils/tabs.ts
  13. 1 1
      art-design-pro/src/views/dashboard/console/index.vue
  14. 3 3
      art-design-pro/src/views/index/index.vue
  15. 359 44
      art-design-pro/src/views/inventory/index.vue
  16. 97 6
      art-design-pro/src/views/master-data/index.vue
  17. 256 76
      art-design-pro/src/views/plans/index.vue
  18. 28 7
      art-design-pro/src/views/user/UserManagement.vue
  19. 460 77
      art-design-pro/src/views/weighing/index.vue
  20. 5 7
      backend/app/api/routes/auth.py
  21. 25 2
      backend/app/api/routes/cargos.py
  22. 25 2
      backend/app/api/routes/companies.py
  23. 121 2
      backend/app/api/routes/inventory.py
  24. 50 0
      backend/app/api/routes/plans.py
  25. 24 13
      backend/app/api/routes/users.py
  26. 248 11
      backend/app/api/routes/weighing.py
  27. 14 1
      backend/app/db/session.py
  28. 2 0
      backend/app/models/master_data.py
  29. 1 1
      backend/app/models/users.py
  30. 7 0
      backend/app/schemas/inventory.py
  31. 4 0
      backend/app/schemas/master_data.py
  32. 2 2
      backend/app/schemas/users.py
  33. 5 5
      backend/app/schemas/weighing.py
  34. 70 0
      backend/migrate_add_remarks.py
  35. 70 0
      backend/migrate_add_remarks_cargos.py
  36. 13 0
      backend/migrate_password_field.sql
  37. 7 0
      backend/migrations/add_remarks_to_companies.sql
  38. 58 0
      weighing_slips.sql

+ 20 - 4
art-design-pro/src/App.vue

@@ -36,10 +36,26 @@
 
   // 获取用户信息
   const getUserInfo = async () => {
-    if (userStore.isLogin) {
-      const userRes = await UserService.getUserInfo()
-      if (userRes.code === ApiStatus.success) {
-        userStore.setUserInfo(userRes.data)
+    // 只有在已登录且有 token 的情况下才调用
+    if (userStore.isLogin && userStore.accessToken) {
+      try {
+        const userRes = await UserService.getUserInfo()
+        if (userRes.code === ApiStatus.success) {
+          userStore.setUserInfo(userRes.data)
+        } else if (userRes.code === 401) {
+          // token 无效,清除登录状态
+          userStore.setLoginStatus(false)
+          userStore.accessToken = ''
+          sessionStorage.removeItem('accessToken')
+        }
+      } catch (error) {
+        // 静默处理错误,不显示错误消息
+        // 可能是 token 过期或无效,这是正常情况
+        if (userStore.isLogin) {
+          userStore.setLoginStatus(false)
+          userStore.accessToken = ''
+          sessionStorage.removeItem('accessToken')
+        }
       }
     }
   }

+ 20 - 1
art-design-pro/src/api/inventoryApi.ts

@@ -1,4 +1,6 @@
 import api from '@/utils/http'
+import type { Plan } from './plansApi'
+import type { WeighingRecord } from './weighingApi'
 
 // ==================== 出入库管理 API ====================
 
@@ -17,6 +19,13 @@ export interface InventoryRecord {
   purchasePlanId?: number
   isDeleted: number
   createdAt: string
+  grossWeight?: number // 毛重(kg)- 汇总自关联地磅单
+  tareWeight?: number // 皮重(kg)- 汇总自关联地磅单
+  deduction?: number // 扣杂(kg)- 汇总自关联地磅单
+  netWeight?: number // 净重(kg)- 汇总自关联地磅单
+  grossWeightTime?: string // 毛重时间 - 来自关联地磅单
+  tareWeightTime?: string // 皮重时间 - 来自关联地磅单
+  hasWeighingSlip?: boolean // 是否已有关联的地磅单
 }
 
 export interface InventoryCreate {
@@ -47,6 +56,11 @@ export interface InventoryUpdate {
   purchasePlanId?: number
 }
 
+export interface InventoryRelatedData {
+  plan: Plan | null
+  weighing: WeighingRecord | null
+}
+
 export const inventoryApi = {
   list: async (params?: {
     q?: string
@@ -65,6 +79,11 @@ export const inventoryApi = {
       url: `/inventory/${id}`
     })
   },
+  getRelated: async (id: number): Promise<InventoryRelatedData> => {
+    return api.get<InventoryRelatedData>({
+      url: `/inventory/${id}/related`
+    })
+  },
   create: async (payload: InventoryCreate): Promise<InventoryRecord> => {
     return api.post<InventoryRecord>({
       url: '/inventory',
@@ -72,7 +91,7 @@ export const inventoryApi = {
     })
   },
   update: async (id: number, payload: InventoryUpdate): Promise<InventoryRecord> => {
-    return api.put<InventoryRecord>({
+    return api.patch<InventoryRecord>({
       url: `/inventory/${id}`,
       data: payload
     })

+ 13 - 1
art-design-pro/src/api/plansApi.ts

@@ -1,4 +1,6 @@
 import api from '@/utils/http'
+import type { InventoryRecord } from './inventoryApi'
+import type { WeighingRecord } from './weighingApi'
 
 // ==================== 计划管理 API ====================
 
@@ -56,6 +58,11 @@ export interface PlanUpdate {
   executedQuantity?: number
 }
 
+export interface PlanRelatedData {
+  inventories: InventoryRecord[]
+  weighings: WeighingRecord[]
+}
+
 export const planApi = {
   list: async (params?: {
     q?: string
@@ -72,6 +79,11 @@ export const planApi = {
       url: `/plans/${id}`
     })
   },
+  getRelated: async (id: number): Promise<PlanRelatedData> => {
+    return api.get<PlanRelatedData>({
+      url: `/plans/${id}/related`
+    })
+  },
   create: async (payload: PlanCreate): Promise<Plan> => {
     return api.post<Plan>({
       url: '/plans',
@@ -79,7 +91,7 @@ export const planApi = {
     })
   },
   update: async (id: number, payload: PlanUpdate): Promise<Plan> => {
-    return api.put<Plan>({
+    return api.patch<Plan>({
       url: `/plans/${id}`,
       data: payload
     })

+ 13 - 1
art-design-pro/src/api/weighingApi.ts

@@ -1,4 +1,6 @@
 import api from '@/utils/http'
+import type { Plan } from './plansApi'
+import type { InventoryRecord } from './inventoryApi'
 
 // ==================== 地磅单管理 API ====================
 
@@ -75,6 +77,11 @@ export interface WeighingUpdate {
   isVoided?: number
 }
 
+export interface WeighingRelatedData {
+  plan: Plan | null
+  inventory: InventoryRecord | null
+}
+
 export const weighingApi = {
   list: async (params?: {
     dateFrom?: string
@@ -93,6 +100,11 @@ export const weighingApi = {
       url: `/weighing/${id}`
     })
   },
+  getRelated: async (id: number): Promise<WeighingRelatedData> => {
+    return api.get<WeighingRelatedData>({
+      url: `/weighing/${id}/related`
+    })
+  },
   create: async (payload: WeighingCreate): Promise<WeighingRecord> => {
     return api.post<WeighingRecord>({
       url: '/weighing',
@@ -100,7 +112,7 @@ export const weighingApi = {
     })
   },
   update: async (id: number, payload: WeighingUpdate): Promise<WeighingRecord> => {
-    return api.put<WeighingRecord>({
+    return api.patch<WeighingRecord>({
       url: `/weighing/${id}`,
       data: payload
     })

+ 2 - 2
art-design-pro/src/assets/styles/app.scss

@@ -120,8 +120,8 @@
 [data-box-mode='shadow-mode'] {
   .page-content,
   .art-custom-card {
-    box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
-    border: 1px solid rgba(var(--art-gray-300-rgb), 0.3) !important;
+    box-shadow: none !important;
+    border: none !important;
   }
 
   .menu-left {

+ 2 - 2
art-design-pro/src/components/Layout/TopBar/index.vue

@@ -17,11 +17,11 @@
           </div>
         </div> -->
         <!-- 刷新按钮 -->
-        <div class="btn-box" v-if="showRefreshButton">
+        <!-- <div class="btn-box" v-if="showRefreshButton">
           <div class="btn refresh-btn" :style="{ marginLeft: !isLeftMenu ? '10px' : '0' }">
             <i class="iconfont-sys" @click="reload()"> &#xe6b3; </i>
           </div>
-        </div>
+        </div> -->
         <!-- 快速入口 -->
         <!-- <fast-enter v-if="width >= 1200" /> -->
         <!-- 面包屑 -->

+ 11 - 0
art-design-pro/src/components/Layout/TopBar/style.scss

@@ -241,6 +241,10 @@
 
     > .left {
       line-height: 60px;
+      position: relative;
+      flex: 1;
+      display: flex;
+      align-items: center;
 
       .top-header {
         display: flex;
@@ -259,6 +263,13 @@
         }
       }
 
+      // 顶部菜单模式下,让菜单居中
+      .menu-top {
+        position: absolute;
+        left: 50%;
+        transform: translateX(-50%);
+      }
+
       .svg-icon {
         display: none;
         width: 30px;

+ 2 - 0
art-design-pro/src/components/Table/ArtTable.vue

@@ -55,6 +55,8 @@
 </template>
 
 <script setup lang="ts">
+  import { computed } from 'vue'
+
   interface TableProps {
     /** 表格数据源 */
     data?: any[]

+ 2 - 2
art-design-pro/src/store/modules/setting.ts

@@ -42,7 +42,7 @@ export interface SettingState {
 export const useSettingStore = defineStore({
   id: 'settingStore',
   state: (): SettingState => ({
-    menuType: MenuTypeEnum.LEFT,
+    menuType: MenuTypeEnum.TOP,
     menuOpenWidth: defaultMenuWidth,
     systemThemeType: SystemThemeEnum.LIGHT,
     systemThemeMode: SystemThemeEnum.LIGHT,
@@ -105,7 +105,7 @@ export const useSettingStore = defineStore({
       if (sys) {
         sys = JSON.parse(sys)
         const { setting } = sys.user
-        this.menuType = setting.menuType || MenuTypeEnum.LEFT
+        this.menuType = setting.menuType || MenuTypeEnum.TOP
         this.menuOpenWidth = Number(setting.menuOpenWidth) || defaultMenuWidth
         this.systemThemeType = setting.systemThemeType || SystemThemeEnum.LIGHT
         this.systemThemeMode = setting.systemThemeMode || SystemThemeEnum.LIGHT

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

@@ -39,6 +39,7 @@ declare module 'vue' {
     ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
     ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']

+ 56 - 9
art-design-pro/src/utils/http/index.ts

@@ -18,7 +18,16 @@ const axiosInstance = axios.create({
   timeout: 15000, // 请求超时时间(毫秒)
   baseURL: getBaseURL(), // API地址
   withCredentials: true, // 异步请求携带cookie
-  transformRequest: [(data) => JSON.stringify(data)], // 请求数据转换为 JSON 字符串
+  transformRequest: [
+    (data, headers) => {
+      // 如果 data 是对象或数组,转换为 JSON 字符串
+      // 如果已经是字符串,直接返回
+      if (data && typeof data === 'object' && !(data instanceof FormData)) {
+        return JSON.stringify(data)
+      }
+      return data
+    }
+  ],
   validateStatus: (status) => status >= 200 && status < 300, // 只接受 2xx 的状态码
   headers: {
     get: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
@@ -45,12 +54,18 @@ axiosInstance.interceptors.request.use(
     const userStore = useUserStore()
     const accessToken = userStore.accessToken
 
-    // 如果 token 存在,则设置请求头
+    // 对于有 body 的请求(POST, PUT, PATCH),确保 Content-Type 正确
+    const method = request.method?.toUpperCase()
+    if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
+      if (request.data && typeof request.data === 'object' && !(request.data instanceof FormData)) {
+        // 如果 data 是对象,确保 Content-Type 是 application/json
+        request.headers['Content-Type'] = 'application/json'
+      }
+    }
+
+    // 如果 token 存在,则设置 Authorization header
     if (accessToken) {
-      request.headers.set({
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${accessToken}`
-      })
+      request.headers['Authorization'] = `Bearer ${accessToken}`
     }
 
     return request // 返回修改后的配置
@@ -68,7 +83,36 @@ axiosInstance.interceptors.response.use(
     if (axios.isCancel(error)) {
       console.log('repeated request: ' + error.message)
     } else {
-      const errorMessage = error.response?.data.message
+      const status = error.response?.status
+      const url = error.config?.url || ''
+      
+      // 对于 401 错误,如果是 userinfo 接口,静默处理(不显示错误消息)
+      // 因为这是自动调用的接口,token 过期或无效是正常情况
+      if (status === 401 && url.includes('/auth/userinfo')) {
+        // 静默处理,清除登录状态
+        const userStore = useUserStore()
+        if (userStore.isLogin) {
+          userStore.setLoginStatus(false)
+          userStore.accessToken = ''
+          sessionStorage.removeItem('accessToken')
+        }
+        return Promise.reject(error)
+      }
+      
+      // 其他 401 错误,也静默处理(可能是 token 过期)
+      if (status === 401) {
+        const userStore = useUserStore()
+        if (userStore.isLogin) {
+          userStore.setLoginStatus(false)
+          userStore.accessToken = ''
+          sessionStorage.removeItem('accessToken')
+        }
+        // 不显示错误消息,避免干扰用户
+        return Promise.reject(error)
+      }
+      
+      // 其他错误正常显示
+      const errorMessage = error.response?.data?.detail || error.response?.data?.message
       ElMessage.error(
         errorMessage
           ? `${errorMessage} ${EmojiText[500]}`
@@ -81,8 +125,8 @@ axiosInstance.interceptors.response.use(
 
 // 请求
 async function request<T = any>(config: AxiosRequestConfig): Promise<T> {
-  // 对 POST | PUT 请求特殊处理
-  if (config.method?.toUpperCase() === 'POST' || config.method?.toUpperCase() === 'PUT') {
+  // 对 POST | PUT | PATCH 请求特殊处理
+  if (config.method?.toUpperCase() === 'POST' || config.method?.toUpperCase() === 'PUT' || config.method?.toUpperCase() === 'PATCH') {
     // 如果已经有 data,则保留原有的 data
     if (config.params && !config.data) {
       config.data = config.params
@@ -112,6 +156,9 @@ const api = {
   put<T>(config: AxiosRequestConfig): Promise<T> {
     return request({ ...config, method: 'PUT' }) // PUT 请求
   },
+  patch<T>(config: AxiosRequestConfig): Promise<T> {
+    return request({ ...config, method: 'PATCH' }) // PATCH 请求
+  },
   del<T>(config: AxiosRequestConfig): Promise<T> {
     return request({ ...config, method: 'DELETE' }) // DELETE 请求
   }

+ 2 - 2
art-design-pro/src/utils/tabs.ts

@@ -8,9 +8,9 @@
  */
 export const TAB_CONFIG = {
   'tab-default': {
-    openTop: 106,
+    openTop: 70,
     closeTop: 60,
-    openHeight: 121,
+    openHeight: 85,
     closeHeight: 75
   },
   'tab-card': {

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

@@ -8,7 +8,7 @@
       </template>
 
       <el-row :gutter="16">
-        <el-col :xs="24" :md="8" v-for="stat in statsList" :key="stat.title">
+        <el-col :xs="24" :md="12" v-for="stat in statsList" :key="stat.title">
           <el-card :loading="isLoading">
             <div class="stat-card">
               <div class="stat-title">{{ stat.title }}</div>

+ 3 - 3
art-design-pro/src/views/index/index.vue

@@ -11,7 +11,7 @@
 
     <!-- 顶栏 -->
     <top-bar>
-      <work-tab v-if="showWorkTab"></work-tab>
+      <!-- <work-tab v-if="showWorkTab"></work-tab> -->
     </top-bar>
 
     <!-- 内容区域 -->
@@ -159,9 +159,9 @@
       :deep(.page-content) {
         position: relative;
         box-sizing: border-box;
-        padding: 20px;
+        padding: 0px;
         overflow: hidden;
-        background: var(--art-main-bg-color);
+        background: transparent;
         border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
       }
     }

+ 359 - 44
art-design-pro/src/views/inventory/index.vue

@@ -8,6 +8,22 @@
       </template>
 
       <div class="toolbar">
+        <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>
           新增记录
@@ -18,52 +34,96 @@
         </el-button>
       </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>
-
       <art-table
         :data="tableData"
         :loading="loading"
         :pagination="true"
         :total="total"
+        :index="true"
         v-model:current-page="currentPage"
         v-model:page-size="pageSize"
         @current-change="handlePageChange"
         @size-change="handleSizeChange"
       >
-        <el-table-column prop="date" label="日期" width="120" />
-        <el-table-column prop="partnerCompany" label="往来单位" width="150" />
-        <el-table-column prop="businessType" label="业务类型" width="100">
+        <el-table-column prop="planNo" label="计划号" min-width="120" />
+        <el-table-column prop="batchNo" label="出入库单号" min-width="140" />
+        <el-table-column prop="date" label="日期" min-width="120" />
+        <el-table-column prop="partnerCompany" label="往来单位" min-width="150" />
+        <el-table-column prop="businessType" label="业务类型" min-width="100">
           <template #default="{ row }">
             <el-tag :type="row.businessType === '入库' ? 'success' : 'warning'">
               {{ row.businessType }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="product" label="产品" width="120" />
-        <el-table-column prop="quantity" label="数量(吨)" width="120" />
-        <el-table-column prop="vehicleNo" label="车牌号" width="120" />
-        <el-table-column prop="planNo" label="计划号" width="120" />
-        <el-table-column label="操作" width="200" fixed="right">
+        <el-table-column prop="status" label="状态" min-width="100">
+          <template #default="{ row }">
+            <el-tag
+              :type="
+                row.status === '已完成'
+                  ? 'success'
+                  : row.status === '待入库' || row.status === '待出库'
+                    ? 'warning'
+                    : 'info'
+              "
+            >
+              {{ row.status || '待处理' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="product" label="产品" min-width="120" />
+        <el-table-column prop="vehicleNo" label="车牌号" min-width="120" />
+        <el-table-column prop="quantity" label="数量(吨)" min-width="120" />
+        <el-table-column prop="netWeight" label="净重(吨)" min-width="120">
+          <template #default="{ row }">
+            {{ row.netWeight !== undefined && row.netWeight !== null ? (row.netWeight / 1000).toFixed(2) : '--' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="grossWeight" label="毛重(吨)" min-width="120">
+          <template #default="{ row }">
+            {{ row.grossWeight !== undefined && row.grossWeight !== null ? (row.grossWeight / 1000).toFixed(2) : '--' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="grossWeightTime" label="毛重时间" min-width="160">
           <template #default="{ row }">
+            {{ row.grossWeightTime || '--' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="tareWeight" label="皮重(吨)" min-width="120">
+          <template #default="{ row }">
+            {{ row.tareWeight !== undefined && row.tareWeight !== null ? (row.tareWeight / 1000).toFixed(2) : '--' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="tareWeightTime" label="皮重时间" min-width="160">
+          <template #default="{ row }">
+            {{ row.tareWeightTime || '--' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="deduction" label="扣杂(吨)" min-width="120">
+          <template #default="{ row }">
+            {{ row.deduction !== undefined && row.deduction !== null ? (row.deduction / 1000).toFixed(2) : '--' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="remarks" label="备注" min-width="150" show-overflow-tooltip />
+        <el-table-column label="操作" width="360" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handleView(row)">
+              <el-icon><View /></el-icon>
+              查看
+            </el-button>
             <el-button link type="primary" @click="handleEdit(row)">
               <el-icon><Edit /></el-icon>
               编辑
             </el-button>
+            <el-button
+              link
+              type="success"
+              :disabled="row.hasWeighingSlip"
+              @click="handleGenerateWeighing(row)"
+            >
+              <el-icon><Plus /></el-icon>
+              {{ row.hasWeighingSlip ? '已生成地磅单' : '生成地磅单' }}
+            </el-button>
             <el-button link type="danger" @click="handleDelete(row.id)">
               <el-icon><Delete /></el-icon>
               删除
@@ -92,7 +152,22 @@
           />
         </el-form-item>
         <el-form-item label="往来单位" prop="partnerCompany">
-          <el-input v-model="formData.partnerCompany" placeholder="请输入往来单位" />
+          <el-select
+            v-model="formData.partnerCompany"
+            filterable
+            remote
+            :remote-method="loadCompanies"
+            placeholder="请选择往来单位"
+            style="width: 100%"
+            @focus="loadCompanies('')"
+          >
+            <el-option
+              v-for="item in companyOptions"
+              :key="item.id"
+              :label="item.name"
+              :value="item.name"
+            />
+          </el-select>
         </el-form-item>
         <el-form-item label="业务类型" prop="businessType">
           <el-radio-group v-model="formData.businessType">
@@ -101,7 +176,22 @@
           </el-radio-group>
         </el-form-item>
         <el-form-item label="产品" prop="product">
-          <el-input v-model="formData.product" placeholder="请输入产品名称" />
+          <el-select
+            v-model="formData.product"
+            filterable
+            remote
+            :remote-method="loadCargos"
+            placeholder="请选择产品"
+            style="width: 100%"
+            @focus="loadCargos('')"
+          >
+            <el-option
+              v-for="item in cargoOptions"
+              :key="item.id"
+              :label="item.name"
+              :value="item.name"
+            />
+          </el-select>
         </el-form-item>
         <el-form-item label="数量(吨)" prop="quantity">
           <el-input-number
@@ -126,19 +216,88 @@
         <el-button type="primary" :loading="saving" @click="handleSubmit">确定</el-button>
       </template>
     </el-dialog>
+
+    <!-- 关联数据对话框 -->
+    <el-dialog v-model="relatedModalOpen" title="查看关联数据" width="1200px">
+      <div v-loading="loadingRelated" class="dialog-content">
+        <!-- 计划 -->
+        <div v-if="relatedData.plan" class="data-item">
+          <div class="data-item-header">
+            <el-tag type="primary">计划</el-tag>
+          </div>
+          <el-descriptions :column="2" border style="margin-top: 12px;">
+            <el-descriptions-item label="计划号">{{ relatedData.plan.planNo }}</el-descriptions-item>
+            <el-descriptions-item label="业务类型">{{ relatedData.plan.businessType }}</el-descriptions-item>
+            <el-descriptions-item label="往来单位">{{ relatedData.plan.customerName }}</el-descriptions-item>
+            <el-descriptions-item label="产品">{{ relatedData.plan.product }}</el-descriptions-item>
+            <el-descriptions-item label="计划日期">{{ relatedData.plan.planDate }}</el-descriptions-item>
+            <el-descriptions-item label="起始日期">{{ relatedData.plan.startDate }}</el-descriptions-item>
+            <el-descriptions-item label="截止日期">{{ relatedData.plan.endDate }}</el-descriptions-item>
+            <el-descriptions-item label="执行状态">
+              <el-tag :type="relatedData.plan.executionStatus === '已入库' || relatedData.plan.executionStatus === '已出库' ? 'success' : 'warning'">
+                {{ relatedData.plan.executionStatus }}
+              </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="计划量(吨)">{{ relatedData.plan.plannedQuantity }}</el-descriptions-item>
+            <el-descriptions-item label="已执行量(吨)">
+              {{ ((relatedData.plan.executedQuantity || 0) / 1000).toFixed(2) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="备注" :span="2">{{ relatedData.plan.remarks || '-' }}</el-descriptions-item>
+          </el-descriptions>
+        </div>
+        <el-empty v-else-if="!relatedData.weighing" description="暂无关联的计划" />
+
+        <!-- 分割线 -->
+        <el-divider v-if="relatedData.plan && relatedData.weighing" />
+
+        <!-- 地磅单 -->
+        <div v-if="relatedData.weighing" class="data-item">
+          <div class="data-item-header">
+            <el-tag type="warning">地磅单</el-tag>
+          </div>
+          <el-descriptions :column="2" border style="margin-top: 12px;">
+            <el-descriptions-item label="出入库单号">{{ relatedData.weighing.batchNo }}</el-descriptions-item>
+            <el-descriptions-item label="车牌号">{{ relatedData.weighing.vehicleNo }}</el-descriptions-item>
+            <el-descriptions-item label="货物名称">{{ relatedData.weighing.cargoName }}</el-descriptions-item>
+            <el-descriptions-item label="业务类型">
+              <el-tag :type="relatedData.weighing.businessType === '入库' ? 'success' : 'warning'">
+                {{ relatedData.weighing.businessType }}
+              </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="供货单位">{{ relatedData.weighing.supplier }}</el-descriptions-item>
+            <el-descriptions-item label="收货单位">{{ relatedData.weighing.consignee }}</el-descriptions-item>
+            <el-descriptions-item label="毛重(kg)">{{ relatedData.weighing.grossWeight }}</el-descriptions-item>
+            <el-descriptions-item label="毛重时间">{{ relatedData.weighing.grossWeightTime }}</el-descriptions-item>
+            <el-descriptions-item label="皮重(kg)">{{ relatedData.weighing.tareWeight }}</el-descriptions-item>
+            <el-descriptions-item label="皮重时间">{{ relatedData.weighing.tareWeightTime }}</el-descriptions-item>
+            <el-descriptions-item label="扣杂(kg)">{{ relatedData.weighing.deduction || 0 }}</el-descriptions-item>
+            <el-descriptions-item label="净重(kg)">{{ relatedData.weighing.netWeight }}</el-descriptions-item>
+            <el-descriptions-item label="备注" :span="2">{{ relatedData.weighing.remarks || '-' }}</el-descriptions-item>
+          </el-descriptions>
+        </div>
+        <el-empty v-else-if="!relatedData.plan" description="暂无关联的地磅单" />
+      </div>
+      <template #footer>
+        <el-button @click="relatedModalOpen = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
   import { ref, reactive, onMounted } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
-  import { Plus, Document, Search, Edit, Delete } from '@element-plus/icons-vue'
+  import { Plus, Document, Search, Edit, Delete, View } from '@element-plus/icons-vue'
   import { inventoryApi, type InventoryRecord, type InventoryCreate } from '@/api/inventoryApi'
-  import { useRoute } from 'vue-router'
+  import { planApi, type Plan } from '@/api/plansApi'
+  import { weighingApi, type WeighingRecord } from '@/api/weighingApi'
+  import { companyApi, cargoApi, type Company, type Cargo } from '@/api/masterDataApi'
+  import { useRoute, useRouter } from 'vue-router'
   import dayjs from 'dayjs'
   import ArtTable from '@/components/Table/ArtTable.vue'
 
   const route = useRoute()
+  const router = useRouter()
 
   const loading = ref(false)
   const saving = ref(false)
@@ -150,27 +309,54 @@
 
   const modalOpen = ref(false)
   const editingRecord = ref<InventoryRecord | null>(null)
+  const companyOptions = ref<Company[]>([])
+  const cargoOptions = ref<Cargo[]>([])
+  const relatedModalOpen = ref(false)
+  const loadingRelated = ref(false)
+  const relatedData = ref<{ plan: Plan | null, weighing: WeighingRecord | null }>({ plan: null, weighing: null })
 
   const formRef = ref()
+  // 如果是从计划管理跳转过来的(有planNo参数),日期为空;否则使用当前日期
+  const initialDate = route.query.planNo ? '' : dayjs().format('YYYY-MM-DD')
+  const purchasePlanIdFromRoute = route.query.purchasePlanId ? Number(route.query.purchasePlanId) : undefined
   const formData = reactive<InventoryCreate>({
-    date: dayjs().format('YYYY-MM-DD'),
-    partnerCompany: '',
+    date: initialDate,
+    partnerCompany: (route.query.customerName as string) || '',
     businessType: '入库',
     product: '',
     quantity: 0,
     planNo: route.query.planNo as string || '',
     vehicleNo: '',
-    remarks: ''
+    remarks: '',
+    purchasePlanId: purchasePlanIdFromRoute
   })
 
   const formRules = {
     date: [{ required: true, message: '请选择日期', trigger: 'change' }],
-    partnerCompany: [{ required: true, message: '请输入往来单位', trigger: 'blur' }],
-    product: [{ required: true, message: '请输入产品', trigger: 'blur' }],
+    partnerCompany: [{ required: true, message: '请选择往来单位', trigger: 'change' }],
+    product: [{ required: true, message: '请选择产品', trigger: 'change' }],
     quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }],
     vehicleNo: [{ required: true, message: '请输入车牌号', trigger: 'blur' }]
   }
 
+  // 加载公司列表
+  const loadCompanies = async (keyword: string) => {
+    try {
+      companyOptions.value = await companyApi.list(keyword || undefined)
+    } catch (error) {
+      console.error('加载公司列表失败', error)
+    }
+  }
+
+  // 加载货物列表
+  const loadCargos = async (keyword: string) => {
+    try {
+      cargoOptions.value = await cargoApi.list(keyword || undefined)
+    } catch (error) {
+      console.error('加载货物列表失败', error)
+    }
+  }
+
   // 从路由参数填充表单
   if (route.query.product) {
     formData.product = route.query.product as string
@@ -178,13 +364,19 @@
   if (route.query.businessType) {
     formData.businessType = route.query.businessType as '入库' | '出库'
   }
+  if (route.query.customerName) {
+    formData.partnerCompany = route.query.customerName as string
+  }
 
   const loadData = async () => {
     loading.value = true
     try {
       const records = await inventoryApi.list({ q: searchKeyword.value || undefined })
-      tableData.value = records
       total.value = records.length
+      // 根据当前页和每页大小进行数据切片
+      const start = (currentPage.value - 1) * pageSize.value
+      const end = start + pageSize.value
+      tableData.value = records.slice(start, end)
     } catch (error) {
       ElMessage.error('加载数据失败')
     } finally {
@@ -210,19 +402,39 @@
 
   const handleAdd = () => {
     editingRecord.value = null
+    // 如果是从计划管理跳转过来的,日期为空;否则使用当前日期
+    const defaultDate = route.query.planNo ? '' : dayjs().format('YYYY-MM-DD')
+    const purchasePlanId = route.query.purchasePlanId ? Number(route.query.purchasePlanId) : undefined
     Object.assign(formData, {
-      date: dayjs().format('YYYY-MM-DD'),
-      partnerCompany: route.query.planNo ? '' : '',
+      date: defaultDate,
+      partnerCompany: (route.query.customerName as string) || '',
       businessType: (route.query.businessType as '入库' | '出库') || '入库',
       product: (route.query.product as string) || '',
       quantity: 0,
       planNo: (route.query.planNo as string) || '',
       vehicleNo: '',
-      remarks: ''
+      remarks: '',
+      purchasePlanId: purchasePlanId
     })
+    // 加载下拉框数据
+    loadCompanies('')
+    loadCargos('')
     modalOpen.value = true
   }
 
+  const handleView = async (record: InventoryRecord) => {
+    loadingRelated.value = true
+    try {
+      const data = await inventoryApi.getRelated(record.id)
+      relatedData.value = data
+      relatedModalOpen.value = true
+    } catch (error) {
+      ElMessage.error('加载关联数据失败')
+    } finally {
+      loadingRelated.value = false
+    }
+  }
+
   const handleEdit = (record: InventoryRecord) => {
     editingRecord.value = record
     Object.assign(formData, {
@@ -233,8 +445,12 @@
       quantity: record.quantity,
       planNo: record.planNo || '',
       vehicleNo: record.vehicleNo,
-      remarks: record.remarks || ''
+      remarks: record.remarks || '',
+      purchasePlanId: record.purchasePlanId
     })
+    // 加载下拉框数据
+    loadCompanies('')
+    loadCargos('')
     modalOpen.value = true
   }
 
@@ -252,6 +468,31 @@
       .catch(() => {})
   }
 
+  const handleGenerateWeighing = (record: InventoryRecord) => {
+    // 根据业务类型设置供货单位和收货单位
+    // 入库单:供货单位 = 往来单位,收货单位 = 安庆市山峡化工有限公司
+    // 出库单:供货单位 = 安庆市山峡化工有限公司,收货单位 = 往来单位
+    const supplier = record.businessType === '入库' ? record.partnerCompany : '安庆市山峡化工有限公司'
+    const consignee = record.businessType === '入库' ? '安庆市山峡化工有限公司' : record.partnerCompany
+    
+    // 跳转到地磅单界面,并传递出入库单数据
+    router.push({
+      path: '/weighing',
+      query: {
+        inboundOutboundId: record.id.toString(),
+        vehicleNo: record.vehicleNo,
+        cargoName: record.product,
+        businessType: record.businessType,
+        supplier: supplier,
+        consignee: consignee,
+        planNo: record.planNo || '',
+        purchasePlanId: record.purchasePlanId?.toString() || '',
+        batchNo: record.batchNo || '', // 传递出入库单号
+        quantity: record.quantity.toString() // 传递数量(吨)
+      }
+    })
+  }
+
   const handleSubmit = async () => {
     if (!formRef.value) return
 
@@ -259,17 +500,45 @@
       if (valid) {
         saving.value = true
         try {
+          // 清理数据:将空字符串转换为 undefined,移除 undefined 字段
+          const cleanData = (data: any) => {
+            const cleaned: any = {}
+            for (const key in data) {
+              const value = data[key]
+              // 如果是空字符串,跳过(不包含在请求中)
+              // 如果是 undefined 或 null,也跳过
+              if (value !== '' && value !== undefined && value !== null) {
+                cleaned[key] = value
+              }
+            }
+            return cleaned
+          }
+
           if (editingRecord.value) {
-            await inventoryApi.update(editingRecord.value.id, formData)
+            const updateData = cleanData(formData)
+            await inventoryApi.update(editingRecord.value.id, updateData)
             ElMessage.success('更新成功')
           } else {
-            await inventoryApi.create(formData)
+            // 创建时,必需字段不能为空
+            const createData: InventoryCreate = {
+              date: formData.date,
+              partnerCompany: formData.partnerCompany,
+              businessType: formData.businessType,
+              product: formData.product,
+              quantity: formData.quantity,
+              vehicleNo: formData.vehicleNo,
+              planNo: formData.planNo || undefined,
+              remarks: formData.remarks || undefined,
+              purchasePlanId: formData.purchasePlanId
+            }
+            await inventoryApi.create(createData)
             ElMessage.success('保存成功')
           }
           modalOpen.value = false
           loadData()
-        } catch (error) {
-          ElMessage.error('操作失败')
+        } catch (error: any) {
+          const errorMessage = error.response?.data?.detail || error.response?.data?.message || '操作失败'
+          ElMessage.error(errorMessage)
         } finally {
           saving.value = false
         }
@@ -333,15 +602,61 @@
     margin-bottom: 16px;
     display: flex;
     gap: 8px;
+    align-items: center;
   }
 
   .search-bar {
-    margin-bottom: 16px;
     width: 400px;
   }
 
   .card-header {
     font-weight: 600;
   }
+
+  :deep(.art-table) {
+    width: 100%;
+  }
+
+  :deep(.el-table) {
+    width: 100% !important;
+    table-layout: auto;
+  }
+
+  // 确保固定列正常工作
+  :deep(.el-table__fixed-right) {
+    right: 0 !important;
+  }
+
+  .data-item {
+    margin-bottom: 16px;
+  }
+
+  .data-item-header {
+    margin-bottom: 8px;
+  }
+
+  .dialog-content {
+    max-height: 60vh;
+    overflow-y: auto;
+    padding-right: 8px;
+  }
+
+  .dialog-content::-webkit-scrollbar {
+    width: 8px;
+  }
+
+  .dialog-content::-webkit-scrollbar-track {
+    background: #f1f1f1;
+    border-radius: 4px;
+  }
+
+  .dialog-content::-webkit-scrollbar-thumb {
+    background: #c1c1c1;
+    border-radius: 4px;
+  }
+
+  .dialog-content::-webkit-scrollbar-thumb:hover {
+    background: #a8a8a8;
+  }
 </style>
 

+ 97 - 6
art-design-pro/src/views/master-data/index.vue

@@ -11,6 +11,22 @@
         <!-- 公司管理 -->
         <el-tab-pane label="往来单位" name="company">
           <div class="toolbar">
+            <div class="search-bar">
+              <el-input
+                v-model="companySearchKeyword"
+                placeholder="搜索公司名称 / 备注"
+                clearable
+                @clear="handleCompanySearch"
+                @keyup.enter="handleCompanySearch"
+              >
+                <template #prefix>
+                  <el-icon><Search /></el-icon>
+                </template>
+                <template #append>
+                  <el-button @click="handleCompanySearch">搜索</el-button>
+                </template>
+              </el-input>
+            </div>
             <el-button type="primary" @click="handleCompanyAdd">
               <el-icon><Plus /></el-icon>
               新增公司
@@ -22,6 +38,7 @@
             :loading="companyLoading"
             :pagination="true"
             :total="companyTotal"
+            :index="true"
             v-model:current-page="companyPage"
             v-model:page-size="companyPageSize"
             @current-change="handleCompanyPageChange"
@@ -47,6 +64,22 @@
         <!-- 货物管理 -->
         <el-tab-pane label="货物" name="cargo">
           <div class="toolbar">
+            <div class="search-bar">
+              <el-input
+                v-model="cargoSearchKeyword"
+                placeholder="搜索货物名称 / 备注"
+                clearable
+                @clear="handleCargoSearch"
+                @keyup.enter="handleCargoSearch"
+              >
+                <template #prefix>
+                  <el-icon><Search /></el-icon>
+                </template>
+                <template #append>
+                  <el-button @click="handleCargoSearch">搜索</el-button>
+                </template>
+              </el-input>
+            </div>
             <el-button type="primary" @click="handleCargoAdd">
               <el-icon><Plus /></el-icon>
               新增货物
@@ -58,6 +91,7 @@
             :loading="cargoLoading"
             :pagination="true"
             :total="cargoTotal"
+            :index="true"
             v-model:current-page="cargoPage"
             v-model:page-size="cargoPageSize"
             @current-change="handleCargoPageChange"
@@ -141,7 +175,7 @@
 <script setup lang="ts">
   import { ref, reactive, onMounted } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
-  import { Plus, Edit, Delete } from '@element-plus/icons-vue'
+  import { Plus, Edit, Delete, Search } from '@element-plus/icons-vue'
   import {
     companyApi,
     cargoApi,
@@ -157,6 +191,7 @@
   // 公司相关
   const companyLoading = ref(false)
   const companySaving = ref(false)
+  const companyAllData = ref<Company[]>([])
   const companyData = ref<Company[]>([])
   const companyTotal = ref(0)
   const companyPage = ref(1)
@@ -171,10 +206,12 @@
   const companyFormRules = {
     name: [{ required: true, message: '请输入公司名称', trigger: 'blur' }]
   }
+  const companySearchKeyword = ref('')
 
   // 货物相关
   const cargoLoading = ref(false)
   const cargoSaving = ref(false)
+  const cargoAllData = ref<Cargo[]>([])
   const cargoData = ref<Cargo[]>([])
   const cargoTotal = ref(0)
   const cargoPage = ref(1)
@@ -189,13 +226,14 @@
   const cargoFormRules = {
     name: [{ required: true, message: '请输入货物名称', trigger: 'blur' }]
   }
+  const cargoSearchKeyword = ref('')
 
   const loadCompanies = async () => {
     companyLoading.value = true
     try {
       const data = await companyApi.list()
-      companyData.value = data
-      companyTotal.value = data.length
+      companyAllData.value = data
+      filterCompanyData()
     } catch (error) {
       ElMessage.error('加载数据失败')
     } finally {
@@ -207,8 +245,8 @@
     cargoLoading.value = true
     try {
       const data = await cargoApi.list()
-      cargoData.value = data
-      cargoTotal.value = data.length
+      cargoAllData.value = data
+      filterCargoData()
     } catch (error) {
       ElMessage.error('加载数据失败')
     } finally {
@@ -216,10 +254,48 @@
     }
   }
 
+  const filterCompanyData = () => {
+    if (!companySearchKeyword.value.trim()) {
+      companyData.value = companyAllData.value
+    } else {
+      const keyword = companySearchKeyword.value.trim().toLowerCase()
+      companyData.value = companyAllData.value.filter(
+        (item) =>
+          item.name.toLowerCase().includes(keyword) ||
+          (item.remarks && item.remarks.toLowerCase().includes(keyword))
+      )
+    }
+    companyTotal.value = companyData.value.length
+    companyPage.value = 1
+  }
+
+  const filterCargoData = () => {
+    if (!cargoSearchKeyword.value.trim()) {
+      cargoData.value = cargoAllData.value
+    } else {
+      const keyword = cargoSearchKeyword.value.trim().toLowerCase()
+      cargoData.value = cargoAllData.value.filter(
+        (item) =>
+          item.name.toLowerCase().includes(keyword) ||
+          (item.remarks && item.remarks.toLowerCase().includes(keyword))
+      )
+    }
+    cargoTotal.value = cargoData.value.length
+    cargoPage.value = 1
+  }
+
+  const handleCompanySearch = () => {
+    filterCompanyData()
+  }
+
+  const handleCargoSearch = () => {
+    filterCargoData()
+  }
+
   const handleTabChange = (tab: string) => {
     if (tab === 'company') {
       loadCompanies()
-    } else if (tab === 'company') {
+    } else if (tab === 'cargo') {
       loadCargos()
     }
   }
@@ -377,10 +453,25 @@
     margin-bottom: 16px;
     display: flex;
     gap: 8px;
+    align-items: center;
+  }
+
+  .search-bar {
+    flex: 1;
+    max-width: 400px;
   }
 
   .card-header {
     font-weight: 600;
   }
+
+  :deep(.art-table) {
+    width: 100%;
+  }
+
+  :deep(.el-table) {
+    width: 100% !important;
+    table-layout: auto;
+  }
 </style>
 

+ 256 - 76
art-design-pro/src/views/plans/index.vue

@@ -9,11 +9,28 @@
 
       <!-- 工具栏 -->
       <div class="toolbar">
+        <!-- 搜索栏 -->
+        <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
+        <!-- <el-upload
           :show-file-list="false"
           accept=".xlsx,.xls,.csv"
           :before-upload="handleImport"
@@ -22,66 +39,51 @@
             <el-icon><Upload /></el-icon>
             导入 Excel
           </el-button>
-        </el-upload>
+        </el-upload> -->
         <el-button @click="handleExport">
           <el-icon><Document /></el-icon>
           导出 Excel
         </el-button>
       </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>
-
       <!-- 表格 -->
       <art-table
         :data="tableData"
         :loading="loading"
         :pagination="true"
         :total="total"
+        :index="true"
         v-model:current-page="currentPage"
         v-model:page-size="pageSize"
         @current-change="handlePageChange"
         @size-change="handleSizeChange"
       >
-        <el-table-column prop="planNo" label="计划号" width="120" />
-        <el-table-column prop="customerName" label="往来单位" width="150" />
-        <el-table-column prop="startDate" label="起始日期" width="120" />
-        <el-table-column prop="endDate" label="截止日期" width="120" />
-        <el-table-column prop="product" label="产品" width="120" />
-        <el-table-column label="计划量 / 已执行 (吨)" width="170">
+        <el-table-column prop="planNo" label="计划号" min-width="120" />
+        <el-table-column prop="businessType" label="业务类型" min-width="100">
+          <template #default="{ row }">
+            <el-tag :type="row.businessType === '入库' ? 'success' : 'warning'">
+              {{ row.businessType }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="customerName" label="往来单位" min-width="150" />
+        <el-table-column prop="startDate" label="起始日期" min-width="120" />
+        <el-table-column prop="endDate" label="截止日期" min-width="120" />
+        <el-table-column prop="product" label="产品" min-width="120" />
+        <el-table-column label="计划量 / 已执行 (吨)" min-width="170">
           <template #default="{ row }">
             {{ row.plannedQuantity }} / {{ ((row.executedQuantity || 0) / 1000).toFixed(2) }}
           </template>
         </el-table-column>
-        <el-table-column prop="executionStatus" label="执行状态" width="120">
+        <el-table-column prop="executionStatus" label="执行状态" min-width="120">
           <template #default="{ row }">
-            <el-tag
-              :type="
-                row.executionStatus === '已入库' || row.executionStatus === '已出库'
-                  ? 'success'
-                  : 'info'
-              "
-            >
-              {{ row.executionStatus }}
+            <el-tag :type="getExecutionStatusType(getCorrectExecutionStatus(row))">
+              {{ getCorrectExecutionStatus(row) }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="300" fixed="right">
+        <el-table-column prop="remarks" label="备注" min-width="150" show-overflow-tooltip />
+        <el-table-column label="操作" width="400" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleView(row)">
               <el-icon><View /></el-icon>
@@ -92,6 +94,7 @@
               编辑
             </el-button>
             <el-button link type="primary" @click="handleGenerateInventory(row)">
+              <el-icon><DocumentAdd /></el-icon>
               生成出入库单
             </el-button>
             <el-button link type="danger" @click="handleDelete(row.id)">
@@ -229,45 +232,83 @@
       </template>
     </el-dialog>
 
-    <!-- 详情对话框 -->
-    <el-dialog v-model="detailModalOpen" title="计划详情" width="700px">
-      <el-descriptions v-if="viewingPlan" :column="2" border>
-        <el-descriptions-item label="计划号">{{ viewingPlan.planNo }}</el-descriptions-item>
-        <el-descriptions-item label="业务类型">{{ viewingPlan.businessType }}</el-descriptions-item>
-        <el-descriptions-item label="往来单位">{{ viewingPlan.customerName }}</el-descriptions-item>
-        <el-descriptions-item label="产品">{{ viewingPlan.product }}</el-descriptions-item>
-        <el-descriptions-item label="计划日期">{{ viewingPlan.planDate }}</el-descriptions-item>
-        <el-descriptions-item label="起始日期">{{ viewingPlan.startDate }}</el-descriptions-item>
-        <el-descriptions-item label="截止日期">{{ viewingPlan.endDate }}</el-descriptions-item>
-        <el-descriptions-item label="执行状态">
-          <el-tag
-            :type="
-              viewingPlan.executionStatus === '已入库' ||
-              viewingPlan.executionStatus === '已出库'
-                ? 'success'
-                : 'info'
-            "
-          >
-            {{ viewingPlan.executionStatus }}
-          </el-tag>
-        </el-descriptions-item>
-        <el-descriptions-item label="计划量(吨)">{{ viewingPlan.plannedQuantity }}</el-descriptions-item>
-        <el-descriptions-item label="已执行量(吨)">
-          {{ ((viewingPlan.executedQuantity || 0) / 1000).toFixed(2) }}
-        </el-descriptions-item>
-        <el-descriptions-item label="备注" :span="2">{{ viewingPlan.remarks || '-' }}</el-descriptions-item>
-      </el-descriptions>
+    <!-- 关联数据对话框 -->
+    <el-dialog v-model="relatedModalOpen" title="查看关联数据" width="1200px">
+      <div v-loading="loadingRelated" class="dialog-content">
+        <div v-if="mergedData.length === 0" style="text-align: center; padding: 40px;">
+          <el-empty description="暂无关联数据" />
+        </div>
+        <div v-else>
+          <div v-for="(item, index) in mergedData" :key="item.id + '-' + item.type">
+            <!-- 出入库单 -->
+            <div v-if="item.type === 'inventory'" class="data-item">
+              <div class="data-item-header">
+                <el-tag type="info">出入库单</el-tag>
+              </div>
+              <el-descriptions :column="2" border style="margin-top: 12px;">
+                <el-descriptions-item label="出入库单号">{{ item.batchNo || '--' }}</el-descriptions-item>
+                <el-descriptions-item label="日期">{{ item.date }}</el-descriptions-item>
+                <el-descriptions-item label="往来单位">{{ item.partnerCompany }}</el-descriptions-item>
+                <el-descriptions-item label="业务类型">
+                  <el-tag :type="item.businessType === '入库' ? 'success' : 'warning'">
+                    {{ item.businessType }}
+                  </el-tag>
+                </el-descriptions-item>
+                <el-descriptions-item label="产品">{{ item.product }}</el-descriptions-item>
+                <el-descriptions-item label="车牌号">{{ item.vehicleNo }}</el-descriptions-item>
+                <el-descriptions-item label="数量(吨)">{{ item.quantity }}</el-descriptions-item>
+                <el-descriptions-item label="状态">
+                  <el-tag
+                    :type="
+                      item.status === '已完成'
+                        ? 'success'
+                        : item.status === '待入库' || item.status === '待出库'
+                          ? 'warning'
+                          : 'info'
+                    "
+                  >
+                    {{ item.status || '待处理' }}
+                  </el-tag>
+                </el-descriptions-item>
+              </el-descriptions>
+            </div>
+            <!-- 地磅单 -->
+            <div v-else-if="item.type === 'weighing'" class="data-item">
+              <div class="data-item-header">
+                <el-tag type="warning">地磅单</el-tag>
+              </div>
+              <el-descriptions :column="2" border style="margin-top: 12px;">
+                <el-descriptions-item label="出入库单号">{{ item.batchNo || '--' }}</el-descriptions-item>
+                <el-descriptions-item label="车牌号">{{ item.vehicleNo }}</el-descriptions-item>
+                <el-descriptions-item label="货物名称">{{ item.cargoName }}</el-descriptions-item>
+                <el-descriptions-item label="业务类型">
+                  <el-tag :type="item.businessType === '入库' ? 'success' : 'warning'">
+                    {{ item.businessType }}
+                  </el-tag>
+                </el-descriptions-item>
+                <el-descriptions-item label="毛重(kg)">{{ item.grossWeight }}</el-descriptions-item>
+                <el-descriptions-item label="皮重(kg)">{{ item.tareWeight }}</el-descriptions-item>
+                <el-descriptions-item label="净重(kg)">{{ item.netWeight }}</el-descriptions-item>
+                <el-descriptions-item label="毛重时间">{{ item.grossWeightTime }}</el-descriptions-item>
+                <el-descriptions-item label="皮重时间">{{ item.tareWeightTime }}</el-descriptions-item>
+              </el-descriptions>
+            </div>
+            <!-- 分割线 -->
+            <el-divider v-if="index < mergedData.length - 1" />
+          </div>
+        </div>
+      </div>
       <template #footer>
-        <el-button @click="detailModalOpen = false">关闭</el-button>
+        <el-button @click="relatedModalOpen = false">关闭</el-button>
       </template>
     </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-  import { ref, reactive, onMounted } from 'vue'
+  import { ref, reactive, computed, onMounted } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
-  import { Plus, Upload, Document, Search, View, Edit, Delete } from '@element-plus/icons-vue'
+  import { Plus, Upload, Document, Search, View, Edit, Delete, DocumentAdd } from '@element-plus/icons-vue'
   import { planApi, type Plan, type PlanCreate } from '@/api/plansApi'
   import { companyApi, cargoApi, type Company, type Cargo } from '@/api/masterDataApi'
   import { useRouter } from 'vue-router'
@@ -285,8 +326,6 @@
   const searchKeyword = ref('')
 
   const modalOpen = ref(false)
-  const detailModalOpen = ref(false)
-  const viewingPlan = ref<Plan | null>(null)
   const editingPlan = ref<Plan | null>(null)
   const companyOptions = ref<Company[]>([])
   const cargoOptions = ref<Cargo[]>([])
@@ -312,13 +351,60 @@
     plannedQuantity: [{ required: true, message: '请输入计划量', trigger: 'blur' }]
   }
 
+  // 根据业务类型获取正确的执行状态
+  const getCorrectExecutionStatus = (row: Plan): string => {
+    // 如果执行状态为空或不符合业务类型,根据业务类型返回正确的状态
+    if (!row.executionStatus) {
+      return row.businessType === '入库' ? '待入库' : '待出库'
+    }
+    // 如果执行状态与业务类型不匹配,进行修正
+    if (row.businessType === '入库') {
+      // 入库业务:待入库、入库中、已入库
+      if (row.executionStatus === '待出库' || row.executionStatus === '出库中' || row.executionStatus === '已出库') {
+        // 如果状态是出库相关的,根据执行量判断
+        if ((row.executedQuantity || 0) > 0) {
+          return '入库中'
+        }
+        return '待入库'
+      }
+    } else if (row.businessType === '出库') {
+      // 出库业务:待出库、出库中、已出库
+      if (row.executionStatus === '待入库' || row.executionStatus === '入库中' || row.executionStatus === '已入库') {
+        // 如果状态是入库相关的,根据执行量判断
+        if ((row.executedQuantity || 0) > 0) {
+          return '出库中'
+        }
+        return '待出库'
+      }
+    }
+    return row.executionStatus
+  }
+
+  // 根据执行状态返回对应的tag类型
+  const getExecutionStatusType = (status: string | undefined): string => {
+    if (!status) return 'info'
+    if (status === '已入库' || status === '已出库') {
+      return 'success' // 绿色 - 已完成
+    }
+    if (status === '入库中' || status === '出库中') {
+      return 'info' // 蓝色 - 进行中
+    }
+    if (status === '待入库' || status === '待出库') {
+      return 'warning' // 橙色 - 待处理
+    }
+    return 'info' // 默认灰色
+  }
+
   // 加载数据
   const loadData = async () => {
     loading.value = true
     try {
       const plans = await planApi.list({ q: searchKeyword.value || undefined })
-      tableData.value = plans
       total.value = plans.length
+      // 根据当前页和每页大小进行数据切片
+      const start = (currentPage.value - 1) * pageSize.value
+      const end = start + pageSize.value
+      tableData.value = plans.slice(start, end)
     } catch (error) {
       ElMessage.error('加载数据失败')
     } finally {
@@ -400,10 +486,56 @@
     modalOpen.value = true
   }
 
-  // 查看详情
-  const handleView = (record: Plan) => {
-    viewingPlan.value = record
-    detailModalOpen.value = true
+  // 查看关联数据
+  const relatedData = ref<{ inventories: any[], weighings: any[] }>({ inventories: [], weighings: [] })
+  const relatedModalOpen = ref(false)
+  const loadingRelated = ref(false)
+
+  // 合并并排序数据
+  const mergedData = computed(() => {
+    const items: any[] = []
+    
+    // 添加出入库单,标记类型
+    relatedData.value.inventories.forEach((inv: any) => {
+      items.push({
+        ...inv,
+        type: 'inventory',
+        sortTime: inv.createdAt || inv.date || ''
+      })
+    })
+    
+    // 添加地磅单,标记类型
+    relatedData.value.weighings.forEach((w: any) => {
+      items.push({
+        ...w,
+        type: 'weighing',
+        sortTime: w.grossWeightTime || w.createdAt || ''
+      })
+    })
+    
+    // 按时间倒序排列(空时间排在最后)
+    return items.sort((a, b) => {
+      const timeA = a.sortTime ? new Date(a.sortTime).getTime() : 0
+      const timeB = b.sortTime ? new Date(b.sortTime).getTime() : 0
+      // 如果时间都为0(空),保持原顺序;否则按时间倒序
+      if (timeA === 0 && timeB === 0) return 0
+      if (timeA === 0) return 1
+      if (timeB === 0) return -1
+      return timeB - timeA
+    })
+  })
+
+  const handleView = async (record: Plan) => {
+    loadingRelated.value = true
+    try {
+      const data = await planApi.getRelated(record.id)
+      relatedData.value = data
+      relatedModalOpen.value = true
+    } catch (error) {
+      ElMessage.error('加载关联数据失败')
+    } finally {
+      loadingRelated.value = false
+    }
   }
 
   // 删除
@@ -426,7 +558,9 @@
     router.push(
       `/inventory?planNo=${encodeURIComponent(record.planNo || '')}&product=${encodeURIComponent(
         record.product || ''
-      )}&businessType=${encodeURIComponent(record.businessType || '')}`
+      )}&businessType=${encodeURIComponent(record.businessType || '')}&customerName=${encodeURIComponent(
+        record.customerName || ''
+      )}&purchasePlanId=${record.id}`
     )
     ElMessage.success('已跳转到出入库页面,请填写详细信息')
   }
@@ -539,15 +673,61 @@
     margin-bottom: 16px;
     display: flex;
     gap: 8px;
+    align-items: center;
   }
 
   .search-bar {
-    margin-bottom: 16px;
     width: 400px;
   }
 
   .card-header {
     font-weight: 600;
   }
+
+  :deep(.art-table) {
+    width: 100%;
+  }
+
+  :deep(.el-table) {
+    width: 100% !important;
+    table-layout: auto;
+  }
+
+  // 确保固定列正常工作
+  :deep(.el-table__fixed-right) {
+    right: 0 !important;
+  }
+
+  .data-item {
+    margin-bottom: 16px;
+  }
+
+  .data-item-header {
+    margin-bottom: 8px;
+  }
+
+  .dialog-content {
+    max-height: 60vh;
+    overflow-y: auto;
+    padding-right: 8px;
+  }
+
+  .dialog-content::-webkit-scrollbar {
+    width: 8px;
+  }
+
+  .dialog-content::-webkit-scrollbar-track {
+    background: #f1f1f1;
+    border-radius: 4px;
+  }
+
+  .dialog-content::-webkit-scrollbar-thumb {
+    background: #c1c1c1;
+    border-radius: 4px;
+  }
+
+  .dialog-content::-webkit-scrollbar-thumb:hover {
+    background: #a8a8a8;
+  }
 </style>
 

+ 28 - 7
art-design-pro/src/views/user/UserManagement.vue

@@ -15,24 +15,31 @@
       </div>
 
       <art-table
-        :data="tableData"
+        :data="paginatedData"
         :loading="loading"
         :pagination="true"
         :total="total"
+        :current-page="currentPage"
+        :page-size="pageSize"
         v-model:current-page="currentPage"
         v-model:page-size="pageSize"
         @current-change="handlePageChange"
         @size-change="handleSizeChange"
       >
-        <el-table-column prop="username" label="用户名" width="150" />
-        <el-table-column prop="role" label="角色" width="120">
+        <el-table-column label="序号" width="80" align="center" fixed="left">
+          <template #default="{ $index }">
+            {{ (currentPage - 1) * pageSize + $index + 1 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="username" label="用户名" min-width="150" />
+        <el-table-column prop="role" label="角色" min-width="120">
           <template #default="{ row }">
             <el-tag :type="row.role === 'admin' ? 'danger' : 'primary'">
               {{ row.role === 'admin' ? '管理员' : '操作员' }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="createdAt" label="创建时间" width="180" />
+        <el-table-column prop="createdAt" label="创建时间" min-width="180" />
         <el-table-column label="操作" width="200" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleEdit(row)">
@@ -94,7 +101,7 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, reactive, onMounted } from 'vue'
+  import { ref, reactive, onMounted, computed } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
   import { Plus, Edit, Delete } from '@element-plus/icons-vue'
   import { userApi, type User, type UserCreate } from '@/api/usersApi'
@@ -107,6 +114,13 @@
   const currentPage = ref(1)
   const pageSize = ref(10)
 
+  // 分页后的数据
+  const paginatedData = computed(() => {
+    const start = (currentPage.value - 1) * pageSize.value
+    const end = start + pageSize.value
+    return tableData.value.slice(start, end)
+  })
+
   const modalOpen = ref(false)
   const editingUser = ref<User | null>(null)
 
@@ -151,13 +165,11 @@
 
   const handlePageChange = (page: number) => {
     currentPage.value = page
-    loadData()
   }
 
   const handleSizeChange = (size: number) => {
     pageSize.value = size
     currentPage.value = 1
-    loadData()
   }
 
   const handleAdd = () => {
@@ -250,5 +262,14 @@
   .card-header {
     font-weight: 600;
   }
+
+  :deep(.art-table) {
+    width: 100%;
+  }
+
+  :deep(.el-table) {
+    width: 100% !important;
+    table-layout: auto;
+  }
 </style>
 

+ 460 - 77
art-design-pro/src/views/weighing/index.vue

@@ -8,33 +8,11 @@
       </template>
 
       <div class="toolbar">
-        <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>
-      </div>
-
-      <!-- 搜索和日期筛选 -->
-      <div class="search-bar">
-        <el-date-picker
-          v-model="dateRange"
-          type="daterange"
-          range-separator="至"
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
-          format="YYYY-MM-DD"
-          value-format="YYYY-MM-DD"
-          @change="handleSearch"
-        />
         <el-input
           v-model="searchKeyword"
           placeholder="搜索车牌号/货物名称"
           clearable
-          style="width: 300px; margin-left: 16px"
+          style="width: 300px"
           @clear="handleSearch"
           @keyup.enter="handleSearch"
         >
@@ -43,33 +21,66 @@
           </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>
       </div>
 
       <art-table
-        :data="tableData"
+        :data="paginatedData"
         :loading="loading"
         :pagination="true"
         :total="total"
+        :index="true"
         v-model:current-page="currentPage"
         v-model:page-size="pageSize"
         @current-change="handlePageChange"
         @size-change="handleSizeChange"
       >
-        <el-table-column prop="vehicleNo" label="车牌号" width="120" />
-        <el-table-column prop="cargoName" label="货物名称" width="150" />
-        <el-table-column prop="businessType" label="业务类型" width="100">
+        <el-table-column prop="grossWeightTime" label="日期" min-width="120">
+          <template #default="{ row }">
+            {{ row.grossWeightTime ? row.grossWeightTime.split(' ')[0] : (row.createdAt ? row.createdAt.split(' ')[0] : '') }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="planNo" label="计划单号" min-width="120" />
+        <el-table-column prop="batchNo" label="出入库单号" min-width="140" />
+        <el-table-column prop="vehicleNo" label="车牌号" min-width="120" />
+        <el-table-column prop="cargoName" label="货物名称" min-width="150" />
+        <el-table-column prop="businessType" label="业务类型" min-width="100">
           <template #default="{ row }">
             <el-tag :type="row.businessType === '入库' ? 'success' : 'warning'">
               {{ row.businessType }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="grossWeight" label="毛重(kg)" width="120" />
-        <el-table-column prop="tareWeight" label="皮重(kg)" width="120" />
-        <el-table-column prop="netWeight" label="净重(kg)" width="120" />
-        <el-table-column prop="grossWeightTime" label="毛重时间" width="160" />
-        <el-table-column label="操作" width="200" fixed="right">
+        <el-table-column prop="supplier" label="供货单位" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="consignee" label="收货单位" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="grossWeight" label="毛重(kg)" min-width="120" />
+        <el-table-column prop="grossWeightTime" label="毛重时间" min-width="160" />
+        <el-table-column prop="tareWeight" label="皮重(kg)" min-width="120" />
+        <el-table-column prop="tareWeightTime" label="皮重时间" min-width="160" />
+        <el-table-column prop="deduction" label="扣杂(kg)" min-width="120">
+          <template #default="{ row }">
+            {{ row.deduction || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="netWeight" label="净重(kg)" min-width="120" />
+        <el-table-column prop="currentQuantity" label="本次量(吨)" min-width="120">
           <template #default="{ row }">
+            {{ row.currentQuantity !== undefined && row.currentQuantity !== null ? (row.currentQuantity / 1000).toFixed(2) : '--' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="280" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handleView(row)">
+              <el-icon><View /></el-icon>
+              查看
+            </el-button>
             <el-button link type="primary" @click="handleEdit(row)">
               <el-icon><Edit /></el-icon>
               编辑
@@ -80,6 +91,7 @@
               type="warning"
               @click="handleVoid(row.id)"
             >
+              <el-icon><Close /></el-icon>
               作废
             </el-button>
             <el-button link type="danger" @click="handleDelete(row.id)">
@@ -107,7 +119,19 @@
           </el-col>
           <el-col :span="12">
             <el-form-item label="货物名称" prop="cargoName">
-              <el-input v-model="formData.cargoName" placeholder="请输入货物名称" />
+              <el-select
+                v-model="formData.cargoName"
+                placeholder="请选择货物名称"
+                filterable
+                style="width: 100%"
+              >
+                <el-option
+                  v-for="cargo in cargos"
+                  :key="cargo.id"
+                  :label="cargo.name"
+                  :value="cargo.name"
+                />
+              </el-select>
             </el-form-item>
           </el-col>
         </el-row>
@@ -126,15 +150,51 @@
             </el-form-item>
           </el-col>
         </el-row>
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="出入库单号" prop="batchNo">
+              <el-input v-model="formData.batchNo" placeholder="出入库单号" readonly />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="本次量(吨)">
+              <el-input :value="currentQuantityInTons" placeholder="本次量(吨)" readonly />
+            </el-form-item>
+          </el-col>
+        </el-row>
         <el-row :gutter="16">
           <el-col :span="12">
             <el-form-item label="供货单位" prop="supplier">
-              <el-input v-model="formData.supplier" placeholder="请输入供货单位" />
+              <el-select
+                v-model="formData.supplier"
+                placeholder="请选择供货单位"
+                filterable
+                style="width: 100%"
+              >
+                <el-option
+                  v-for="company in companies"
+                  :key="company.id"
+                  :label="company.name"
+                  :value="company.name"
+                />
+              </el-select>
             </el-form-item>
           </el-col>
           <el-col :span="12">
             <el-form-item label="收货单位" prop="consignee">
-              <el-input v-model="formData.consignee" placeholder="请输入收货单位" />
+              <el-select
+                v-model="formData.consignee"
+                placeholder="请选择收货单位"
+                filterable
+                style="width: 100%"
+              >
+                <el-option
+                  v-for="company in companies"
+                  :key="company.id"
+                  :label="company.name"
+                  :value="company.name"
+                />
+              </el-select>
             </el-form-item>
           </el-col>
         </el-row>
@@ -145,8 +205,10 @@
                 v-model="formData.grossWeight"
                 :min="0"
                 :precision="2"
+                :controls="false"
+                placeholder="请输入毛重"
                 style="width: 100%"
-                @change="calculateNetWeight"
+                @change="handleGrossWeightChange"
               />
             </el-form-item>
           </el-col>
@@ -170,8 +232,10 @@
                 v-model="formData.tareWeight"
                 :min="0"
                 :precision="2"
+                :controls="false"
+                placeholder="请输入皮重"
                 style="width: 100%"
-                @change="calculateNetWeight"
+                @change="handleTareWeightChange"
               />
             </el-form-item>
           </el-col>
@@ -195,6 +259,8 @@
                 v-model="formData.deduction"
                 :min="0"
                 :precision="2"
+                :controls="false"
+                placeholder="请输入扣杂"
                 style="width: 100%"
                 @change="calculateNetWeight"
               />
@@ -202,7 +268,10 @@
           </el-col>
           <el-col :span="12">
             <el-form-item label="净重(kg)">
-              <el-input :value="netWeight.toFixed(2)" disabled />
+              <el-input 
+                :value="formData.grossWeight > 0 && formData.tareWeight > 0 ? netWeight.toFixed(2) : '--'" 
+                disabled 
+              />
               <el-alert
                 v-if="isNetInvalid"
                 type="error"
@@ -225,28 +294,111 @@
         </el-button>
       </template>
     </el-dialog>
+
+    <!-- 关联数据对话框 -->
+    <el-dialog v-model="relatedModalOpen" title="查看关联数据" width="1200px">
+      <div v-loading="loadingRelated" class="dialog-content">
+        <!-- 计划 -->
+        <div v-if="relatedData.plan" class="data-item">
+          <div class="data-item-header">
+            <el-tag type="primary">计划</el-tag>
+          </div>
+          <el-descriptions :column="2" border style="margin-top: 12px;">
+            <el-descriptions-item label="计划号">{{ relatedData.plan.planNo }}</el-descriptions-item>
+            <el-descriptions-item label="业务类型">{{ relatedData.plan.businessType }}</el-descriptions-item>
+            <el-descriptions-item label="往来单位">{{ relatedData.plan.customerName }}</el-descriptions-item>
+            <el-descriptions-item label="产品">{{ relatedData.plan.product }}</el-descriptions-item>
+            <el-descriptions-item label="计划日期">{{ relatedData.plan.planDate }}</el-descriptions-item>
+            <el-descriptions-item label="起始日期">{{ relatedData.plan.startDate }}</el-descriptions-item>
+            <el-descriptions-item label="截止日期">{{ relatedData.plan.endDate }}</el-descriptions-item>
+            <el-descriptions-item label="执行状态">
+              <el-tag :type="relatedData.plan.executionStatus === '已入库' || relatedData.plan.executionStatus === '已出库' ? 'success' : 'warning'">
+                {{ relatedData.plan.executionStatus }}
+              </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="计划量(吨)">{{ relatedData.plan.plannedQuantity }}</el-descriptions-item>
+            <el-descriptions-item label="已执行量(吨)">
+              {{ ((relatedData.plan.executedQuantity || 0) / 1000).toFixed(2) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="备注" :span="2">{{ relatedData.plan.remarks || '-' }}</el-descriptions-item>
+          </el-descriptions>
+        </div>
+        <el-empty v-else-if="!relatedData.inventory" description="暂无关联的计划" />
+
+        <!-- 分割线 -->
+        <el-divider v-if="relatedData.plan && relatedData.inventory" />
+
+        <!-- 出入库单 -->
+        <div v-if="relatedData.inventory" class="data-item">
+          <div class="data-item-header">
+            <el-tag type="info">出入库单</el-tag>
+          </div>
+          <el-descriptions :column="2" border style="margin-top: 12px;">
+            <el-descriptions-item label="出入库单号">{{ relatedData.inventory.batchNo }}</el-descriptions-item>
+            <el-descriptions-item label="日期">{{ relatedData.inventory.date }}</el-descriptions-item>
+            <el-descriptions-item label="往来单位">{{ relatedData.inventory.partnerCompany }}</el-descriptions-item>
+            <el-descriptions-item label="业务类型">
+              <el-tag :type="relatedData.inventory.businessType === '入库' ? 'success' : 'warning'">
+                {{ relatedData.inventory.businessType }}
+              </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="产品">{{ relatedData.inventory.product }}</el-descriptions-item>
+            <el-descriptions-item label="车牌号">{{ relatedData.inventory.vehicleNo }}</el-descriptions-item>
+            <el-descriptions-item label="数量(吨)">{{ relatedData.inventory.quantity }}</el-descriptions-item>
+            <el-descriptions-item label="状态">
+              <el-tag
+                :type="
+                  relatedData.inventory.status === '已完成'
+                    ? 'success'
+                    : relatedData.inventory.status === '待入库' || relatedData.inventory.status === '待出库'
+                      ? 'warning'
+                      : 'info'
+                "
+              >
+                {{ relatedData.inventory.status || '待处理' }}
+              </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="备注" :span="2">{{ relatedData.inventory.remarks || '-' }}</el-descriptions-item>
+          </el-descriptions>
+        </div>
+        <el-empty v-else-if="!relatedData.plan" description="暂无关联的出入库单" />
+      </div>
+      <template #footer>
+        <el-button @click="relatedModalOpen = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
   import { ref, reactive, computed, onMounted } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
-  import { Plus, Document, Search, Edit, Delete } from '@element-plus/icons-vue'
+  import { Plus, Document, Search, Edit, Delete, Close, View } from '@element-plus/icons-vue'
   import { weighingApi, type WeighingRecord, type WeighingCreate } from '@/api/weighingApi'
+  import { companyApi, cargoApi, type Company, type Cargo } from '@/api/masterDataApi'
+  import { planApi, type Plan } from '@/api/plansApi'
+  import { inventoryApi, type InventoryRecord } from '@/api/inventoryApi'
+  import { useRoute } from 'vue-router'
   import dayjs from 'dayjs'
   import ArtTable from '@/components/Table/ArtTable.vue'
 
   const loading = ref(false)
   const saving = ref(false)
   const tableData = ref<WeighingRecord[]>([])
+  const allData = ref<WeighingRecord[]>([])
   const total = ref(0)
   const currentPage = ref(1)
   const pageSize = ref(10)
   const searchKeyword = ref('')
-  const dateRange = ref<[string, string] | null>(null)
+  const companies = ref<Company[]>([])
+  const cargos = ref<Cargo[]>([])
+  const route = useRoute()
 
   const modalOpen = ref(false)
   const editingRecord = ref<WeighingRecord | null>(null)
+  const relatedModalOpen = ref(false)
+  const loadingRelated = ref(false)
+  const relatedData = ref<{ plan: Plan | null, inventory: InventoryRecord | null }>({ plan: null, inventory: null })
 
   const formRef = ref()
   const formData = reactive<WeighingCreate>({
@@ -257,51 +409,99 @@
     batchNo: '',
     supplier: '',
     consignee: '',
-    grossWeight: 0,
-    grossWeightTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
-    tareWeight: 0,
-    tareWeightTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
-    deduction: 0,
+    grossWeight: undefined as any,
+    grossWeightTime: '',
+    tareWeight: undefined as any,
+    tareWeightTime: '',
+    deduction: undefined as any,
     netWeight: 0,
-    remarks: ''
+    remarks: '',
+    purchasePlanId: undefined,
+    inboundOutboundId: undefined,
+    weighingPoint: '山峡化工',
+    plannedQuantity: undefined,
+    currentQuantity: undefined,
+    executedQuantity: undefined
   })
 
   const formRules = {
     vehicleNo: [{ required: true, message: '请输入车牌号', trigger: 'blur' }],
-    cargoName: [{ required: true, message: '请输入货物名称', trigger: 'blur' }],
-    supplier: [{ required: true, message: '请输入供货单位', trigger: 'blur' }],
-    consignee: [{ required: true, message: '请输入收货单位', trigger: 'blur' }],
-    grossWeight: [{ required: true, message: '请输入毛重', trigger: 'blur' }],
-    tareWeight: [{ required: true, message: '请输入皮重', trigger: 'blur' }]
+    cargoName: [{ required: true, message: '请选择货物名称', trigger: 'change' }],
+    supplier: [{ required: true, message: '请选择供货单位', trigger: 'change' }],
+    consignee: [{ required: true, message: '请选择收货单位', trigger: 'change' }]
   }
 
   const netWeight = computed(() => {
-    const gross = formData.grossWeight || 0
-    const tare = formData.tareWeight || 0
-    const deduction = formData.deduction || 0
+    const gross = formData.grossWeight ?? 0
+    const tare = formData.tareWeight ?? 0
+    const deduction = formData.deduction ?? 0
     return Number((gross - tare - deduction).toFixed(2))
   })
 
-  const isNetInvalid = computed(() => netWeight.value <= 0)
+  // 本次量(吨)- 从 kg 转换为吨显示
+  const currentQuantityInTons = computed(() => {
+    if (formData.currentQuantity !== undefined && formData.currentQuantity !== null) {
+      return (formData.currentQuantity / 1000).toFixed(2)
+    }
+    return '--'
+  })
+
+  // 只有在毛重和皮重都存在时,才需要验证净重
+  const isNetInvalid = computed(() => {
+    const hasGrossWeight = formData.grossWeight && formData.grossWeight > 0
+    const hasTareWeight = formData.tareWeight && formData.tareWeight > 0
+    // 如果毛重和皮重都存在,净重必须大于0
+    if (hasGrossWeight && hasTareWeight) {
+      return netWeight.value <= 0
+    }
+    // 如果只有其中一个,或者都没有,不验证净重
+    return false
+  })
 
   const calculateNetWeight = () => {
     formData.netWeight = netWeight.value
   }
 
+  // 处理毛重变化
+  const handleGrossWeightChange = () => {
+    calculateNetWeight()
+    // 如果毛重大于0且毛重时间为空,自动填充当前时间
+    if (formData.grossWeight && formData.grossWeight > 0 && !formData.grossWeightTime) {
+      formData.grossWeightTime = dayjs().format('YYYY-MM-DD HH:mm:ss')
+    }
+  }
+
+  // 处理皮重变化
+  const handleTareWeightChange = () => {
+    calculateNetWeight()
+    // 如果皮重大于0且皮重时间为空,自动填充当前时间
+    if (formData.tareWeight && formData.tareWeight > 0 && !formData.tareWeightTime) {
+      formData.tareWeightTime = dayjs().format('YYYY-MM-DD HH:mm:ss')
+    }
+  }
+
+  // 分页后的数据
+  const paginatedData = computed(() => {
+    const start = (currentPage.value - 1) * pageSize.value
+    const end = start + pageSize.value
+    return allData.value.slice(start, end)
+  })
+
   const loadData = async () => {
     loading.value = true
     try {
       const params: any = {}
-      if (dateRange.value) {
-        params.dateFrom = dateRange.value[0]
-        params.dateTo = dateRange.value[1]
-      }
       if (searchKeyword.value) {
         params.vehicleNo = searchKeyword.value
       }
       const records = await weighingApi.list(params)
-      tableData.value = records
+      allData.value = records
       total.value = records.length
+      // 如果当前页超出范围,重置到第一页
+      const maxPage = Math.ceil(total.value / pageSize.value) || 1
+      if (currentPage.value > maxPage) {
+        currentPage.value = 1
+      }
     } catch (error) {
       ElMessage.error('加载数据失败')
     } finally {
@@ -316,13 +516,11 @@
 
   const handlePageChange = (page: number) => {
     currentPage.value = page
-    loadData()
   }
 
   const handleSizeChange = (size: number) => {
     pageSize.value = size
     currentPage.value = 1
-    loadData()
   }
 
   const handleAdd = () => {
@@ -335,17 +533,36 @@
       batchNo: '',
       supplier: '',
       consignee: '',
-      grossWeight: 0,
-      grossWeightTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
-      tareWeight: 0,
-      tareWeightTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
-      deduction: 0,
+      grossWeight: undefined as any,
+      grossWeightTime: '',
+      tareWeight: undefined as any,
+      tareWeightTime: '',
+      deduction: undefined as any,
       netWeight: 0,
-      remarks: ''
+      remarks: '',
+      purchasePlanId: undefined,
+      inboundOutboundId: undefined,
+      weighingPoint: '山峡化工',
+      plannedQuantity: undefined,
+      currentQuantity: undefined,
+      executedQuantity: undefined
     })
     modalOpen.value = true
   }
 
+  const handleView = async (record: WeighingRecord) => {
+    loadingRelated.value = true
+    try {
+      const data = await weighingApi.getRelated(record.id)
+      relatedData.value = data
+      relatedModalOpen.value = true
+    } catch (error) {
+      ElMessage.error('加载关联数据失败')
+    } finally {
+      loadingRelated.value = false
+    }
+  }
+
   const handleEdit = (record: WeighingRecord) => {
     editingRecord.value = record
     Object.assign(formData, {
@@ -362,7 +579,13 @@
       tareWeightTime: record.tareWeightTime,
       deduction: record.deduction || 0,
       netWeight: record.netWeight,
-      remarks: record.remarks || ''
+      remarks: record.remarks || '',
+      purchasePlanId: record.purchasePlanId,
+      inboundOutboundId: record.inboundOutboundId,
+      weighingPoint: record.weighingPoint || '山峡化工',
+      plannedQuantity: record.plannedQuantity,
+      currentQuantity: record.currentQuantity,
+      executedQuantity: record.executedQuantity
     })
     modalOpen.value = true
   }
@@ -402,12 +625,50 @@
       if (valid) {
         saving.value = true
         try {
-          formData.netWeight = netWeight.value
+          // 计算净重(如果毛重和皮重都存在)
+          const hasGrossWeight = formData.grossWeight && formData.grossWeight > 0
+          const hasTareWeight = formData.tareWeight && formData.tareWeight > 0
+          if (hasGrossWeight && hasTareWeight) {
+            formData.netWeight = netWeight.value
+          } else {
+            // 如果毛重或皮重不存在,净重设为0
+            formData.netWeight = 0
+          }
+          
+          // 确保提交时,如果值为undefined,转换为0
+          const submitGrossWeight = formData.grossWeight === undefined || formData.grossWeight === null ? 0 : formData.grossWeight
+          const submitTareWeight = formData.tareWeight === undefined || formData.tareWeight === null ? 0 : formData.tareWeight
+          const submitDeduction = formData.deduction === undefined || formData.deduction === null ? 0 : formData.deduction
+          
+          // 构建提交数据,确保所有字段都被包含
+          const submitData: any = {
+            vehicleNo: formData.vehicleNo,
+            planNo: formData.planNo || undefined,
+            cargoName: formData.cargoName,
+            businessType: formData.businessType,
+            batchNo: formData.batchNo || undefined,
+            supplier: formData.supplier,
+            consignee: formData.consignee,
+            grossWeight: submitGrossWeight,
+            grossWeightTime: formData.grossWeightTime || '',
+            tareWeight: submitTareWeight,
+            tareWeightTime: formData.tareWeightTime || '',
+            deduction: submitDeduction,
+            netWeight: formData.netWeight,
+            plannedQuantity: formData.plannedQuantity || undefined,
+            executedQuantity: formData.executedQuantity || undefined,
+            purchasePlanId: formData.purchasePlanId || undefined,
+            inboundOutboundId: formData.inboundOutboundId || undefined,
+            weighingPoint: formData.weighingPoint || '山峡化工',
+            remarks: formData.remarks || undefined,
+            currentQuantity: formData.currentQuantity || undefined
+          }
+          
           if (editingRecord.value) {
-            await weighingApi.update(editingRecord.value.id, formData)
+            await weighingApi.update(editingRecord.value.id, submitData)
             ElMessage.success('更新成功')
           } else {
-            await weighingApi.create(formData)
+            await weighingApi.create(submitData)
             ElMessage.success('保存成功')
           }
           modalOpen.value = false
@@ -476,8 +737,89 @@
     }
   }
 
+  const loadCompanies = async () => {
+    try {
+      companies.value = await companyApi.list()
+    } catch (error) {
+      ElMessage.error('加载往来单位失败')
+    }
+  }
+
+  const loadCargos = async () => {
+    try {
+      cargos.value = await cargoApi.list()
+    } catch (error) {
+      ElMessage.error('加载货物列表失败')
+    }
+  }
+
+  // 从路由参数填充表单数据
+  const initFromRoute = async () => {
+    const inboundOutboundId = route.query.inboundOutboundId as string
+    if (inboundOutboundId) {
+      try {
+        // 获取出入库单数据
+        const inventory = await inventoryApi.get(Number(inboundOutboundId))
+        
+        // 填充基础数据(优先使用路由参数,如果没有则使用出入库单数据)
+        formData.vehicleNo = (route.query.vehicleNo as string) || inventory.vehicleNo
+        formData.cargoName = (route.query.cargoName as string) || inventory.product
+        const businessType = (route.query.businessType as '入库' | '出库') || inventory.businessType
+        formData.businessType = businessType
+        
+        // 根据业务类型设置供货单位和收货单位
+        // 入库单:供货单位 = 往来单位,收货单位 = 安庆市山峡化工有限公司
+        // 出库单:供货单位 = 安庆市山峡化工有限公司,收货单位 = 往来单位
+        if (route.query.supplier) {
+          formData.supplier = route.query.supplier as string
+        } else {
+          formData.supplier = businessType === '入库' ? inventory.partnerCompany : '安庆市山峡化工有限公司'
+        }
+        
+        if (route.query.consignee) {
+          formData.consignee = route.query.consignee as string
+        } else {
+          formData.consignee = businessType === '入库' ? '安庆市山峡化工有限公司' : inventory.partnerCompany
+        }
+        
+        formData.planNo = (route.query.planNo as string) || inventory.planNo || ''
+        // 填充出入库单号(优先使用路由参数,如果没有则使用出入库单数据)
+        formData.batchNo = (route.query.batchNo as string) || inventory.batchNo || ''
+        formData.inboundOutboundId = Number(inboundOutboundId)
+        formData.weighingPoint = '山峡化工'
+        
+        // 本次量:优先使用路由参数中的quantity(吨),如果没有则使用出入库单的quantity(吨),然后转换为kg
+        const quantityInTons = route.query.quantity ? Number(route.query.quantity) : inventory.quantity
+        formData.currentQuantity = quantityInTons * 1000
+        
+        // 如果有计划ID,获取计划数据(优先使用路由参数)
+        const purchasePlanId = (route.query.purchasePlanId as string) || inventory.purchasePlanId?.toString()
+        if (purchasePlanId) {
+          // 先设置计划ID,确保即使获取计划数据失败也能保存计划ID
+          formData.purchasePlanId = Number(purchasePlanId)
+          try {
+            const plan = await planApi.get(Number(purchasePlanId))
+            // 计划量就是计划的plannedQuantity(单位:吨),需要转换为kg
+            formData.plannedQuantity = plan.plannedQuantity * 1000
+          } catch (error) {
+            console.error('获取计划数据失败', error)
+            // 即使获取计划数据失败,计划ID也已经设置了
+          }
+        }
+        
+        // 打开弹窗
+        modalOpen.value = true
+      } catch (error) {
+        ElMessage.error('加载出入库单数据失败')
+      }
+    }
+  }
+
   onMounted(() => {
     loadData()
+    loadCompanies()
+    loadCargos()
+    initFromRoute()
   })
 </script>
 
@@ -486,16 +828,57 @@
     margin-bottom: 16px;
     display: flex;
     gap: 8px;
-  }
-
-  .search-bar {
-    margin-bottom: 16px;
-    display: flex;
     align-items: center;
   }
 
   .card-header {
     font-weight: 600;
   }
+
+  :deep(.art-table) {
+    width: 100%;
+  }
+
+  :deep(.el-table) {
+    width: 100% !important;
+    table-layout: auto;
+  }
+
+  // 确保固定列正常工作
+  :deep(.el-table__fixed-right) {
+    right: 0 !important;
+  }
+
+  .data-item {
+    margin-bottom: 16px;
+  }
+
+  .data-item-header {
+    margin-bottom: 8px;
+  }
+
+  .dialog-content {
+    max-height: 60vh;
+    overflow-y: auto;
+    padding-right: 8px;
+  }
+
+  .dialog-content::-webkit-scrollbar {
+    width: 8px;
+  }
+
+  .dialog-content::-webkit-scrollbar-track {
+    background: #f1f1f1;
+    border-radius: 4px;
+  }
+
+  .dialog-content::-webkit-scrollbar-thumb {
+    background: #c1c1c1;
+    border-radius: 4px;
+  }
+
+  .dialog-content::-webkit-scrollbar-thumb:hover {
+    background: #a8a8a8;
+  }
 </style>
 

+ 5 - 7
backend/app/api/routes/auth.py

@@ -2,7 +2,6 @@ from __future__ import annotations
 
 from datetime import datetime, timedelta
 
-import hashlib
 import jwt
 from fastapi import APIRouter, Depends, HTTPException, status
 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -53,10 +52,9 @@ def create_access_token(user_id: int, username: str) -> str:
     return token
 
 
-def verify_password(plain_password: str, hashed_password: str) -> bool:
-    """验证密码"""
-    password_hash = hashlib.sha256(plain_password.encode()).hexdigest()
-    return password_hash == hashed_password
+def verify_password(plain_password: str, stored_password: str) -> bool:
+    """验证密码(明文比较)"""
+    return plain_password == stored_password
 
 
 @router.post("/login", response_model=LoginResponse)
@@ -79,8 +77,8 @@ async def login(
             detail="用户名或密码错误",
         )
 
-    # 验证密码
-    if not verify_password(payload.password, user.hashed_password):
+    # 验证密码(明文比较)
+    if not verify_password(payload.password, user.password):
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="用户名或密码错误",

+ 25 - 2
backend/app/api/routes/cargos.py

@@ -35,14 +35,18 @@ async def create_cargo(
 ) -> Cargo:
     from datetime import datetime
     await _ensure_unique_name(payload.name, session)
-    cargo = Cargo(name=payload.name.strip(), created_at=datetime.now().isoformat())
+    cargo = Cargo(
+        name=payload.name.strip(),
+        remarks=payload.remarks,
+        created_at=datetime.now().isoformat()
+    )
     session.add(cargo)
     await session.commit()
     await session.refresh(cargo)
     return cargo
 
 
-@router.patch("/{cargo_id}", response_model=CargoRead)
+@router.put("/{cargo_id}", response_model=CargoRead)
 async def update_cargo(
     cargo_id: int,
     payload: CargoUpdate,
@@ -52,6 +56,25 @@ async def update_cargo(
     if payload.name and payload.name.strip().lower() != cargo.name.lower():
         await _ensure_unique_name(payload.name, session)
         cargo.name = payload.name.strip()
+    if payload.remarks is not None:
+        cargo.remarks = payload.remarks
+    await session.commit()
+    await session.refresh(cargo)
+    return cargo
+
+
+@router.patch("/{cargo_id}", response_model=CargoRead)
+async def patch_cargo(
+    cargo_id: int,
+    payload: CargoUpdate,
+    session: AsyncSession = Depends(get_async_session),
+) -> Cargo:
+    cargo = await _get_cargo(cargo_id, session)
+    if payload.name and payload.name.strip().lower() != cargo.name.lower():
+        await _ensure_unique_name(payload.name, session)
+        cargo.name = payload.name.strip()
+    if payload.remarks is not None:
+        cargo.remarks = payload.remarks
     await session.commit()
     await session.refresh(cargo)
     return cargo

+ 25 - 2
backend/app/api/routes/companies.py

@@ -39,14 +39,18 @@ async def create_company(
 ) -> Company:
     from datetime import datetime
     await _ensure_unique_name(payload.name, session)
-    company = Company(name=payload.name.strip(), created_at=datetime.now().isoformat())
+    company = Company(
+        name=payload.name.strip(),
+        remarks=payload.remarks,
+        created_at=datetime.now().isoformat()
+    )
     session.add(company)
     await session.commit()
     await session.refresh(company)
     return company
 
 
-@router.patch("/{company_id}", response_model=CompanyRead)
+@router.put("/{company_id}", response_model=CompanyRead)
 async def update_company(
     company_id: int,
     payload: CompanyUpdate,
@@ -56,6 +60,25 @@ async def update_company(
     if payload.name and payload.name.strip().lower() != company.name.lower():
         await _ensure_unique_name(payload.name, session)
         company.name = payload.name.strip()
+    if payload.remarks is not None:
+        company.remarks = payload.remarks
+    await session.commit()
+    await session.refresh(company)
+    return company
+
+
+@router.patch("/{company_id}", response_model=CompanyRead)
+async def patch_company(
+    company_id: int,
+    payload: CompanyUpdate,
+    session: AsyncSession = Depends(get_async_session),
+) -> Company:
+    company = await _get_company(company_id, session)
+    if payload.name and payload.name.strip().lower() != company.name.lower():
+        await _ensure_unique_name(payload.name, session)
+        company.name = payload.name.strip()
+    if payload.remarks is not None:
+        company.remarks = payload.remarks
     await session.commit()
     await session.refresh(company)
     return company

+ 121 - 2
backend/app/api/routes/inventory.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import re
 from datetime import datetime
 
 from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -9,11 +10,16 @@ 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.models.plans import PurchasePlan
 from app.schemas.inventory import (
     InboundOutboundCreate,
     InboundOutboundRead,
     InboundOutboundUpdate,
 )
+from app.schemas.plans import PurchasePlanRead
+from app.schemas.weighing import WeighingSlipRead
+from pydantic import BaseModel
 
 router = APIRouter(prefix="/inventory", tags=["inventory"])
 
@@ -22,6 +28,36 @@ def _base_query():
     return select(InboundOutbound).where(InboundOutbound.is_deleted == 0)
 
 
+async def _generate_batch_no(session: AsyncSession) -> str:
+    """
+    生成出入库单号,格式为 CRKDH+0000001 这样的递增序号
+    """
+    # 查询所有以 CRKDH 开头的 batch_no
+    result = await session.exec(
+        select(InboundOutbound.batch_no)
+        .where(
+            InboundOutbound.batch_no.like("CRKDH%"),
+            InboundOutbound.is_deleted == 0
+        )
+    )
+    batch_nos = result.all()
+    
+    # 提取所有序号
+    max_seq = 0
+    pattern = re.compile(r"^CRKDH(\d+)$")
+    for batch_no in batch_nos:
+        if batch_no:
+            match = pattern.match(batch_no)
+            if match:
+                seq = int(match.group(1))
+                if seq > max_seq:
+                    max_seq = seq
+    
+    # 生成新的序号(递增1)
+    new_seq = max_seq + 1
+    return f"CRKDH{new_seq:07d}"
+
+
 @router.get("/", response_model=list[InboundOutboundRead])
 async def list_inventory(
     q: str | None = Query(default=None, description="模糊搜索往来单位或产品"),
@@ -52,7 +88,46 @@ async def list_inventory(
         query = query.where(InboundOutbound.business_type == business_type)
 
     result = await session.exec(query)
-    return result.all()
+    inventories = result.all()
+    
+    # 为每个出入库单查询关联的地磅单数据(一对一关系)
+    result_list = []
+    for inventory in inventories:
+        # 查询该出入库单关联的未作废的地磅单(一对一关系,只取第一个)
+        weighing_result = await session.exec(
+            select(WeighingSlip).where(
+                WeighingSlip.inbound_outbound_id == inventory.id,
+                WeighingSlip.is_voided == 0,
+            ).limit(1)
+        )
+        weighing = weighing_result.first()
+        
+        # 将出入库单转换为字典,然后添加地磅单数据
+        inventory_dict = inventory.model_dump()
+        
+        # 如果有关联的地磅单,直接使用其数据
+        if weighing:
+            inventory_dict['gross_weight'] = weighing.gross_weight if weighing.gross_weight is not None else None
+            inventory_dict['tare_weight'] = weighing.tare_weight if weighing.tare_weight is not None else None
+            inventory_dict['deduction'] = weighing.deduction if weighing.deduction is not None else None
+            inventory_dict['net_weight'] = weighing.net_weight if weighing.net_weight is not None else None
+            inventory_dict['gross_weight_time'] = weighing.gross_weight_time if weighing.gross_weight_time and weighing.gross_weight_time.strip() else None
+            inventory_dict['tare_weight_time'] = weighing.tare_weight_time if weighing.tare_weight_time and weighing.tare_weight_time.strip() else None
+            inventory_dict['has_weighing_slip'] = True  # 已有关联的地磅单
+        else:
+            # 如果没有关联的地磅单,设置为None
+            inventory_dict['gross_weight'] = None
+            inventory_dict['tare_weight'] = None
+            inventory_dict['deduction'] = None
+            inventory_dict['net_weight'] = None
+            inventory_dict['gross_weight_time'] = None
+            inventory_dict['tare_weight_time'] = None
+            inventory_dict['has_weighing_slip'] = False  # 没有关联的地磅单
+        
+        # 使用 schema 验证并返回
+        result_list.append(InboundOutboundRead.model_validate(inventory_dict))
+    
+    return result_list
 
 
 @router.post("/", response_model=InboundOutboundRead, status_code=status.HTTP_201_CREATED)
@@ -60,6 +135,11 @@ async def create_inventory(
     payload: InboundOutboundCreate,
     session: AsyncSession = Depends(get_async_session),
 ) -> InboundOutbound:
+    # 如果没有提供 batch_no,自动生成
+    batch_no = payload.batch_no.strip() if payload.batch_no else None
+    if not batch_no:
+        batch_no = await _generate_batch_no(session)
+    
     inventory = InboundOutbound(
         date=payload.date,
         partner_company=payload.partner_company.strip(),
@@ -67,7 +147,7 @@ async def create_inventory(
         product=payload.product.strip(),
         quantity=payload.quantity,
         plan_no=payload.plan_no.strip() if payload.plan_no else None,
-        batch_no=payload.batch_no.strip() if payload.batch_no else None,
+        batch_no=batch_no,
         vehicle_no=payload.vehicle_no.strip(),
         status=payload.status,
         remarks=payload.remarks,
@@ -127,3 +207,42 @@ async def _get_inventory(id: int, session: AsyncSession) -> InboundOutbound:
             detail=f"Inventory {id} not found",
         )
     return inventory
+
+
+class InventoryRelatedData(BaseModel):
+    plan: PurchasePlanRead | None
+    weighing: WeighingSlipRead | None
+
+
+@router.get("/{id}/related", response_model=InventoryRelatedData)
+async def get_inventory_related(
+    id: int,
+    session: AsyncSession = Depends(get_async_session),
+) -> InventoryRelatedData:
+    """获取出入库单关联的计划和地磅单"""
+    inventory = await _get_inventory(id, session)
+    
+    # 获取关联的计划(通过 purchase_plan_id)
+    plan = None
+    if inventory.purchase_plan_id:
+        plan_result = await session.exec(
+            select(PurchasePlan).where(
+                PurchasePlan.id == inventory.purchase_plan_id,
+                PurchasePlan.is_deleted == 0
+            )
+        )
+        plan = plan_result.one_or_none()
+    
+    # 获取关联的地磅单(通过 inbound_outbound_id)
+    weighing_result = await session.exec(
+        select(WeighingSlip).where(
+            WeighingSlip.inbound_outbound_id == inventory.id,
+            WeighingSlip.is_voided == 0
+        ).limit(1)
+    )
+    weighing = weighing_result.first()
+    
+    return InventoryRelatedData(
+        plan=PurchasePlanRead.model_validate(plan.model_dump()) if plan else None,
+        weighing=WeighingSlipRead.model_validate(weighing.model_dump()) if weighing else None
+    )

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

@@ -9,7 +9,12 @@ from sqlmodel.ext.asyncio.session import AsyncSession
 
 from app.db.session import get_async_session
 from app.models.plans import PurchasePlan
+from app.models.inventory import InboundOutbound
+from app.models.weighing import WeighingSlip
 from app.schemas.plans import PurchasePlanCreate, PurchasePlanRead, PurchasePlanUpdate
+from app.schemas.inventory import InboundOutboundRead
+from app.schemas.weighing import WeighingSlipRead
+from pydantic import BaseModel
 
 router = APIRouter(prefix="/plans", tags=["plans"])
 
@@ -134,3 +139,48 @@ async def _get_plan(id: int, session: AsyncSession) -> PurchasePlan:
             detail=f"Plan {id} not found",
         )
     return plan
+
+
+class PlanRelatedData(BaseModel):
+    inventories: list[InboundOutboundRead]
+    weighings: list[WeighingSlipRead]
+
+
+@router.get("/{id}/related", response_model=PlanRelatedData)
+async def get_plan_related(
+    id: int,
+    session: AsyncSession = Depends(get_async_session),
+) -> PlanRelatedData:
+    """获取计划关联的出入库单和地磅单"""
+    plan = await _get_plan(id, session)
+    
+    # 获取关联的出入库单(通过 purchase_plan_id)
+    inventory_result = await session.exec(
+        select(InboundOutbound).where(
+            InboundOutbound.purchase_plan_id == plan.id,
+            InboundOutbound.is_deleted == 0
+        ).order_by(InboundOutbound.date.desc(), InboundOutbound.id.desc())
+    )
+    inventories = inventory_result.all()
+    
+    # 获取关联的地磅单(通过 plan_no 或 purchase_plan_id)
+    weighing_query = select(WeighingSlip).where(
+        WeighingSlip.is_voided == 0
+    )
+    if plan.plan_no:
+        weighing_query = weighing_query.where(
+            (WeighingSlip.plan_no == plan.plan_no) |
+            (WeighingSlip.purchase_plan_id == plan.id)
+        )
+    else:
+        weighing_query = weighing_query.where(
+            WeighingSlip.purchase_plan_id == plan.id
+        )
+    weighing_query = weighing_query.order_by(WeighingSlip.created_at.desc())
+    weighing_result = await session.exec(weighing_query)
+    weighings = weighing_result.all()
+    
+    return PlanRelatedData(
+        inventories=[InboundOutboundRead.model_validate(inv.model_dump()) for inv in inventories],
+        weighings=[WeighingSlipRead.model_validate(w.model_dump()) for w in weighings]
+    )

+ 24 - 13
backend/app/api/routes/users.py

@@ -40,15 +40,10 @@ async def create_user(
             detail="Username already exists",
         )
 
-    # Hash password (using bcrypt would be better, but for simplicity using a basic approach)
-    # In production, use: from passlib.context import CryptContext
-    import hashlib
-
-    hashed_password = hashlib.sha256(payload.password.encode()).hexdigest()
-
+    # Store password as plain text
     user = User(
         username=payload.username.strip(),
-        hashed_password=hashed_password,
+        password=payload.password,
         role=payload.role,
     )
     session.add(user)
@@ -57,12 +52,12 @@ async def create_user(
     return user
 
 
-@router.patch("/{id}", response_model=UserRead)
-async def update_user(
+async def _update_user_logic(
     id: int,
     payload: UserUpdate,
-    session: AsyncSession = Depends(get_async_session),
+    session: AsyncSession,
 ) -> User:
+    """更新用户的通用逻辑"""
     user = await _get_user(id, session)
     payload_dict = payload.model_dump(exclude_unset=True)
 
@@ -81,9 +76,7 @@ async def update_user(
         user.username = payload.username.strip()
 
     if "password" in payload_dict:
-        import hashlib
-
-        user.hashed_password = hashlib.sha256(payload.password.encode()).hexdigest()
+        user.password = payload.password
 
     if "role" in payload_dict:
         user.role = payload.role
@@ -93,6 +86,24 @@ async def update_user(
     return user
 
 
+@router.put("/{id}", response_model=UserRead)
+async def update_user(
+    id: int,
+    payload: UserUpdate,
+    session: AsyncSession = Depends(get_async_session),
+) -> User:
+    return await _update_user_logic(id, payload, session)
+
+
+@router.patch("/{id}", response_model=UserRead)
+async def patch_user(
+    id: int,
+    payload: UserUpdate,
+    session: AsyncSession = Depends(get_async_session),
+) -> User:
+    return await _update_user_logic(id, payload, session)
+
+
 @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_user(
     id: int,

+ 248 - 11
backend/app/api/routes/weighing.py

@@ -8,7 +8,12 @@ from sqlmodel.ext.asyncio.session import AsyncSession
 
 from app.db.session import get_async_session
 from app.models.weighing import WeighingSlip
+from app.models.plans import PurchasePlan
+from app.models.inventory import InboundOutbound
 from app.schemas.weighing import WeighingSlipCreate, WeighingSlipRead, WeighingSlipUpdate
+from app.schemas.plans import PurchasePlanRead
+from app.schemas.inventory import InboundOutboundRead
+from pydantic import BaseModel
 
 router = APIRouter(prefix="/weighing", tags=["weighing"])
 
@@ -53,33 +58,90 @@ async def create_weighing(
     payload: WeighingSlipCreate,
     session: AsyncSession = Depends(get_async_session),
 ) -> WeighingSlip:
+    # 如果有关联的出入库单,检查是否已经存在地磅单(一对一关系)
+    inbound_outbound_id = payload.inbound_outbound_id
+    if inbound_outbound_id:
+        existing_weighing_result = await session.exec(
+            select(WeighingSlip).where(
+                WeighingSlip.inbound_outbound_id == inbound_outbound_id,
+                WeighingSlip.is_voided == 0,
+            )
+        )
+        existing_weighing = existing_weighing_result.first()
+        if existing_weighing:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="该出入库单已经存在关联的地磅单,无法重复创建",
+            )
+    
+    # 如果有关联的出入库单,自动填充相关字段
+    planned_quantity = payload.planned_quantity
+    current_quantity = payload.current_quantity
+    purchase_plan_id = payload.purchase_plan_id
+    batch_no = payload.batch_no.strip() if payload.batch_no else None
+    
+    if inbound_outbound_id:
+        # 获取出入库单数据
+        inventory_result = await session.exec(
+            select(InboundOutbound).where(InboundOutbound.id == inbound_outbound_id)
+        )
+        inventory = inventory_result.one_or_none()
+        if inventory:
+            # 本次量就是出入库单的quantity(单位:吨),需要转换为kg
+            if current_quantity is None:
+                current_quantity = inventory.quantity * 1000
+            
+            # 如果有计划ID,获取计划数据(优先使用出入库单的计划ID,如果前端没有传递)
+            if purchase_plan_id is None and inventory.purchase_plan_id:
+                purchase_plan_id = inventory.purchase_plan_id
+            
+            # 如果现在有计划ID,获取计划数据
+            if purchase_plan_id:
+                plan_result = await session.exec(
+                    select(PurchasePlan).where(PurchasePlan.id == purchase_plan_id)
+                )
+                plan = plan_result.one_or_none()
+                if plan:
+                    # 计划量就是计划的planned_quantity(单位:吨),需要转换为kg
+                    if planned_quantity is None:
+                        planned_quantity = plan.planned_quantity * 1000
+            
+            # 如果前端没有传递 batch_no,使用出入库单的 batch_no(出入库单号)
+            if not batch_no and inventory.batch_no:
+                batch_no = inventory.batch_no
+    
     weighing = WeighingSlip(
         vehicle_no=payload.vehicle_no.strip(),
         plan_no=payload.plan_no.strip() if payload.plan_no else None,
         cargo_name=payload.cargo_name.strip(),
         business_type=payload.business_type,
-        batch_no=payload.batch_no.strip() if payload.batch_no else None,
+        batch_no=batch_no,  # 使用出入库单号
         supplier=payload.supplier.strip(),
         consignee=payload.consignee.strip(),
-        gross_weight=payload.gross_weight,
-        gross_weight_time=payload.gross_weight_time,
-        tare_weight=payload.tare_weight,
-        tare_weight_time=payload.tare_weight_time,
+        gross_weight=payload.gross_weight or 0,
+        gross_weight_time=payload.gross_weight_time.strip() if payload.gross_weight_time else "",
+        tare_weight=payload.tare_weight or 0,
+        tare_weight_time=payload.tare_weight_time.strip() if payload.tare_weight_time else "",
         deduction=payload.deduction or 0,
-        net_weight=payload.net_weight,
-        planned_quantity=payload.planned_quantity,
+        net_weight=payload.net_weight or 0,
+        planned_quantity=planned_quantity,
         executed_quantity=payload.executed_quantity,
-        purchase_plan_id=payload.purchase_plan_id,
-        inbound_outbound_id=payload.inbound_outbound_id,
-        weighing_point=payload.weighing_point or "山峡地磅",
+        purchase_plan_id=purchase_plan_id,
+        inbound_outbound_id=inbound_outbound_id,
+        weighing_point=payload.weighing_point or "山峡化工",
         remarks=payload.remarks,
-        current_quantity=payload.current_quantity,
+        current_quantity=current_quantity,
         is_voided=0,
         created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
     )
     session.add(weighing)
     await session.commit()
     await session.refresh(weighing)
+    
+    # 处理业务逻辑
+    await _process_weighing_business_logic(weighing, session)
+    await session.refresh(weighing)
+    
     return weighing
 
 
@@ -114,6 +176,11 @@ async def update_weighing(
 
     await session.commit()
     await session.refresh(weighing)
+    
+    # 处理业务逻辑
+    await _process_weighing_business_logic(weighing, session)
+    await session.refresh(weighing)
+    
     return weighing
 
 
@@ -132,6 +199,9 @@ async def void_weighing(
 
     weighing.is_voided = 1
     await session.commit()
+    
+    # 处理业务逻辑(作废后需要重新计算)
+    await _process_weighing_business_logic(weighing, session)
 
 
 @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -142,6 +212,9 @@ async def delete_weighing(
     weighing = await _get_weighing(id, session)
     weighing.is_voided = 1
     await session.commit()
+    
+    # 处理业务逻辑(删除后需要重新计算)
+    await _process_weighing_business_logic(weighing, session)
 
 
 async def _get_weighing(id: int, session: AsyncSession) -> WeighingSlip:
@@ -153,3 +226,167 @@ async def _get_weighing(id: int, session: AsyncSession) -> WeighingSlip:
             detail=f"Weighing {id} not found",
         )
     return weighing
+
+
+class WeighingRelatedData(BaseModel):
+    plan: PurchasePlanRead | None
+    inventory: InboundOutboundRead | None
+
+
+@router.get("/{id}/related", response_model=WeighingRelatedData)
+async def get_weighing_related(
+    id: int,
+    session: AsyncSession = Depends(get_async_session),
+) -> WeighingRelatedData:
+    """获取地磅单关联的计划和出入库单"""
+    weighing = await _get_weighing(id, session)
+    
+    # 获取关联的计划(通过 purchase_plan_id 或 plan_no)
+    plan = None
+    if weighing.purchase_plan_id:
+        plan_result = await session.exec(
+            select(PurchasePlan).where(
+                PurchasePlan.id == weighing.purchase_plan_id,
+                PurchasePlan.is_deleted == 0
+            )
+        )
+        plan = plan_result.one_or_none()
+    elif weighing.plan_no:
+        plan_result = await session.exec(
+            select(PurchasePlan).where(
+                PurchasePlan.plan_no == weighing.plan_no,
+                PurchasePlan.is_deleted == 0
+            ).limit(1)
+        )
+        plan = plan_result.first()
+    
+    # 获取关联的出入库单(通过 inbound_outbound_id)
+    inventory = None
+    if weighing.inbound_outbound_id:
+        inventory_result = await session.exec(
+            select(InboundOutbound).where(
+                InboundOutbound.id == weighing.inbound_outbound_id,
+                InboundOutbound.is_deleted == 0
+            )
+        )
+        inventory = inventory_result.one_or_none()
+    
+    return WeighingRelatedData(
+        plan=PurchasePlanRead.model_validate(plan.model_dump()) if plan else None,
+        inventory=InboundOutboundRead.model_validate(inventory.model_dump()) if inventory else None
+    )
+
+
+async def _process_weighing_business_logic(
+    weighing: WeighingSlip, session: AsyncSession
+) -> None:
+    """
+    处理地磅单的业务逻辑:
+    1. 更新计划单的已执行量(单位:kg)
+    2. 更新出入库单的状态(只有皮重和毛重都存在了,状态改成已完成,否则还原到待入库/待出库)
+    3. 重新计算地磅单的executed_quantity(按照创建时间顺序累计)
+    """
+    # 收集需要处理的计划ID和出入库单ID
+    plan_ids_to_process = set()
+    inventory_ids_to_process = set()
+    
+    # 如果当前地磅单有计划ID,添加到处理列表
+    if weighing.purchase_plan_id:
+        plan_ids_to_process.add(weighing.purchase_plan_id)
+    
+    # 如果当前地磅单有出入库单ID,添加到处理列表
+    if weighing.inbound_outbound_id:
+        inventory_ids_to_process.add(weighing.inbound_outbound_id)
+    
+    # 如果当前地磅单有计划号,查找所有使用该计划号的计划
+    if weighing.plan_no:
+        plans_result = await session.exec(
+            select(PurchasePlan).where(
+                PurchasePlan.plan_no == weighing.plan_no,
+                PurchasePlan.is_deleted == 0
+            )
+        )
+        plans = plans_result.all()
+        for plan in plans:
+            plan_ids_to_process.add(plan.id)
+    
+    # 1. 处理所有相关计划单的已执行量
+    for plan_id in plan_ids_to_process:
+        plan_result = await session.exec(
+            select(PurchasePlan).where(PurchasePlan.id == plan_id)
+        )
+        plan = plan_result.one_or_none()
+        if plan:
+            # 获取该计划号所有未作废的地磅单,按创建时间排序
+            plan_weighings_result = await session.exec(
+                select(WeighingSlip)
+                .where(
+                    WeighingSlip.plan_no == plan.plan_no,
+                    WeighingSlip.is_voided == 0,
+                )
+                .order_by(WeighingSlip.created_at.asc())
+            )
+            plan_weighings = plan_weighings_result.all()
+            
+            # 计算已执行量(所有地磅单的净重总和,单位:kg)
+            total_executed = sum(w.net_weight for w in plan_weighings if w.net_weight)
+            plan.executed_quantity = total_executed
+            
+            # 更新计划的执行状态
+            # 根据业务类型和执行量判断状态
+            planned_quantity_kg = plan.planned_quantity * 1000  # 计划量转换为kg
+            if total_executed >= planned_quantity_kg:
+                # 已执行量达到或超过计划量,状态为已完成
+                plan.execution_status = "已入库" if plan.business_type == "入库" else "已出库"
+            elif total_executed > 0:
+                # 有执行量但未完成,状态为进行中
+                plan.execution_status = "入库中" if plan.business_type == "入库" else "出库中"
+            else:
+                # 没有执行量,状态为待执行
+                plan.execution_status = "待入库" if plan.business_type == "入库" else "待出库"
+            
+            await session.commit()
+            await session.refresh(plan)
+            
+            # 重新计算每个地磅单的executed_quantity(按创建时间顺序累计)
+            cumulative_executed = 0.0
+            for w in plan_weighings:
+                cumulative_executed += w.net_weight if w.net_weight else 0.0
+                w.executed_quantity = cumulative_executed
+            await session.commit()
+
+    # 2. 处理所有相关出入库单的状态
+    for inventory_id in inventory_ids_to_process:
+        inventory_result = await session.exec(
+            select(InboundOutbound).where(InboundOutbound.id == inventory_id)
+        )
+        inventory = inventory_result.one_or_none()
+        if inventory:
+            # 检查该出入库单关联的所有地磅单
+            related_weighings_result = await session.exec(
+                select(WeighingSlip).where(
+                    WeighingSlip.inbound_outbound_id == inventory_id,
+                    WeighingSlip.is_voided == 0,
+                )
+            )
+            related_weighings = related_weighings_result.all()
+            
+            # 检查是否所有地磅单都有皮重和毛重(非空字符串)
+            all_complete = (
+                len(related_weighings) > 0
+                and all(
+                    w.gross_weight_time and w.gross_weight_time.strip() 
+                    and w.tare_weight_time and w.tare_weight_time.strip()
+                    for w in related_weighings
+                )
+            )
+            
+            if all_complete and len(related_weighings) > 0:
+                # 所有地磅单都完成了,状态改为已完成
+                inventory.status = "已完成"
+            else:
+                # 否则还原到待入库/待出库
+                inventory.status = "待入库" if inventory.business_type == "入库" else "待出库"
+            
+            await session.commit()
+            await session.refresh(inventory)

+ 14 - 1
backend/app/db/session.py

@@ -17,7 +17,20 @@ from app.models import (  # noqa: F401
 )
 
 settings = get_settings()
-engine = create_async_engine(settings.database_url, echo=settings.env == "dev")
+
+# 配置连接池参数
+# SQLAlchemy 内置连接池,无需额外插件
+engine = create_async_engine(
+    settings.database_url,
+    echo=settings.env == "dev",
+    # 连接池配置
+    pool_size=10,  # 连接池中保持的连接数
+    max_overflow=20,  # 连接池外可以额外创建的连接数(最大连接数 = pool_size + max_overflow)
+    pool_timeout=30,  # 从连接池获取连接的超时时间(秒)
+    pool_recycle=3600,  # 连接回收时间(秒),1小时后回收连接,避免 MySQL 的 wait_timeout 导致的连接过期
+    pool_pre_ping=True,  # 每次使用连接前先 ping 一下,确保连接有效,自动重连失效的连接(关键配置!)
+)
+
 AsyncSessionLocal = async_sessionmaker(
     bind=engine, class_=AsyncSession, expire_on_commit=False, autoflush=False
 )

+ 2 - 0
backend/app/models/master_data.py

@@ -8,6 +8,7 @@ class Company(SQLModel, table=True):
 
     id: int | None = Field(default=None, primary_key=True)
     name: str = Field(index=True, unique=True, max_length=255)
+    remarks: str | None = Field(default=None)
     is_deleted: int = Field(default=0, index=True)
     created_at: str = Field(max_length=255)
 
@@ -17,5 +18,6 @@ class Cargo(SQLModel, table=True):
 
     id: int | None = Field(default=None, primary_key=True)
     name: str = Field(index=True, unique=True, max_length=255)
+    remarks: str | None = Field(default=None)
     is_deleted: int = Field(default=0, index=True)
     created_at: str = Field(max_length=255)

+ 1 - 1
backend/app/models/users.py

@@ -11,7 +11,7 @@ class User(SQLModel, table=True):
 
     id: int | None = Field(default=None, primary_key=True)
     username: str = Field(index=True, unique=True, max_length=255)
-    hashed_password: str = Field(max_length=255)
+    password: str = Field(max_length=255)
     role: str = Field(default="user", max_length=50)
     created_at: datetime | None = Field(
         default=None,

+ 7 - 0
backend/app/schemas/inventory.py

@@ -55,6 +55,13 @@ class InboundOutboundRead(InboundOutboundBase):
     id: int
     is_deleted: int = Field(default=0, alias="isDeleted")
     created_at: str = Field(alias="createdAt")
+    gross_weight: float | None = Field(default=None, alias="grossWeight")  # 毛重(kg)- 汇总自关联地磅单
+    tare_weight: float | None = Field(default=None, alias="tareWeight")  # 皮重(kg)- 汇总自关联地磅单
+    deduction: float | None = Field(default=None)  # 扣杂(kg)- 汇总自关联地磅单
+    net_weight: float | None = Field(default=None, alias="netWeight")  # 净重(kg)- 汇总自关联地磅单
+    gross_weight_time: str | None = Field(default=None, alias="grossWeightTime")  # 毛重时间 - 来自关联地磅单
+    tare_weight_time: str | None = Field(default=None, alias="tareWeightTime")  # 皮重时间 - 来自关联地磅单
+    has_weighing_slip: bool = Field(default=False, alias="hasWeighingSlip")  # 是否已有关联的地磅单
 
 
 # 兼容别名

+ 4 - 0
backend/app/schemas/master_data.py

@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
 
 class CompanyBase(BaseModel):
     name: str = Field(min_length=1, max_length=255)
+    remarks: str | None = Field(default=None)
 
     model_config = {"from_attributes": True}
 
@@ -15,6 +16,7 @@ class CompanyCreate(CompanyBase):
 
 class CompanyUpdate(BaseModel):
     name: str | None = Field(default=None, min_length=1, max_length=255)
+    remarks: str | None = Field(default=None)
 
 
 class CompanyRead(CompanyBase):
@@ -23,6 +25,7 @@ class CompanyRead(CompanyBase):
 
 class CargoBase(BaseModel):
     name: str = Field(min_length=1, max_length=255)
+    remarks: str | None = Field(default=None)
 
     model_config = {"from_attributes": True}
 
@@ -33,6 +36,7 @@ class CargoCreate(CargoBase):
 
 class CargoUpdate(BaseModel):
     name: str | None = Field(default=None, min_length=1, max_length=255)
+    remarks: str | None = Field(default=None)
 
 
 class CargoRead(CargoBase):

+ 2 - 2
backend/app/schemas/users.py

@@ -14,13 +14,13 @@ class UserBase(BaseModel):
 
 class UserCreate(BaseModel):
     username: str = Field(max_length=100)
-    password: str = Field(min_length=6)
+    password: str = Field(min_length=1)
     role: str = Field(default="user", max_length=50)
 
 
 class UserUpdate(BaseModel):
     username: str | None = Field(default=None, max_length=100)
-    password: str | None = Field(default=None, min_length=6)
+    password: str | None = Field(default=None, min_length=1)
     role: str | None = Field(default=None, max_length=50)
 
 

+ 5 - 5
backend/app/schemas/weighing.py

@@ -36,12 +36,12 @@ class WeighingSlipCreate(BaseModel):
     batch_no: str | None = Field(default=None, max_length=255, alias="batchNo")
     supplier: str = Field(max_length=255)
     consignee: str = Field(max_length=255)
-    gross_weight: float = Field(alias="grossWeight")
-    gross_weight_time: str = Field(max_length=255, alias="grossWeightTime")
-    tare_weight: float = Field(alias="tareWeight")
-    tare_weight_time: str = Field(max_length=255, alias="tareWeightTime")
+    gross_weight: float = Field(default=0, alias="grossWeight")
+    gross_weight_time: str = Field(default="", max_length=255, alias="grossWeightTime")
+    tare_weight: float = Field(default=0, alias="tareWeight")
+    tare_weight_time: str = Field(default="", max_length=255, alias="tareWeightTime")
     deduction: float | None = Field(default=0)
-    net_weight: float = Field(alias="netWeight")
+    net_weight: float = Field(default=0, alias="netWeight")
     planned_quantity: float | None = Field(default=None, alias="plannedQuantity")
     executed_quantity: float | None = Field(default=None, alias="executedQuantity")
     purchase_plan_id: int | None = Field(default=None, alias="purchasePlanId")

+ 70 - 0
backend/migrate_add_remarks.py

@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""
+Migration script: Add remarks column to companies table
+Run this script to add the remarks field to the companies table.
+"""
+from __future__ import annotations
+
+import asyncio
+import sys
+from pathlib import Path
+
+# Add parent directory to path
+backend_dir = Path(__file__).parent
+if str(backend_dir) not in sys.path:
+    sys.path.insert(0, str(backend_dir))
+
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from app.core.config import get_settings
+
+
+async def migrate_add_remarks() -> None:
+    """Add remarks column to companies table if it doesn't exist"""
+    settings = get_settings()
+    engine = create_async_engine(settings.database_url, echo=False)
+    AsyncSessionLocal = async_sessionmaker(
+        bind=engine, class_=AsyncSession, expire_on_commit=False
+    )
+
+    async with AsyncSessionLocal() as session:
+        try:
+            # Check if column already exists
+            check_sql = """
+                SELECT COUNT(*) as count
+                FROM INFORMATION_SCHEMA.COLUMNS
+                WHERE TABLE_SCHEMA = DATABASE()
+                AND TABLE_NAME = 'companies'
+                AND COLUMN_NAME = 'remarks'
+            """
+            result = await session.execute(text(check_sql))
+            count = result.scalar()
+
+            if count > 0:
+                print("✓ Column 'remarks' already exists in companies table")
+                return
+
+            # Add the remarks column
+            alter_sql = """
+                ALTER TABLE `companies` 
+                ADD COLUMN `remarks` TEXT NULL COMMENT '备注' AFTER `name`
+            """
+            await session.execute(text(alter_sql))
+            await session.commit()
+            print("✓ Successfully added 'remarks' column to companies table")
+
+        except Exception as e:
+            await session.rollback()
+            print(f"✗ Error adding remarks column: {e}")
+            raise
+        finally:
+            await engine.dispose()
+
+
+if __name__ == "__main__":
+    print("Starting migration: Add remarks column to companies table...")
+    asyncio.run(migrate_add_remarks())
+    print("Migration complete!")
+

+ 70 - 0
backend/migrate_add_remarks_cargos.py

@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""
+Migration script: Add remarks column to cargos table
+Run this script to add the remarks field to the cargos table.
+"""
+from __future__ import annotations
+
+import asyncio
+import sys
+from pathlib import Path
+
+# Add parent directory to path
+backend_dir = Path(__file__).parent
+if str(backend_dir) not in sys.path:
+    sys.path.insert(0, str(backend_dir))
+
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from app.core.config import get_settings
+
+
+async def migrate_add_remarks_cargos() -> None:
+    """Add remarks column to cargos table if it doesn't exist"""
+    settings = get_settings()
+    engine = create_async_engine(settings.database_url, echo=False)
+    AsyncSessionLocal = async_sessionmaker(
+        bind=engine, class_=AsyncSession, expire_on_commit=False
+    )
+
+    async with AsyncSessionLocal() as session:
+        try:
+            # Check if column already exists
+            check_sql = """
+                SELECT COUNT(*) as count
+                FROM INFORMATION_SCHEMA.COLUMNS
+                WHERE TABLE_SCHEMA = DATABASE()
+                AND TABLE_NAME = 'cargos'
+                AND COLUMN_NAME = 'remarks'
+            """
+            result = await session.execute(text(check_sql))
+            count = result.scalar()
+
+            if count > 0:
+                print("✓ Column 'remarks' already exists in cargos table")
+                return
+
+            # Add the remarks column
+            alter_sql = """
+                ALTER TABLE `cargos` 
+                ADD COLUMN `remarks` TEXT NULL COMMENT '备注' AFTER `name`
+            """
+            await session.execute(text(alter_sql))
+            await session.commit()
+            print("✓ Successfully added 'remarks' column to cargos table")
+
+        except Exception as e:
+            await session.rollback()
+            print(f"✗ Error adding remarks column: {e}")
+            raise
+        finally:
+            await engine.dispose()
+
+
+if __name__ == "__main__":
+    print("Starting migration: Add remarks column to cargos table...")
+    asyncio.run(migrate_add_remarks_cargos())
+    print("Migration complete!")
+

+ 13 - 0
backend/migrate_password_field.sql

@@ -0,0 +1,13 @@
+-- 迁移脚本:将 hashed_password 字段改为 password
+-- 执行此脚本前请备份数据库
+
+-- 1. 添加新字段 password
+ALTER TABLE `users` ADD COLUMN `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' AFTER `username`;
+
+-- 2. 将 hashed_password 的数据复制到 password(如果是hash值,需要先解密,这里假设直接复制)
+-- 注意:如果现有数据是hash值,需要手动处理或使用其他方式转换
+UPDATE `users` SET `password` = `hashed_password`;
+
+-- 3. 删除旧字段 hashed_password
+ALTER TABLE `users` DROP COLUMN `hashed_password`;
+

+ 7 - 0
backend/migrations/add_remarks_to_companies.sql

@@ -0,0 +1,7 @@
+-- Migration: Add remarks column to companies table
+-- Date: 2025-11-29
+-- Description: Add remarks field to support company notes/remarks
+
+ALTER TABLE `companies` 
+ADD COLUMN `remarks` TEXT NULL COMMENT '备注' AFTER `name`;
+

+ 58 - 0
weighing_slips.sql

@@ -0,0 +1,58 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 121.43.228.102 hn02le.34lkdLKD
+ Source Server Type    : MySQL
+ Source Server Version : 50736 (5.7.36)
+ Source Host           : 121.43.228.102:3306
+ Source Schema         : dibang
+
+ Target Server Type    : MySQL
+ Target Server Version : 50736 (5.7.36)
+ File Encoding         : 65001
+
+ Date: 29/11/2025 19:50:48
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for weighing_slips
+-- ----------------------------
+DROP TABLE IF EXISTS `weighing_slips`;
+CREATE TABLE `weighing_slips` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `vehicle_no` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车号',
+  `plan_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计划单号',
+  `cargo_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '货物名称',
+  `business_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务类型(入库/出库)',
+  `batch_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '批次号',
+  `supplier` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供货单位',
+  `consignee` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货单位',
+  `gross_weight` double NOT NULL COMMENT '毛重(kg)',
+  `gross_weight_time` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '毛重时间',
+  `tare_weight` double NOT NULL COMMENT '皮重(kg)',
+  `tare_weight_time` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '皮重时间',
+  `deduction` double DEFAULT '0' COMMENT '扣杂(kg)',
+  `net_weight` double NOT NULL COMMENT '净重(kg)',
+  `planned_quantity` double DEFAULT NULL COMMENT '计划数量(kg)',
+  `executed_quantity` double DEFAULT NULL COMMENT '执行数量(kg)',
+  `purchase_plan_id` int(11) DEFAULT NULL COMMENT '关联的计划ID',
+  `inbound_outbound_id` int(11) DEFAULT NULL COMMENT '关联的出入库单ID',
+  `weighing_point` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '山峡地磅' COMMENT '称重点',
+  `remarks` text COLLATE utf8mb4_unicode_ci COMMENT '备注',
+  `current_quantity` double DEFAULT NULL COMMENT '本次量(kg)',
+  `is_voided` int(11) DEFAULT '0' COMMENT '是否作废(0=否, 1=是)',
+  `created_at` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_vehicle_no` (`vehicle_no`) USING BTREE,
+  KEY `idx_plan_no` (`plan_no`) USING BTREE,
+  KEY `idx_cargo_name` (`cargo_name`) USING BTREE,
+  KEY `idx_gross_weight_time` (`gross_weight_time`) USING BTREE,
+  KEY `idx_is_voided` (`is_voided`) USING BTREE,
+  KEY `idx_purchase_plan_id` (`purchase_plan_id`) USING BTREE,
+  KEY `idx_inbound_outbound_id` (`inbound_outbound_id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=116 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='地磅单表';
+
+SET FOREIGN_KEY_CHECKS = 1;