diff --git a/.gitignore b/.gitignore index a7f322c..b182c14 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ coverage/ # 编译产物(后端可执行文件) backend/issue backend/gencode +backend/import-history +backend/import-inventory +backend/import-partner # 全局 todo skill 的本地数据(看板/任务,不入库) /todo/ diff --git a/backend/cmd/import-history/main.go b/backend/cmd/import-history/main.go index d8c0983..58661f6 100644 --- a/backend/cmd/import-history/main.go +++ b/backend/cmd/import-history/main.go @@ -58,9 +58,15 @@ func main() { var ( shopCode = flag.String("shop-code", "", "目标门店 code(与 shop-id 二选一)") shopID = flag.Uint64("shop-id", 0, "目标门店 id(与 shop-code 二选一)") - dataDir = flag.String("data-dir", "../data", "data 目录路径") + dataDir = flag.String("data-dir", "../data", "data 目录路径(默认 4 文件模式)") dryRun = flag.Bool("dry-run", false, "只统计不写库") envFile = flag.String("env-file", "", "可选:systemd 风格 KEY=VALUE 环境文件(用于远端读取 DATABASE_DSN,自行解析避免 shell 引号问题)") + + // 增量单文件模式(一个 xlsx 内含表头 + 明细两个 sheet,只导一个方向): + singleFile = flag.String("single-file", "", "增量单文件路径(含表头+明细两 sheet);设置后走单文件单方向模式") + singleDir = flag.String("single-dir", "", "单文件方向:in 入库 | out 出库") + headerSheet = flag.String("header-sheet", "0", "单文件模式:表头所在 sheet(名或从 0 起的索引),默认 0") + detailSheet = flag.String("detail-sheet", "1", "单文件模式:明细所在 sheet(名或从 0 起的索引),默认 1") ) flag.Parse() @@ -115,6 +121,24 @@ func main() { ic.ensureUsers() ic.ensurePlaceholderProduct() + if *singleFile != "" { + // ── 增量单文件单方向模式 ────────────────────────── + var isIn bool + switch *singleDir { + case "in": + isIn = true + case "out": + isIn = false + default: + log.Fatal("--single-file 模式必须指定 --single-dir in|out") + } + hSel := sheetSel{file: *singleFile, sheet: *headerSheet} + dSel := sheetSel{file: *singleFile, sheet: *detailSheet} + ic.importDocSel(hSel, dSel, isIn) + ic.report() + return + } + // ── 入库 ────────────────────────────────────────────── ic.importDoc( filepath.Join(*dataDir, "入库单总表.xlsx"), @@ -228,7 +252,10 @@ const placeholderProductCode = "HIST-PLACEHOLDER" // 自身快照列(product_name/series/spec),故该占位商品仅作引用、不参与展示。 func (ic *importCtx) ensurePlaceholderProduct() { var p model.Product - if ic.db.Where("shop_id = ? AND code = ? AND deleted_at IS NULL", ic.shopID, placeholderProductCode).First(&p).Error == nil { + // 不过滤 deleted_at:占位商品可能已被界面软删(shop8 id=4809 于 2026-06-20 软删), + // 但它仅作 stock_*_items.product_id 外键目标、不参与展示(展示走明细快照列)。 + // 复用软删行即可;若带 deleted_at IS NULL 会漏查 → 新建同 code 撞 uk_shop_code 唯一键报错。 + if ic.db.Where("shop_id = ? AND code = ?", ic.shopID, placeholderProductCode).First(&p).Error == nil { ic.placeholderProductID = p.ID return } @@ -357,19 +384,49 @@ func parseDateOrNow(s string) model.Date { return model.Date{Time: time.Now()} } +// sheetSel 定位一个工作表:file 为 xlsx 路径,sheet 为 sheet 名或从 0 起的索引字符串。 +type sheetSel struct { + file string + sheet string // sheet 名,或 "0"/"1"... 形式的索引 +} + // readSheet 读取首个工作表,返回表头列名→列索引映射 + 数据行。 func readSheet(path string) (map[string]int, [][]string) { - f, err := excelize.OpenFile(path) + return readSheetSel(sheetSel{file: path, sheet: "0"}) +} + +// readSheetSel 读取指定工作表(按名或索引),返回表头列名→列索引映射 + 数据行。 +func readSheetSel(sel sheetSel) (map[string]int, [][]string) { + f, err := excelize.OpenFile(sel.file) if err != nil { - log.Fatalf("打开 %s 失败: %v", path, err) + log.Fatalf("打开 %s 失败: %v", sel.file, err) } defer f.Close() - rows, err := f.GetRows(f.GetSheetName(0)) + + // sheet 定位:优先按名精确匹配,否则按数字索引(从 0 起)。 + sheetName := "" + names := f.GetSheetList() + for _, n := range names { + if n == sel.sheet { + sheetName = n + break + } + } + if sheetName == "" { + if idx, e := strconv.Atoi(strings.TrimSpace(sel.sheet)); e == nil && idx >= 0 && idx < len(names) { + sheetName = names[idx] + } + } + if sheetName == "" { + log.Fatalf("%s 找不到 sheet %q(可用: %v)", sel.file, sel.sheet, names) + } + + rows, err := f.GetRows(sheetName) if err != nil { - log.Fatalf("读取 %s 失败: %v", path, err) + log.Fatalf("读取 %s[%s] 失败: %v", sel.file, sheetName, err) } if len(rows) < 1 { - log.Fatalf("%s 为空", path) + log.Fatalf("%s[%s] 为空", sel.file, sheetName) } cols := map[string]int{} for i, name := range rows[0] { @@ -389,6 +446,10 @@ func get(row []string, cols map[string]int, name string) string { // ── 核心导入 ───────────────────────────────────────────── func (ic *importCtx) importDoc(headerPath, detailPath string, isIn bool) { + ic.importDocSel(sheetSel{file: headerPath, sheet: "0"}, sheetSel{file: detailPath, sheet: "0"}, isIn) +} + +func (ic *importCtx) importDocSel(hSel, dSel sheetSel, isIn bool) { label := "出库" orderType := "sale" partnerType := "customer" @@ -396,8 +457,8 @@ func (ic *importCtx) importDoc(headerPath, detailPath string, isIn bool) { label, orderType, partnerType = "入库", "purchase", "supplier" } - hCols, hRows := readSheet(headerPath) - dCols, dRows := readSheet(detailPath) + hCols, hRows := readSheetSel(hSel) + dCols, dRows := readSheetSel(dSel) // 明细按单据编号分组 detailsByNo := map[string][][]string{} diff --git a/backend/cmd/import-inventory/main.go b/backend/cmd/import-inventory/main.go new file mode 100644 index 0000000..b643d52 --- /dev/null +++ b/backend/cmd/import-inventory/main.go @@ -0,0 +1,446 @@ +// import-inventory —— 一次性:用 Excel 库存表【全量覆盖】某门店的当前库存。 +// +// 口径(已与用户确认): +// - 以【商品编号】为唯一键,用文件为准 reconcile 当前库存: +// 文件有·库无 → 新增(按编号建/复用 product + 建 inventory 行) +// 文件有·库有(非sold) → 以文件刷新数量与快照字段(有变才更新,全同不动) +// 文件无·库有(非sold) → 软删(deleted_at,可回滚),并记一笔 out 流水 +// sold(已售留痕) → 一律排除、保留不动(文件是当前盘点,本就不含已售) +// - 仓库列忽略:所有行统一落门店默认仓(如「鼎晟酒行」)。 +// - 只动 inventories / inventory_logs / products(新增缺失商品),不碰出入库单据。 +// - 覆盖仅作用于 import 来源库存(stock_in_item_id IS NULL),不动出入库派生的库存。 +// +// 用法(在 ali 上执行,--env-file 提供 DATABASE_DSN): +// +// ./import-inventory --shop-code S000008 --data-file 库存20260901.xls --env-file import.env --dry-run +// ./import-inventory --shop-code S000008 --data-file 库存20260901.xls --env-file import.env +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/shakinm/xlsReader/xls" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/model" +) + +func main() { + var ( + shopCode = flag.String("shop-code", "", "目标门店 code") + dataFile = flag.String("data-file", "", "库存 .xls 文件路径") + whName = flag.String("warehouse-name", "", "统一落入的仓库名(空=用门店默认仓)") + dryRun = flag.Bool("dry-run", false, "只统计不写库") + envFile = flag.String("env-file", "", "systemd 风格 KEY=VALUE 环境文件(提供 DATABASE_DSN)") + ) + flag.Parse() + + if *shopCode == "" || *dataFile == "" { + log.Fatal("必须提供 --shop-code 和 --data-file") + } + if *envFile != "" { + loadEnvFile(*envFile) + } + config.Load() + db, err := gorm.Open(mysql.Open(config.C.Database.DSN), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + log.Fatalf("连接数据库失败: %v", err) + } + + // ── 门店 ── + var shop model.Shop + if err := db.Where("code = ?", *shopCode).First(&shop).Error; err != nil { + log.Fatalf("门店 code=%s 不存在: %v", *shopCode, err) + } + // ── 目标仓库(统一落入)── + var wh model.Warehouse + q := db.Where("shop_id = ? AND deleted_at IS NULL", shop.ID) + if *whName != "" { + q = q.Where("name = ?", *whName) + } else { + q = q.Where("is_default = ?", true) + } + if err := q.First(&wh).Error; err != nil { + // 回退:该店任意仓 + if db.Where("shop_id = ? AND deleted_at IS NULL", shop.ID).Order("id ASC").First(&wh).Error != nil { + log.Fatalf("门店 %d 无可用仓库", shop.ID) + } + } + log.Printf("目标门店: %s (code=%s id=%d) | 统一仓库: %s (id=%d) | dry-run=%v", + shop.Name, shop.Code, shop.ID, wh.Name, wh.ID, *dryRun) + + // ── 读文件 ── + fileRows := readInventoryXls(*dataFile) + log.Printf("文件有效数据行(有商品名称): %d", len(fileRows)) + + // ── 预加载 DB 现有有效 import 库存 ── + var dbInvs []model.Inventory + db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shop.ID).Find(&dbInvs) + // 非 sold 行按编号索引(同一编号取一行;正常每编号一行) + dbByCode := map[string]*model.Inventory{} + soldCodes := map[string]bool{} + var soldKept int + for i := range dbInvs { + inv := &dbInvs[i] + if inv.Status == "sold" { + soldKept++ + soldCodes[inv.ProductCode] = true + continue // 已售留痕:排除在覆盖之外 + } + if inv.ProductCode != "" { + dbByCode[inv.ProductCode] = inv + } + } + + ic := &ctx{db: db, shopID: shop.ID, whID: wh.ID, whName: wh.Name, dryRun: *dryRun} + + fileCodes := map[string]bool{} + for _, fr := range fileRows { + if fr.code != "" { + fileCodes[fr.code] = true + } + existing := dbByCode[fr.code] + if fr.code == "" { + // 无编号行:按名称|系列|规格 无法可靠 reconcile,稳妥当作新增 + existing = nil + } + if existing != nil { + ic.updateIfChanged(existing, fr) + } else { + ic.insertNew(fr) + } + } + + // ── 软删:库有(非sold)·文件无 ── + for code, inv := range dbByCode { + if !fileCodes[code] { + ic.softDelete(inv) + } + } + + ic.report(soldKept) +} + +// ── 文件解析 ── + +type fileRow struct { + code, name, series, spec, unit string + qty, price float64 + prodDate, batch, supplier string + remark, stockInDate string +} + +func readInventoryXls(path string) []fileRow { + wb, err := xls.OpenFile(path) + if err != nil { + log.Fatalf("打开 %s 失败: %v", path, err) + } + sheet, err := wb.GetSheet(0) + if err != nil || sheet == nil { + log.Fatalf("读取 sheet 失败: %v", err) + } + numRows := sheet.GetNumberRows() + header, _ := sheet.GetRow(0) + numCols := len(header.GetCols()) + if numCols == 0 { + numCols = 20 + } + cell := func(r, c int) string { + row, _ := sheet.GetRow(r) + if row == nil { + return "" + } + cd, e := row.GetCol(c) + if e != nil || cd == nil { + return "" + } + return strings.TrimSpace(cd.GetString()) + } + // 动态列检测(镜像 handler.ImportInventory) + col := map[string]int{ + "code": 0, "name": 1, "series": 2, "spec": 3, "unit": 4, + "qty": 5, "price": 6, "prod": 8, "batch": 9, "wh": 11, "sin": 12, "sup": 13, "remark": 15, + } + for j := 0; j < numCols; j++ { + switch cell(0, j) { + case "商品编号", "商品编码", "编号", "编码", "商品条码": + col["code"] = j + case "商品名称", "品名", "名称", "货品名称": + col["name"] = j + case "系列", "品牌系列": + col["series"] = j + case "规格", "规格型号": + col["spec"] = j + case "单位": + col["unit"] = j + case "库存数量", "数量", "库存": + col["qty"] = j + case "单价", "进价", "采购单价": + col["price"] = j + case "生产日期", "生产年月": + col["prod"] = j + case "批次", "批次号", "批次/编号/物流码": + col["batch"] = j + case "入库日期", "入库时间", "入库日": + col["sin"] = j + case "供应商", "供应商名称": + col["sup"] = j + case "备注": + col["remark"] = j + } + } + get := func(r int, k string) string { return cell(r, col[k]) } + + var out []fileRow + for r := 1; r < numRows; r++ { + name := get(r, "name") + if name == "" { + continue // 空行 + } + qty := parseFloatLoose(get(r, "qty")) + if qty <= 0 { + qty = 1 + } + out = append(out, fileRow{ + code: get(r, "code"), name: name, series: get(r, "series"), spec: get(r, "spec"), + unit: get(r, "unit"), qty: qty, price: parseFloatLoose(get(r, "price")), + prodDate: get(r, "prod"), batch: get(r, "batch"), supplier: get(r, "sup"), + remark: get(r, "remark"), stockInDate: get(r, "sin"), + }) + } + return out +} + +func parseFloatLoose(s string) float64 { + s = strings.TrimSpace(strings.ReplaceAll(s, ",", "")) + if s == "" { + return 0 + } + f, _ := strconv.ParseFloat(s, 64) + return f +} + +var dateLayouts = []string{"2006-01-02", "2006/01/02", "2006-01-02 15:04:05", "2006/01/02 15:04:05"} + +func parseDatePtr(s string) *model.Date { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + for _, l := range dateLayouts { + if t, err := time.ParseInLocation(l, s, time.Local); err == nil { + return &model.Date{Time: t} + } + } + 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) + return &model.Date{Time: base.AddDate(0, 0, int(serial))} + } + return nil +} + +func parseTimeOrNow(s string) time.Time { + if d := parseDatePtr(s); d != nil { + return d.Time + } + return time.Now() +} + +// ── reconcile 上下文 ── + +type ctx struct { + db *gorm.DB + shopID, whID uint64 + whName string + dryRun bool + statInsert, statUpdate int + statUnchanged, statDelete int +} + +// findOrCreateProduct 按编号匹配/创建(编号即特有产品序列号,绝不按名称合并)。 +func (ic *ctx) findOrCreateProduct(fr fileRow) uint64 { + var p model.Product + if fr.code != "" { + if ic.db.Where("shop_id = ? AND code = ? AND deleted_at IS NULL", ic.shopID, fr.code).First(&p).Error == nil { + return p.ID + } + } else { + if ic.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL", + ic.shopID, fr.name, fr.series, fr.spec).First(&p).Error == nil { + return p.ID + } + } + if ic.dryRun { + return 0 + } + unit := fr.unit + if unit == "" { + unit = "瓶" + } + p = model.Product{ + TenantBase: model.TenantBase{ShopID: ic.shopID}, + PublicID: uuid.New().String(), + Code: fr.code, Name: fr.name, Series: fr.series, Spec: fr.spec, Unit: unit, + } + if err := ic.db.Create(&p).Error; err != nil { + log.Fatalf("建商品 %s(%s) 失败: %v", fr.name, fr.code, err) + } + return p.ID +} + +func (ic *ctx) insertNew(fr fileRow) { + ic.statInsert++ + if ic.dryRun { + return + } + prodID := ic.findOrCreateProduct(fr) + whID := ic.whID + var pricePtr *float64 + if fr.price != 0 { + pricePtr = &fr.price + } + inv := model.Inventory{ + ShopID: ic.shopID, WarehouseID: &whID, ProductID: &prodID, + Quantity: fr.qty, ProductCode: fr.code, ProductName: fr.name, + Series: fr.series, Spec: fr.spec, Unit: fr.unit, WarehouseName: ic.whName, + UnitPrice: pricePtr, ProductionDate: parseDatePtr(fr.prodDate), BatchNo: fr.batch, + SupplierName: fr.supplier, Remark: fr.remark, CreatedAt: parseTimeOrNow(fr.stockInDate), + } + if err := ic.db.Create(&inv).Error; err != nil { + log.Fatalf("建库存 %s 失败: %v", fr.code, err) + } + ic.db.Create(&model.InventoryLog{ + ShopID: ic.shopID, WarehouseID: whID, ProductID: prodID, + Direction: "in", Quantity: fr.qty, QtyBefore: 0, QtyAfter: fr.qty, + RefType: "import_replace", RefID: 0, + }) +} + +func (ic *ctx) updateIfChanged(inv *model.Inventory, fr fileRow) { + updates := map[string]interface{}{} + if inv.Quantity != fr.qty { + updates["quantity"] = fr.qty + } + if inv.ProductName != fr.name { + updates["product_name"] = fr.name + } + if inv.Series != fr.series { + updates["series"] = fr.series + } + if inv.Spec != fr.spec { + updates["spec"] = fr.spec + } + if fr.unit != "" && inv.Unit != fr.unit { + updates["unit"] = fr.unit + } + if inv.WarehouseName != ic.whName { + updates["warehouse_name"] = ic.whName + updates["warehouse_id"] = ic.whID + } + if inv.BatchNo != fr.batch { + updates["batch_no"] = fr.batch + } + if inv.SupplierName != fr.supplier { + updates["supplier_name"] = fr.supplier + } + if inv.Remark != fr.remark { + updates["remark"] = fr.remark + } + if fr.price != 0 && (inv.UnitPrice == nil || *inv.UnitPrice != fr.price) { + updates["unit_price"] = fr.price + } + fd := parseDatePtr(fr.prodDate) + if fd != nil && (inv.ProductionDate == nil || !inv.ProductionDate.Time.Equal(fd.Time)) { + updates["production_date"] = fd + } + + if len(updates) == 0 { + ic.statUnchanged++ + return + } + ic.statUpdate++ + if ic.dryRun { + return + } + qtyBefore := inv.Quantity + if err := ic.db.Model(inv).Updates(updates).Error; err != nil { + log.Fatalf("更新库存 %s 失败: %v", inv.ProductCode, err) + } + if _, ok := updates["quantity"]; ok && inv.ProductID != nil { + ic.db.Create(&model.InventoryLog{ + ShopID: ic.shopID, WarehouseID: ic.whID, ProductID: *inv.ProductID, + Direction: "in", Quantity: fr.qty, QtyBefore: qtyBefore, QtyAfter: fr.qty, + RefType: "import_replace", RefID: 0, + }) + } +} + +func (ic *ctx) softDelete(inv *model.Inventory) { + ic.statDelete++ + if ic.dryRun { + return + } + now := time.Now() + if err := ic.db.Model(inv).Update("deleted_at", now).Error; err != nil { + log.Fatalf("软删库存 %s 失败: %v", inv.ProductCode, err) + } + if inv.ProductID != nil { + ic.db.Create(&model.InventoryLog{ + ShopID: ic.shopID, WarehouseID: ic.whID, ProductID: *inv.ProductID, + Direction: "out", Quantity: inv.Quantity, QtyBefore: inv.Quantity, QtyAfter: 0, + RefType: "import_replace", RefID: 0, + }) + } +} + +func (ic *ctx) report(soldKept int) { + fmt.Println("═══════════════════════════════════════════") + fmt.Println(" 库存覆盖导入汇总") + if ic.dryRun { + fmt.Println(" *** DRY-RUN(未写库)***") + } + fmt.Println("═══════════════════════════════════════════") + fmt.Printf(" 新增(文件有·库无) : %d\n", ic.statInsert) + fmt.Printf(" 更新(库有·字段有变) : %d\n", ic.statUpdate) + fmt.Printf(" 不变(库有·全同) : %d\n", ic.statUnchanged) + fmt.Printf(" 软删(文件无·库有非sold) : %d\n", ic.statDelete) + fmt.Printf(" 保留不动(sold 已售留痕) : %d\n", soldKept) + fmt.Printf(" ── 覆盖后在库/在售行 = 新增+更新+不变 = %d\n", ic.statInsert+ic.statUpdate+ic.statUnchanged) + fmt.Println("═══════════════════════════════════════════") +} + +// loadEnvFile 解析 systemd EnvironmentFile 风格 KEY=VALUE,写入进程环境。 +func loadEnvFile(path string) { + data, err := os.ReadFile(path) + if err != nil { + log.Fatalf("读取 env 文件 %s 失败: %v", path, err) + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + eq := strings.IndexByte(line, '=') + if eq <= 0 { + continue + } + key := strings.TrimSpace(line[:eq]) + val := strings.TrimSpace(line[eq+1:]) + if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] { + val = val[1 : len(val)-1] + } + _ = os.Setenv(key, val) + } +} diff --git a/backend/cmd/import-partner/main.go b/backend/cmd/import-partner/main.go new file mode 100644 index 0000000..d63d402 --- /dev/null +++ b/backend/cmd/import-partner/main.go @@ -0,0 +1,342 @@ +// import-partner —— 把往来单位 Excel 导入某门店(按【名称】reconcile)。 +// +// 列按【表头名】动态匹配(兼容"往来名称20260901.xls"多出的"当前金额"列位移)。 +// 模式: +// incremental(默认):文件有·库无 → 新增;已存在 → 视 --update 决定是否刷字段;库有·文件无 → 不动。 +// overwrite :在 incremental 基础上,把"库有·文件无"的往来单位软删(deleted_at,可回滚)。 +// +// 金额:默认不动 Balance(往来余额由系统按单据累计,避免冲突);--with-balance 时用文件"当前金额"覆盖。 +// +// 用法: +// ./import-partner --shop-code S000008 --data-file p.xls --env-file import.env --dry-run +// ./import-partner --shop-code S000008 --data-file p.xls --env-file import.env --mode overwrite --update +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/shakinm/xlsReader/xls" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/util" +) + +func main() { + var ( + shopCode = flag.String("shop-code", "", "目标门店 code") + dataFile = flag.String("data-file", "", "往来单位 .xls 路径") + mode = flag.String("mode", "incremental", "incremental | overwrite") + update = flag.Bool("update", false, "已存在的往来单位是否用文件刷新字段") + withBalance = flag.Bool("with-balance", false, "用文件『当前金额』覆盖 Balance(默认不动)") + dryRun = flag.Bool("dry-run", false, "只统计不写库") + envFile = flag.String("env-file", "", "KEY=VALUE 环境文件(提供 DATABASE_DSN)") + ) + flag.Parse() + if *shopCode == "" || *dataFile == "" { + log.Fatal("必须提供 --shop-code 和 --data-file") + } + if *mode != "incremental" && *mode != "overwrite" { + log.Fatal("--mode 只能是 incremental 或 overwrite") + } + if *envFile != "" { + loadEnvFile(*envFile) + } + config.Load() + db, err := gorm.Open(mysql.Open(config.C.Database.DSN), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + log.Fatalf("连接数据库失败: %v", err) + } + + var shop model.Shop + if err := db.Where("code = ?", *shopCode).First(&shop).Error; err != nil { + log.Fatalf("门店 code=%s 不存在: %v", *shopCode, err) + } + log.Printf("目标门店: %s (code=%s id=%d) | mode=%s update=%v with-balance=%v dry-run=%v", + shop.Name, shop.Code, shop.ID, *mode, *update, *withBalance, *dryRun) + + fileRows := readPartnerXls(*dataFile) + log.Printf("文件有效数据行(有名称,去重后): %d", len(fileRows)) + + var dbAll []model.Partner + db.Where("shop_id = ? AND deleted_at IS NULL", shop.ID).Find(&dbAll) + dbByName := map[string]*model.Partner{} + for i := range dbAll { + dbByName[dbAll[i].Name] = &dbAll[i] + } + log.Printf("库现有往来单位(未删): %d", len(dbAll)) + + ic := &pctx{db: db, shopID: shop.ID, dryRun: *dryRun, update: *update, withBalance: *withBalance} + fileNames := map[string]bool{} + for _, fr := range fileRows { + fileNames[fr.name] = true + if ex := dbByName[fr.name]; ex != nil { + ic.onExisting(ex, fr) + } else { + ic.insertNew(fr) + } + } + if *mode == "overwrite" { + for name, p := range dbByName { + if !fileNames[name] { + ic.softDelete(p) + } + } + } else { + // 仅统计库有·文件无的数量(informational) + for name := range dbByName { + if !fileNames[name] { + ic.statFileMissing++ + } + } + } + ic.report(*mode) +} + +type pRow struct { + code, ptype, status, name, phone, bankAcct, addr, remark string + initAmt, curAmt float64 +} + +func readPartnerXls(path string) []pRow { + wb, err := xls.OpenFile(path) + if err != nil { + log.Fatalf("打开失败: %v", err) + } + sheet, err := wb.GetSheet(0) + if err != nil || sheet == nil { + log.Fatalf("读 sheet 失败: %v", err) + } + n := sheet.GetNumberRows() + hdr, _ := sheet.GetRow(0) + numCols := len(hdr.GetCols()) + cell := func(r, c int) string { + row, _ := sheet.GetRow(r) + if row == nil { + return "" + } + cd, e := row.GetCol(c) + if e != nil || cd == nil { + return "" + } + return strings.TrimSpace(cd.GetString()) + } + col := map[string]int{} + for j := 0; j < numCols; j++ { + switch cell(0, j) { + case "编号", "编码": + col["code"] = j + case "类型": + col["type"] = j + case "状态": + col["status"] = j + case "名称", "往来单位", "往来单位名称", "单位名称": + col["name"] = j + case "电话", "联系电话", "手机": + col["phone"] = j + case "卡号", "银行卡号", "开户行": + col["bank"] = j + case "初始金额", "期初金额": + col["init"] = j + case "当前金额", "余额", "当前余额": + col["cur"] = j + case "地址": + col["addr"] = j + case "备注": + col["remark"] = j + } + } + get := func(r int, k string) string { + if c, ok := col[k]; ok { + return cell(r, c) + } + return "" + } + seen := map[string]bool{} + var out []pRow + for r := 1; r < n; r++ { + name := get(r, "name") + if name == "" || seen[name] { + continue // 空行或文件内重名去重(保留首次) + } + seen[name] = true + out = append(out, pRow{ + code: get(r, "code"), ptype: get(r, "type"), status: get(r, "status"), name: name, + phone: get(r, "phone"), bankAcct: get(r, "bank"), addr: get(r, "addr"), remark: get(r, "remark"), + initAmt: parseFloatLoose(get(r, "init")), curAmt: parseFloatLoose(get(r, "cur")), + }) + } + return out +} + +func parseFloatLoose(s string) float64 { + s = strings.TrimSpace(strings.ReplaceAll(s, ",", "")) + if s == "" { + return 0 + } + f, _ := strconv.ParseFloat(s, 64) + return f +} + +func parsePartnerType(raw string) string { + hasCust := strings.Contains(raw, "客户") + hasSupp := strings.Contains(raw, "供应商") + switch { + case hasCust && hasSupp: + return "supplier,customer" + case hasCust: + return "customer" + default: + return "supplier" + } +} + +type pctx struct { + db *gorm.DB + shopID uint64 + dryRun, update, withBalance bool + statInsert, statUpdate int + statUnchanged, statDelete int + statFileMissing int +} + +func (ic *pctx) insertNew(fr pRow) { + ic.statInsert++ + if ic.dryRun { + return + } + status := "enabled" + if fr.status == "禁用" { + status = "disabled" + } + bal := 0.0 + if ic.withBalance { + bal = fr.curAmt + } + pinyin, initials := util.ToPinyin(fr.name) + p := model.Partner{ + TenantBase: model.TenantBase{ShopID: ic.shopID}, + Code: fr.code, + Name: fr.name, + NamePinyin: pinyin, + NameInitials: initials, + Type: parsePartnerType(fr.ptype), + Status: status, + Phone: fr.phone, + BankAccount: fr.bankAcct, + Balance: bal, + Address: fr.addr, + Remark: fr.remark, + } + if err := ic.db.Create(&p).Error; err != nil { + log.Fatalf("建往来单位 %s 失败: %v", fr.name, err) + } +} + +func (ic *pctx) onExisting(p *model.Partner, fr pRow) { + if !ic.update { + ic.statUnchanged++ + return + } + updates := map[string]interface{}{} + if fr.code != "" && p.Code != fr.code { + updates["code"] = fr.code + } + if t := parsePartnerType(fr.ptype); p.Type != t { + updates["type"] = t + } + st := "enabled" + if fr.status == "禁用" { + st = "disabled" + } + if p.Status != st { + updates["status"] = st + } + if fr.phone != "" && p.Phone != fr.phone { + updates["phone"] = fr.phone + } + if fr.bankAcct != "" && p.BankAccount != fr.bankAcct { + updates["bank_account"] = fr.bankAcct + } + if fr.addr != "" && p.Address != fr.addr { + updates["address"] = fr.addr + } + if fr.remark != "" && p.Remark != fr.remark { + updates["remark"] = fr.remark + } + if ic.withBalance && p.Balance != fr.curAmt { + updates["balance"] = fr.curAmt + } + if len(updates) == 0 { + ic.statUnchanged++ + return + } + ic.statUpdate++ + if ic.dryRun { + return + } + if err := ic.db.Model(p).Updates(updates).Error; err != nil { + log.Fatalf("更新往来单位 %s 失败: %v", fr.name, err) + } +} + +func (ic *pctx) softDelete(p *model.Partner) { + ic.statDelete++ + if ic.dryRun { + return + } + if err := ic.db.Model(p).Update("deleted_at", time.Now()).Error; err != nil { + log.Fatalf("软删往来单位 %s 失败: %v", p.Name, err) + } +} + +func (ic *pctx) report(mode string) { + fmt.Println("═══════════════════════════════════════════") + fmt.Println(" 往来单位导入汇总") + if ic.dryRun { + fmt.Println(" *** DRY-RUN(未写库)***") + } + fmt.Printf(" 模式: %s\n", mode) + fmt.Println("═══════════════════════════════════════════") + fmt.Printf(" 新增(文件有·库无) : %d\n", ic.statInsert) + fmt.Printf(" 更新(已存在·字段有变) : %d\n", ic.statUpdate) + fmt.Printf(" 不变(已存在) : %d\n", ic.statUnchanged) + if mode == "overwrite" { + fmt.Printf(" 软删(库有·文件无) : %d\n", ic.statDelete) + } else { + fmt.Printf(" 库有·文件无(保留不动) : %d\n", ic.statFileMissing) + } + fmt.Println("═══════════════════════════════════════════") +} + +func loadEnvFile(path string) { + data, err := os.ReadFile(path) + if err != nil { + log.Fatalf("读取 env 文件失败: %v", err) + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + eq := strings.IndexByte(line, '=') + if eq <= 0 { + continue + } + key := strings.TrimSpace(line[:eq]) + val := strings.TrimSpace(line[eq+1:]) + if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] { + val = val[1 : len(val)-1] + } + _ = os.Setenv(key, val) + } +} diff --git a/backend/import-history b/backend/import-history deleted file mode 100755 index 5517121..0000000 Binary files a/backend/import-history and /dev/null differ