fix(backend): 库存导入用 Excel「入库日期」+ 同 key 增量更新/全同跳过
- 读取 Excel「入库日期」列(默认第12列,兼容入库日期/入库时间/入库日), 显式写入库存 CreatedAt,不再被 GORM 落成导入当天;新增 parseTimeWithFallback 兼容 yyyy-MM-dd / 含时分秒 / Excel 序列号 - 同 key(商品编码|仓库,回退 名+系列+规格|仓库):全字段一致则跳过, 仅差异字段增量更新;库存流水仅在 quantity 实际变化时追加 - 响应新增 skipped 计数;多租户 shop_id 隔离不变 go build / vet / test 全过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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, "供应商")
|
||||
|
||||
Reference in New Issue
Block a user