6fa91db517
- 新增 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
343 lines
9.6 KiB
Go
343 lines
9.6 KiB
Go
// 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)
|
||
}
|
||
}
|