diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index 02a9e59..59f1cfe 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -519,23 +519,47 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { } var res importResult - // 仓库缓存,只查找不创建 - warehouseCache := map[string]*uint64{} + // 预加载仓库(1 次查询) + var warehouses []model.Warehouse + h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses) + warehouseByName := make(map[string]uint64, len(warehouses)) + for _, wh := range warehouses { + warehouseByName[wh.Name] = wh.ID + } findWarehouse := func(name string) *uint64 { if name == "" { return nil } - if idPtr, ok := warehouseCache[name]; ok { - return idPtr + if id, ok := warehouseByName[name]; ok { + return &id } - var wh model.Warehouse - if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil { - warehouseCache[name] = nil - return nil + return nil + } + + // 预加载商品(1 次查询) + var allProducts []model.Product + h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&allProducts) + productByCode := make(map[string]*model.Product, len(allProducts)) + productByNSS := make(map[string]*model.Product, len(allProducts)) + for i := range allProducts { + p := &allProducts[i] + if p.Code != "" { + productByCode[p.Code] = p } - id := wh.ID - warehouseCache[name] = &id - return &id + productByNSS[p.Name+"|"+p.Series+"|"+p.Spec] = p + } + + // 预加载已有导入库存(1 次查询) + var allInvs []model.Inventory + h.db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID).Find(&allInvs) + invByKey := make(map[string]*model.Inventory, len(allInvs)) + for i := range allInvs { + inv := &allInvs[i] + whID := uint64(0) + if inv.WarehouseID != nil { + whID = *inv.WarehouseID + } + invByKey[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv } // Dynamic column detection from header row @@ -561,6 +585,8 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { } } + var logsToCreate []model.InventoryLog + for i, row := range rows[1:] { productName := cell(row, 1) if productName == "" { @@ -586,20 +612,30 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { } price, _ := strconv.ParseFloat(priceStr, 64) - // 只查找商品,不强制创建 - var prod model.Product - if h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))", - shopID, productCode, productName, series, spec).First(&prod).Error != nil { - // 若找不到则创建 + // 从缓存查商品,找不到才创建 + var prod *model.Product + if productCode != "" { + prod = productByCode[productCode] + } + if prod == nil { + prod = productByNSS[productName+"|"+series+"|"+spec] + } + if prod == nil { newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec) if createErr != nil { res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error())) continue } - prod = newProd + allProducts = append(allProducts, newProd) + prod = &allProducts[len(allProducts)-1] + if prod.Code != "" { + productByCode[prod.Code] = prod + } + productByNSS[prod.Name+"|"+prod.Series+"|"+prod.Spec] = prod } if unit != "" && prod.Unit == "" { - h.db.Model(&prod).Update("unit", unit) + h.db.Model(prod).Update("unit", unit) + prod.Unit = unit } // 解析生产日期 @@ -609,7 +645,6 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { productionDate = &d } - // 查找仓库(只查,不创建) whIDPtr := findWarehouse(warehouseName) var unitPricePtr *float64 @@ -617,30 +652,25 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { unitPricePtr = &price } - productIDCopy := prod.ID - - // Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建 - var existing model.Inventory - q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", - shopID, prod.Code) + // 从缓存查库存记录 + whIDVal := uint64(0) if whIDPtr != nil { - q = q.Where("warehouse_id = ?", *whIDPtr) - } else { - q = q.Where("warehouse_id IS NULL") + whIDVal = *whIDPtr } - found := q.First(&existing).Error == nil + invKey := fmt.Sprintf("%s|%d", prod.Code, whIDVal) + existing := invByKey[invKey] - if found { + if existing != nil { updates := map[string]interface{}{ - "quantity": qty, - "product_name": prod.Name, - "series": prod.Series, - "spec": prod.Spec, - "unit": prod.Unit, - "warehouse_name": warehouseName, - "supplier_name": supplierName, - "remark": remark, - "deleted_at": nil, + "quantity": qty, + "product_name": prod.Name, + "series": prod.Series, + "spec": prod.Spec, + "unit": prod.Unit, + "warehouse_name": warehouseName, + "supplier_name": supplierName, + "remark": remark, + "deleted_at": nil, } if unitPricePtr != nil { updates["unit_price"] = *unitPricePtr @@ -651,11 +681,18 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { if batchNo != "" { updates["batch_no"] = batchNo } - if err := h.db.Model(&existing).Updates(updates).Error; err != nil { + if err := h.db.Model(existing).Updates(updates).Error; err != nil { res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error())) continue } + logsToCreate = append(logsToCreate, model.InventoryLog{ + ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID, + Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty, + RefType: "import", RefID: 0, + }) + res.updated++ } else { + productIDCopy := prod.ID inv := model.Inventory{ ShopID: shopID, WarehouseID: whIDPtr, @@ -678,32 +715,19 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error())) continue } - } - - // 写流水 - warehouseID := uint64(0) - if whIDPtr != nil { - warehouseID = *whIDPtr - } - qtyBefore := 0.0 - if found { - qtyBefore = existing.Quantity - res.updated++ - } else { + invByKey[invKey] = &inv + logsToCreate = append(logsToCreate, model.InventoryLog{ + ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID, + Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty, + RefType: "import", RefID: 0, + }) res.imported++ } - log := model.InventoryLog{ - ShopID: shopID, - WarehouseID: warehouseID, - ProductID: prod.ID, - Direction: "in", - Quantity: qty, - QtyBefore: qtyBefore, - QtyAfter: qty, - RefType: "import", - RefID: 0, - } - h.db.Create(&log) + } + + // 批量写流水 + if len(logsToCreate) > 0 { + h.db.CreateInBatches(&logsToCreate, 100) } c.JSON(http.StatusOK, gin.H{ diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index 7df107b..ab8e8fa 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -99,42 +99,94 @@ class _InventoryListScreenState extends ConsumerState { final file = result.files.first; final bytes = file.bytes; if (bytes == null) return; + if (!context.mounted) return; final token = ref.read(authStateProvider).user?.accessToken ?? ''; - final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl)); + final dio = Dio(BaseOptions( + baseUrl: AppConfig.apiBaseUrl, + sendTimeout: const Duration(seconds: 120), + receiveTimeout: const Duration(seconds: 300), + )); dio.options.headers['Authorization'] = 'Bearer $token'; - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)), + final stateNotifier = ValueNotifier<_ImportState>( + const _ImportState(stage: _ImportStage.uploading, uploadPercent: 0), ); + BuildContext? dialogCtx; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + dialogCtx = ctx; + return PopScope( + canPop: false, + child: _ImportProgressDialog(stateNotifier: stateNotifier), + ); + }, + ); + + Timer? processingTimer; + bool uploadDone = false; + int elapsed = 0; + + void closeDialog() { + processingTimer?.cancel(); + if (dialogCtx != null && dialogCtx!.mounted) { + Navigator.of(dialogCtx!).pop(); + } + } + 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(); + final resp = await dio.post( + '/import/inventory', + data: formData, + onSendProgress: (sent, total) { + if (total <= 0) return; + if (sent >= total && !uploadDone) { + uploadDone = true; + elapsed = 0; + stateNotifier.value = const _ImportState( + stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: 0, + ); + processingTimer = Timer.periodic(const Duration(seconds: 1), (_) { + elapsed++; + stateNotifier.value = _ImportState( + stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: elapsed, + ); + }); + } else if (!uploadDone) { + final pct = (sent / total * 100).round().clamp(0, 99); + stateNotifier.value = _ImportState(stage: _ImportStage.uploading, uploadPercent: pct); + } + }, + ); + + processingTimer?.cancel(); + final data = resp.data as Map; + stateNotifier.value = _ImportState( + stage: _ImportStage.done, + uploadPercent: 100, + imported: data['imported'] ?? 0, + updated: data['updated'] ?? 0, + skipped: data['skipped'] ?? 0, + errors: (data['errors'] as List?)?.cast() ?? [], + ); + + await Future.delayed(const Duration(seconds: 2)); + closeDialog(); + if (context.mounted) 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, - )); + processingTimer?.cancel(); + final msg = (e.response?.data is Map ? e.response!.data['error'] : null) + ?? e.message ?? '未知错误'; + stateNotifier.value = _ImportState( + stage: _ImportStage.error, uploadPercent: 0, errorMsg: msg, + ); } } @@ -865,3 +917,149 @@ class _DirectionBadge extends StatelessWidget { ); } } + +// ── 导入进度对话框 ────────────────────────────────────────── + +enum _ImportStage { uploading, processing, done, error } + +class _ImportState { + final _ImportStage stage; + final int uploadPercent; + final int processingSeconds; + final int imported; + final int updated; + final int skipped; + final List errors; + final String? errorMsg; + + const _ImportState({ + required this.stage, + required this.uploadPercent, + this.processingSeconds = 0, + this.imported = 0, + this.updated = 0, + this.skipped = 0, + this.errors = const [], + this.errorMsg, + }); +} + +class _ImportProgressDialog extends StatelessWidget { + final ValueNotifier<_ImportState> stateNotifier; + const _ImportProgressDialog({required this.stateNotifier}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('导入库存'), + content: ValueListenableBuilder<_ImportState>( + valueListenable: stateNotifier, + builder: (_, state, __) => SizedBox( + width: 320, + child: _buildContent(context, state), + ), + ), + actions: [ + ValueListenableBuilder<_ImportState>( + valueListenable: stateNotifier, + builder: (ctx, state, __) { + if (state.stage == _ImportStage.done || state.stage == _ImportStage.error) { + return TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('关闭'), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ); + } + + Widget _buildContent(BuildContext context, _ImportState state) { + switch (state.stage) { + case _ImportStage.uploading: + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('上传文件...', style: TextStyle(fontSize: 14)), + const SizedBox(height: 12), + LinearProgressIndicator( + value: state.uploadPercent / 100, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation(AppTheme.primary), + ), + const SizedBox(height: 6), + Text('${state.uploadPercent}%', + style: TextStyle(fontSize: 12, color: Colors.grey[600])), + ], + ); + + case _ImportStage.processing: + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppTheme.primary), + ), + ), + const SizedBox(width: 10), + Text( + '导入数据中...${state.processingSeconds > 0 ? "(${state.processingSeconds}秒)" : ""}', + style: const TextStyle(fontSize: 14), + ), + ]), + const SizedBox(height: 8), + LinearProgressIndicator( + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation(AppTheme.primary), + ), + ], + ); + + case _ImportStage.done: + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.check_circle, color: AppTheme.success, size: 20), + const SizedBox(width: 8), + const Text('导入成功', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ]), + const SizedBox(height: 10), + Text('新增:${state.imported} 条', style: const TextStyle(fontSize: 13)), + if (state.updated > 0) + Text('更新:${state.updated} 条', style: const TextStyle(fontSize: 13)), + if (state.skipped > 0) + Text('跳过:${state.skipped} 条', + style: TextStyle(fontSize: 13, color: Colors.grey[600])), + if (state.errors.isNotEmpty) + Text('失败:${state.errors.length} 条', + style: TextStyle(fontSize: 13, color: AppTheme.danger)), + ], + ); + + case _ImportStage.error: + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: AppTheme.danger, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + '导入失败:${state.errorMsg}', + style: TextStyle(fontSize: 13, color: AppTheme.danger), + ), + ), + ], + ); + } + } +} diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index ef07d11..f0cff1b 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import '../../core/utils/dialog_util.dart'; import 'package:dio/dio.dart'; import 'package:file_picker/file_picker.dart'; @@ -1524,10 +1525,22 @@ class _ImportSlot { int total = 0; int imported = 0; int skipped = 0; + // 进度 + int uploadPercent = 0; + bool isProcessing = false; + int processingSeconds = 0; _ImportSlot(this.title, this.endpoint, this.hint); bool get hasResult => success != null; + + void resetProgress() { + uploadPercent = 0; + isProcessing = false; + processingSeconds = 0; + success = null; + error = null; + } } class _BatchImportWidget extends ConsumerStatefulWidget { @@ -1657,14 +1670,15 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { setState(() { _loading = true; for (final s in _slots) { - if (s.file != null) { - s.success = null; - s.error = null; - } + if (s.file != null) s.resetProgress(); } }); - final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl)); + final dio = Dio(BaseOptions( + baseUrl: AppConfig.apiBaseUrl, + sendTimeout: const Duration(seconds: 120), + receiveTimeout: const Duration(seconds: 300), + )); dio.options.headers['Authorization'] = 'Bearer $token'; for (final slot in _slots) { @@ -1674,29 +1688,56 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; }); continue; } + + Timer? processingTimer; + bool uploadDone = false; + try { final formData = FormData.fromMap({ 'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name), }); - final resp = await dio.post(slot.endpoint, data: formData); + final resp = await dio.post( + slot.endpoint, + data: formData, + onSendProgress: (sent, total) { + if (total <= 0 || !mounted) return; + if (sent >= total && !uploadDone) { + uploadDone = true; + setState(() { slot.isProcessing = true; slot.processingSeconds = 0; }); + processingTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => slot.processingSeconds++); + }); + } else if (!uploadDone) { + final pct = (sent / total * 100).round().clamp(0, 99); + if (mounted) setState(() => slot.uploadPercent = pct); + } + }, + ); + processingTimer?.cancel(); final data = (resp.data is Map) ? resp.data as Map : {}; if (mounted) setState(() { slot.success = true; slot.total = (data['total'] ?? data['imported'] ?? 0) as int; slot.imported = (data['imported'] ?? 0) as int; slot.skipped = (data['skipped'] ?? 0) as int; + slot.isProcessing = false; }); } on DioException catch (e) { + processingTimer?.cancel(); final raw = e.response?.data; final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误'; if (mounted) setState(() { slot.success = false; slot.error = msg.toString(); + slot.isProcessing = false; }); } catch (e) { + processingTimer?.cancel(); if (mounted) setState(() { slot.success = false; slot.error = e.toString(); + slot.isProcessing = false; }); } } @@ -2047,10 +2088,42 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { ); } } else if (_loading && slot.file != null) { - statusWidget = const SizedBox( - width: 14, height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ); + if (slot.isProcessing) { + statusWidget = Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 12, height: 12, + child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary), + ), + const SizedBox(width: 6), + Text( + '导入数据${slot.processingSeconds > 0 ? "(${slot.processingSeconds}秒)" : ""}', + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), + ), + ], + ); + } else { + statusWidget = Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 80, + child: LinearProgressIndicator( + value: slot.uploadPercent / 100, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation(AppTheme.primary), + minHeight: 6, + ), + ), + const SizedBox(width: 6), + Text( + '上传 ${slot.uploadPercent}%', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + ], + ); + } } return Padding( diff --git a/deploy/nginx-jiu.conf b/deploy/nginx-jiu.conf index 0d3508b..01677f9 100644 --- a/deploy/nginx-jiu.conf +++ b/deploy/nginx-jiu.conf @@ -16,6 +16,14 @@ server { add_header Cache-Control "public, immutable"; } + # 文件导入接口(超时更长) + location ~ ^/api/v1/import/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 300s; + } + # API 反向代理 location ~ ^/(api|health|version) { proxy_pass http://127.0.0.1:8080;