Files
wangjia 6fa91db517 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
2026-09-02 23:56:28 +08:00

343 lines
9.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}