chore(backend): 增量导入一次性工具 + 忽略编译二进制
- 新增 cmd/import-partner(往来单位按名 reconcile,单文件动态列)
- 新增 cmd/import-inventory(库存按商品编号全量覆盖 reconcile)
- import-history 支持单文件双 sheet 单方向模式(--single-file/--single-dir),
占位商品定位去 deleted_at 过滤(复用软删占位,避免撞 uk_shop_code)
- .gitignore 忽略 backend/import-{history,inventory,partner} 编译产物,移除误提交的二进制
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
This commit is contained in:
@@ -56,6 +56,9 @@ coverage/
|
||||
# 编译产物(后端可执行文件)
|
||||
backend/issue
|
||||
backend/gencode
|
||||
backend/import-history
|
||||
backend/import-inventory
|
||||
backend/import-partner
|
||||
|
||||
# 全局 todo skill 的本地数据(看板/任务,不入库)
|
||||
/todo/
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user