From 7ac06a409d8365511a50b6d950cc36755ce99ab6 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 19 May 2026 00:22:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=93=E5=AD=98=20XLS=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=8A=9F=E8=83=BD=EF=BC=88=E5=90=8E=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20+=20=E5=89=8D=E7=AB=AF=E5=AF=BC=E5=85=A5=E6=8C=89?= =?UTF-8?q?=E9=92=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/import.go | 128 ++++++++++++++++++ backend/internal/router/router.go | 1 + .../inventory/inventory_list_screen.dart | 59 ++++++++ .../lib/screens/settings/settings_screen.dart | 2 + 4 files changed, 190 insertions(+) diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index 07b30d2..9b6d2be 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -468,6 +468,134 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "items": len(items)}) } +// ImportInventory POST /api/v1/import/inventory +// 列顺序:商品编号,商品名称,系列,规格,单位,库存数量,单价,金额,生产日期,批次,分类,所在仓库,入库日期,供应商,上次盘点,备注 +func (h *ImportHandler) ImportInventory(c *gin.Context) { + shopID := middleware.GetShopID(c) + + rows, err := parseUploadedExcel(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + type result struct { + imported int + skipped int + errors []string + } + var res result + + // 仓库缓存,避免重复查询 + warehouseCache := map[string]uint64{} + findOrCreateWarehouse := func(name string) (uint64, error) { + if name == "" { + name = "默认仓库" + } + if id, ok := warehouseCache[name]; ok { + return id, nil + } + var wh model.Warehouse + if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil { + wh = model.Warehouse{ + TenantBase: model.TenantBase{ShopID: shopID}, + Name: name, + } + if err := h.db.Create(&wh).Error; err != nil { + return 0, err + } + } + warehouseCache[name] = wh.ID + return wh.ID, nil + } + + for i, row := range rows[1:] { + productName := cell(row, 1) + if productName == "" { + res.skipped++ + continue + } + series := cell(row, 2) + spec := cell(row, 3) + unit := cell(row, 4) + qtyStr := cell(row, 5) + priceStr := cell(row, 6) + warehouseName := cell(row, 11) + + qty, _ := strconv.ParseFloat(qtyStr, 64) + price, _ := strconv.ParseFloat(priceStr, 64) + + // 找或创建商品 + prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec) + if err != nil { + res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, err.Error())) + continue + } + if unit != "" && prod.Unit == "" { + h.db.Model(&prod).Update("unit", unit) + } + if price > 0 && prod.PurchasePrice == 0 { + h.db.Model(&prod).Update("purchase_price", price) + } + + // 找或创建仓库 + whID, err := findOrCreateWarehouse(warehouseName) + if err != nil { + res.errors = append(res.errors, fmt.Sprintf("行%d: 仓库创建失败: %s", i+2, err.Error())) + continue + } + + // upsert 库存数量 + err = h.db.Transaction(func(tx *gorm.DB) error { + var inv model.Inventory + isNew := false + if tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?", + shopID, whID, prod.ID).First(&inv).Error != nil { + inv = model.Inventory{ + ShopID: shopID, + WarehouseID: whID, + ProductID: prod.ID, + } + isNew = true + } + qtyBefore := inv.Quantity + inv.Quantity = qty + if isNew { + if err := tx.Create(&inv).Error; err != nil { + return err + } + } else { + if err := tx.Save(&inv).Error; err != nil { + return err + } + } + // 写流水 + log := model.InventoryLog{ + ShopID: shopID, + WarehouseID: whID, + ProductID: prod.ID, + Direction: "in", + Quantity: qty, + QtyBefore: qtyBefore, + QtyAfter: qty, + RefType: "import", + } + return tx.Create(&log).Error + }) + if err != nil { + res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error())) + continue + } + res.imported++ + } + + c.JSON(http.StatusOK, gin.H{ + "imported": res.imported, + "skipped": res.skipped, + "errors": res.errors, + }) +} + // ── 内部辅助函数 ───────────────────────────────────────────── func parseUploadedExcel(c *gin.Context) ([][]string, error) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 988e47c..3699db4 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -169,6 +169,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { imp.POST("/product-specs", importH.ImportProductSpecs) imp.POST("/stock-in", importH.ImportStockIn) imp.POST("/stock-out", importH.ImportStockOut) + imp.POST("/inventory", importH.ImportInventory) } // 基础数据选项(名称/系列/规格) diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index b81763d..e737b9e 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -1,9 +1,13 @@ import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../core/config/app_config.dart'; import '../../core/theme/app_theme.dart'; import '../../models/inventory.dart'; +import '../../core/auth/auth_state.dart'; import '../../providers/inventory_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader; @@ -37,6 +41,55 @@ class _InventoryListScreenState extends ConsumerState { }); } + Future _importInventory(BuildContext context, WidgetRef ref) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['xls', 'xlsx'], + withData: true, + ); + if (result == null || result.files.isEmpty) return; + final file = result.files.first; + final bytes = file.bytes; + if (bytes == null) return; + + final token = ref.read(authStateProvider).user?.accessToken ?? ''; + final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl)); + dio.options.headers['Authorization'] = 'Bearer $token'; + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)), + ); + + try { + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: file.name), + }); + final resp = await dio.post('/import/inventory', data: formData); + final data = resp.data as Map; + final imported = data['imported'] ?? 0; + final skipped = data['skipped'] ?? 0; + final errors = (data['errors'] as List?)?.cast() ?? []; + + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('导入完成:$imported 条成功,$skipped 条跳过${errors.isNotEmpty ? ',${errors.length} 条失败' : ''}'), + backgroundColor: errors.isEmpty ? Colors.green : AppTheme.accent, + duration: const Duration(seconds: 4), + )); + ref.read(inventoryListProvider.notifier).reload(); + } on DioException catch (e) { + final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? e.message ?? '未知错误'; + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('导入失败:$msg'), + backgroundColor: AppTheme.danger, + )); + } + } + @override Widget build(BuildContext context) { return PageScaffold( @@ -144,6 +197,12 @@ class _InventoryListScreenState extends ConsumerState { label: const Text('发起盘点'), ), const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => _importInventory(context, ref), + icon: const Icon(Icons.upload_file, size: 16), + label: const Text('导入库存'), + ), + const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => exportExcel( filename: '库存查询', diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index a77cddd..174914d 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1449,6 +1449,8 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { '格式:选项编号 | 选项名称 | 备注'), _ImportSlot('商品规格', '/import/product-specs', '格式:选项编号 | 选项名称 | 单品数量 | 备注'), + _ImportSlot('库存', '/import/inventory', + '格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'), ]; @override