feat: 库存 XLS 导入功能(后端接口 + 前端导入按钮)
This commit is contained in:
@@ -468,6 +468,134 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "items": len(items)})
|
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) {
|
func parseUploadedExcel(c *gin.Context) ([][]string, error) {
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
imp.POST("/product-specs", importH.ImportProductSpecs)
|
imp.POST("/product-specs", importH.ImportProductSpecs)
|
||||||
imp.POST("/stock-in", importH.ImportStockIn)
|
imp.POST("/stock-in", importH.ImportStockIn)
|
||||||
imp.POST("/stock-out", importH.ImportStockOut)
|
imp.POST("/stock-out", importH.ImportStockOut)
|
||||||
|
imp.POST("/inventory", importH.ImportInventory)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基础数据选项(名称/系列/规格)
|
// 基础数据选项(名称/系列/规格)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/inventory.dart';
|
import '../../models/inventory.dart';
|
||||||
|
import '../../core/auth/auth_state.dart';
|
||||||
import '../../providers/inventory_provider.dart';
|
import '../../providers/inventory_provider.dart';
|
||||||
import '../../widgets/data_table_card.dart';
|
import '../../widgets/data_table_card.dart';
|
||||||
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
|
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
|
||||||
@@ -37,6 +41,55 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<String, dynamic>;
|
||||||
|
final imported = data['imported'] ?? 0;
|
||||||
|
final skipped = data['skipped'] ?? 0;
|
||||||
|
final errors = (data['errors'] as List?)?.cast<String>() ?? [];
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PageScaffold(
|
return PageScaffold(
|
||||||
@@ -144,6 +197,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
label: const Text('发起盘点'),
|
label: const Text('发起盘点'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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(
|
OutlinedButton.icon(
|
||||||
onPressed: () => exportExcel(
|
onPressed: () => exportExcel(
|
||||||
filename: '库存查询',
|
filename: '库存查询',
|
||||||
|
|||||||
@@ -1449,6 +1449,8 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
'格式:选项编号 | 选项名称 | 备注'),
|
'格式:选项编号 | 选项名称 | 备注'),
|
||||||
_ImportSlot('商品规格', '/import/product-specs',
|
_ImportSlot('商品规格', '/import/product-specs',
|
||||||
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
|
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
|
||||||
|
_ImportSlot('库存', '/import/inventory',
|
||||||
|
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user