diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index 75074f5..ff1aaee 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -496,6 +496,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { total int imported int updated int + skipped int errors []string } var res importResult @@ -560,6 +561,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { // Dynamic column detection from header row colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4 colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15 + colStockInDate := 12 if len(rows) > 0 { log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0]) for j, h := range rows[0] { @@ -580,8 +582,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { colPrice = j case "生产日期", "生产年月": colProductionDate = j - case "批次", "批次号": + case "批次", "批次号", "批次/编号/物流码": colBatchNo = j + case "入库日期", "入库时间", "入库日": + colStockInDate = j case "所在仓库", "仓库", "库位": colWarehouse = j case "供应商", "供应商名称": @@ -620,6 +624,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { warehouseName := cell(row, colWarehouse) supplierName := cell(row, colSupplier) remark := cell(row, colRemark) + stockInDateStr := cell(row, colStockInDate) qty, _ := strconv.ParseFloat(qtyStr, 64) if qty <= 0 { @@ -662,6 +667,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { productionDate = &d } + // 解析入库日期:用 Excel 里的值作为库存记录的入库时间(CreatedAt), + // 解析失败或为空时回退到当前时间。 + stockInTime, _ := parseTimeWithFallback(stockInDateStr, time.Now()) + whIDPtr := findWarehouse(warehouseName) var unitPricePtr *float64 @@ -677,35 +686,93 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal) 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, + // 去重/增量更新:逐字段对比 Excel 行与库内现有记录, + // 仅把「不一致」的字段放进 updates;全部一致则跳过不写库。 + updates := map[string]interface{}{} + + if existing.Quantity != qty { + updates["quantity"] = qty } - if unitPricePtr != nil { - updates["unit_price"] = *unitPricePtr + if existing.ProductName != prod.Name { + updates["product_name"] = prod.Name } - if productionDate != nil { - updates["production_date"] = productionDate + if existing.Series != prod.Series { + updates["series"] = prod.Series } - if batchNo != "" { + if existing.Spec != prod.Spec { + updates["spec"] = prod.Spec + } + if existing.Unit != prod.Unit { + updates["unit"] = prod.Unit + } + if existing.WarehouseName != warehouseName { + updates["warehouse_name"] = warehouseName + } + if existing.SupplierName != supplierName { + updates["supplier_name"] = supplierName + } + if existing.Remark != remark { + updates["remark"] = remark + } + if existing.BatchNo != batchNo { updates["batch_no"] = batchNo } + // 单价:仅当 Excel 提供了非零单价且与现有不同才更新 + if unitPricePtr != nil { + if existing.UnitPrice == nil || *existing.UnitPrice != *unitPricePtr { + updates["unit_price"] = *unitPricePtr + } + } + // 生产日期:仅当 Excel 提供了值且与现有不同才更新 + if productionDate != nil { + if existing.ProductionDate == nil || + !existing.ProductionDate.Time.Equal(productionDate.Time) { + updates["production_date"] = productionDate + } + } + // 入库时间(created_at):与现有不同才更新 + if !existing.CreatedAt.Equal(stockInTime) { + updates["created_at"] = stockInTime + } + + if len(updates) == 0 { + // 所有字段都一致,跳过(不写库、不写流水) + res.skipped++ + continue + } + + qtyBefore := existing.Quantity 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, - }) + // 仅在库存数量变化时才记一笔流水(用更新前的数量作为 QtyBefore) + if _, ok := updates["quantity"]; ok { + logsToCreate = append(logsToCreate, model.InventoryLog{ + ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID, + Direction: "in", Quantity: qty, QtyBefore: qtyBefore, QtyAfter: qty, + RefType: "import", RefID: 0, + }) + } + // 同步缓存,避免同文件后续行重复比对到旧值 + existing.Quantity = qty + existing.ProductName = prod.Name + existing.Series = prod.Series + existing.Spec = prod.Spec + existing.Unit = prod.Unit + existing.WarehouseName = warehouseName + existing.SupplierName = supplierName + existing.Remark = remark + existing.BatchNo = batchNo + if _, ok := updates["unit_price"]; ok { + existing.UnitPrice = unitPricePtr + } + if _, ok := updates["production_date"]; ok { + existing.ProductionDate = productionDate + } + if _, ok := updates["created_at"]; ok { + existing.CreatedAt = stockInTime + } res.updated++ } else { productIDCopy := prod.ID @@ -726,6 +793,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { BatchNo: batchNo, SupplierName: supplierName, Remark: remark, + CreatedAt: stockInTime, // 用 Excel「入库日期」作为入库时间 } if err := h.db.Create(&inv).Error; err != nil { res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error())) @@ -756,13 +824,14 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader)) } - log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d", - res.total, res.imported, res.updated, len(res.errors)) + log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d skipped=%d errors=%d", + res.total, res.imported, res.updated, res.skipped, len(res.errors)) c.JSON(http.StatusOK, gin.H{ "total": res.total, "imported": res.imported, "updated": res.updated, + "skipped": res.skipped, "errors": res.errors, }) } @@ -922,6 +991,38 @@ func parseDate(s string) model.Date { return model.Date{Time: t} } +// parseTimeWithFallback 解析「入库日期」等列的时间值。 +// 兼容:yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy/MM/dd、Excel 序列号。 +// 解析失败或为空时返回 fallback(通常为 time.Now()),ok=false。 +func parseTimeWithFallback(s string, fallback time.Time) (t time.Time, ok bool) { + s = strings.TrimSpace(s) + if s == "" { + return fallback, false + } + layouts := []string{ + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + "2006-01-02 15:04", + "2006-01-02", + "2006/01/02 15:04:05", + "2006/01/02", + } + for _, layout := range layouts { + if parsed, err := time.ParseInLocation(layout, s, time.Local); err == nil { + return parsed, true + } + } + // Excel 序列号(自 1899-12-30 起的天数,可带小数表示时间) + if serial, err := strconv.ParseFloat(s, 64); err == nil && serial > 0 && serial < 100000 { + base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.Local) + days := int(serial) + frac := serial - float64(days) + parsed := base.AddDate(0, 0, days).Add(time.Duration(frac * 24 * float64(time.Hour))) + return parsed, true + } + return fallback, false +} + func parsePartnerType(raw string) string { hasCust := strings.Contains(raw, "客户") hasSupp := strings.Contains(raw, "供应商")