cockpit-source/backend/internal/importer/orders.go
2026-04-02 14:12:43 +08:00

631 lines
16 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.

package importer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"strconv"
"strings"
"time"
"cockpit/internal/domain"
"github.com/shopspring/decimal"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
)
const OrdersSheetName = "订单导入"
// 生成 Excel 模板xlsx bytes
func BuildOrdersTemplate() ([]byte, error) {
f := excelize.NewFile()
defer func() { _ = f.Close() }()
sheet := OrdersSheetName
f.SetSheetName("Sheet1", sheet)
headers := []string{"订单号", "下单日期", "出货日期", "下单金额", "订单金额", "客户名", "备注", "状态"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
_ = f.SetCellValue(sheet, cell, h)
}
// 示例行(可删)
example := []any{"A20260401001", "2026-04-01", "2026-04-05", "1234.50", "1200.00", "示例客户", "备注示例", "已出货"}
for i, v := range example {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
_ = f.SetCellValue(sheet, cell, v)
}
_ = f.SetColWidth(sheet, "A", "H", 18)
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
type OrderInput struct {
OrderNo string `json:"order_no"`
OrderDate string `json:"order_date"`
ShipDate string `json:"ship_date"`
AmountA string `json:"amount_a"`
AmountB string `json:"amount_b"`
CustomerName string `json:"customer_name"`
Remark string `json:"remark"`
StatusName string `json:"status_name"`
}
type OrderClean struct {
OrderNo string
OrderDate time.Time
ShipDate *time.Time
AmountA decimal.Decimal
AmountB decimal.Decimal
CustomerID uint64
StatusID uint64
Remark string
CustomerName string // 仅用于预览回显
StatusName string // 仅用于预览回显
}
type RowError struct {
RowNo int `json:"rowNo"`
Field string `json:"field"`
Message string `json:"message"`
RawData any `json:"rawData,omitempty"`
}
func ParseOrdersExcel(ctx context.Context, db *gorm.DB, file multipart.File, previewLimit int) (total int, preview []OrderClean, errors []RowError, err error) {
data, err := io.ReadAll(file)
if err != nil {
return 0, nil, nil, err
}
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return 0, nil, nil, err
}
defer func() { _ = f.Close() }()
rows, err := f.GetRows(OrdersSheetName)
if err != nil || len(rows) == 0 {
// 兼容用户把 sheet 名改掉:取第一个 sheet
names := f.GetSheetList()
if len(names) == 0 {
return 0, nil, nil, fmt.Errorf("Excel 无可用 sheet")
}
rows, err = f.GetRows(names[0])
if err != nil {
return 0, nil, nil, err
}
}
if len(rows) < 2 {
return 0, nil, nil, fmt.Errorf("Excel 至少需要表头+1行数据")
}
header := rows[0]
idx := headerIndex(header)
// 缓存客户/状态映射
customerMap, err := loadCustomerMap(ctx, db)
if err != nil {
return 0, nil, nil, err
}
statusMap, err := loadStatusMap(ctx, db)
if err != nil {
return 0, nil, nil, err
}
for r := 1; r < len(rows); r++ {
total++
raw := rowToOrderInput(rows[r], idx)
clean, rowErrs := cleanOneOrder(raw, r+1, customerMap, statusMap)
if len(rowErrs) > 0 {
errors = append(errors, rowErrs...)
} else if len(preview) < previewLimit {
preview = append(preview, clean)
}
}
return total, preview, errors, nil
}
func ParseOrdersJSON(ctx context.Context, db *gorm.DB, body []byte, previewLimit int) (total int, preview []OrderClean, errors []RowError, err error) {
var inputs []OrderInput
if err := json.Unmarshal(body, &inputs); err != nil {
return 0, nil, nil, err
}
customerMap, err := loadCustomerMap(ctx, db)
if err != nil {
return 0, nil, nil, err
}
statusMap, err := loadStatusMap(ctx, db)
if err != nil {
return 0, nil, nil, err
}
for i, in := range inputs {
total++
clean, rowErrs := cleanOneOrder(in, i+1, customerMap, statusMap)
if len(rowErrs) > 0 {
errors = append(errors, rowErrs...)
} else if len(preview) < previewLimit {
preview = append(preview, clean)
}
}
return total, preview, errors, nil
}
func CommitOrders(ctx context.Context, db *gorm.DB, inputs []OrderClean, createdBy uint64, inputFmt, fileName string) (*domain.ImportJob, error) {
now := time.Now()
job := domain.ImportJob{
Type: "orders",
InputFmt: inputFmt,
Status: "running",
FileName: fileName,
TotalRows: len(inputs),
StartedAt: &now,
CreatedBy: createdBy,
}
if err := db.WithContext(ctx).Create(&job).Error; err != nil {
return nil, err
}
ok := 0
fail := 0
for _, in := range inputs {
order := domain.Order{
OrderNo: in.OrderNo,
OrderDate: in.OrderDate,
ShipDate: in.ShipDate,
AmountA: in.AmountA,
AmountB: in.AmountB,
CustomerID: in.CustomerID,
StatusID: in.StatusID,
Remark: in.Remark,
Source: "import",
CreatedBy: createdBy,
}
if err := db.WithContext(ctx).Create(&order).Error; err != nil {
fail++
_ = db.WithContext(ctx).Create(&domain.ImportJobError{
JobID: job.ID,
RowNo: 0,
Field: "order_no",
Message: "入库失败:" + err.Error(),
RawData: "",
}).Error
continue
}
ok++
}
job.OKRows = ok
job.FailRows = fail
finished := time.Now()
job.FinishedAt = &finished
if fail == 0 {
job.Status = "success"
} else if ok == 0 {
job.Status = "failed"
} else {
job.Status = "partial"
}
_ = db.WithContext(ctx).Save(&job).Error
return &job, nil
}
// CommitOrdersFromExcel解析 Excel 并直接落库,返回 import_job
func CommitOrdersFromExcel(ctx context.Context, db *gorm.DB, file multipart.File, createdBy uint64, fileName string) (*domain.ImportJob, error) {
data, err := io.ReadAll(file)
if err != nil {
return nil, err
}
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
rows, err := f.GetRows(OrdersSheetName)
if err != nil || len(rows) == 0 {
names := f.GetSheetList()
if len(names) == 0 {
return nil, fmt.Errorf("Excel 无可用 sheet")
}
rows, err = f.GetRows(names[0])
if err != nil {
return nil, err
}
}
if len(rows) < 2 {
return nil, fmt.Errorf("Excel 至少需要表头+1行数据")
}
customerMap, err := loadCustomerMap(ctx, db)
if err != nil {
return nil, err
}
statusMap, err := loadStatusMap(ctx, db)
if err != nil {
return nil, err
}
now := time.Now()
job := domain.ImportJob{
Type: "orders",
InputFmt: "excel",
Status: "running",
FileName: fileName,
StartedAt: &now,
CreatedBy: createdBy,
}
if err := db.WithContext(ctx).Create(&job).Error; err != nil {
return nil, err
}
idx := headerIndex(rows[0])
seen := map[string]bool{}
total := 0
ok := 0
fail := 0
for r := 1; r < len(rows); r++ {
total++
raw := rowToOrderInput(rows[r], idx)
rawErrs := []RowError{}
if strings.TrimSpace(raw.OrderNo) != "" && seen[strings.TrimSpace(raw.OrderNo)] {
rawErrs = append(rawErrs, RowError{RowNo: r + 1, Field: "order_no", Message: "文件内订单号重复", RawData: raw})
}
seen[strings.TrimSpace(raw.OrderNo)] = true
clean, errs := cleanOneOrder(raw, r+1, customerMap, statusMap)
errs = append(errs, rawErrs...)
if len(errs) > 0 {
fail++
for _, e := range errs {
b, _ := json.Marshal(e.RawData)
_ = db.WithContext(ctx).Create(&domain.ImportJobError{
JobID: job.ID,
RowNo: e.RowNo,
Field: e.Field,
Message: e.Message,
RawData: string(b),
}).Error
}
continue
}
// DB 去重
var exists int64
_ = db.WithContext(ctx).Model(&domain.Order{}).Where("order_no = ?", clean.OrderNo).Count(&exists).Error
if exists > 0 {
fail++
_ = db.WithContext(ctx).Create(&domain.ImportJobError{
JobID: job.ID,
RowNo: r + 1,
Field: "order_no",
Message: "订单号已存在(与数据库冲突)",
RawData: "",
}).Error
continue
}
order := domain.Order{
OrderNo: clean.OrderNo,
OrderDate: clean.OrderDate,
ShipDate: clean.ShipDate,
AmountA: clean.AmountA,
AmountB: clean.AmountB,
CustomerID: clean.CustomerID,
StatusID: clean.StatusID,
Remark: clean.Remark,
Source: "import",
CreatedBy: createdBy,
}
if err := db.WithContext(ctx).Create(&order).Error; err != nil {
fail++
_ = db.WithContext(ctx).Create(&domain.ImportJobError{
JobID: job.ID,
RowNo: r + 1,
Field: "order_no",
Message: "入库失败:" + err.Error(),
RawData: "",
}).Error
continue
}
ok++
}
job.TotalRows = total
job.OKRows = ok
job.FailRows = fail
finished := time.Now()
job.FinishedAt = &finished
if fail == 0 {
job.Status = "success"
} else if ok == 0 {
job.Status = "failed"
} else {
job.Status = "partial"
}
_ = db.WithContext(ctx).Save(&job).Error
return &job, nil
}
// CommitOrdersFromJSON解析 JSON 并直接落库,返回 import_job
func CommitOrdersFromJSON(ctx context.Context, db *gorm.DB, body []byte, createdBy uint64) (*domain.ImportJob, error) {
var inputs []OrderInput
if err := json.Unmarshal(body, &inputs); err != nil {
return nil, err
}
customerMap, err := loadCustomerMap(ctx, db)
if err != nil {
return nil, err
}
statusMap, err := loadStatusMap(ctx, db)
if err != nil {
return nil, err
}
now := time.Now()
job := domain.ImportJob{
Type: "orders",
InputFmt: "json",
Status: "running",
StartedAt: &now,
CreatedBy: createdBy,
TotalRows: len(inputs),
}
if err := db.WithContext(ctx).Create(&job).Error; err != nil {
return nil, err
}
seen := map[string]bool{}
ok := 0
fail := 0
for i, raw := range inputs {
rowNo := i + 1
rawErrs := []RowError{}
if strings.TrimSpace(raw.OrderNo) != "" && seen[strings.TrimSpace(raw.OrderNo)] {
rawErrs = append(rawErrs, RowError{RowNo: rowNo, Field: "order_no", Message: "JSON 内订单号重复", RawData: raw})
}
seen[strings.TrimSpace(raw.OrderNo)] = true
clean, errs := cleanOneOrder(raw, rowNo, customerMap, statusMap)
errs = append(errs, rawErrs...)
if len(errs) > 0 {
fail++
for _, e := range errs {
b, _ := json.Marshal(e.RawData)
_ = db.WithContext(ctx).Create(&domain.ImportJobError{
JobID: job.ID,
RowNo: e.RowNo,
Field: e.Field,
Message: e.Message,
RawData: string(b),
}).Error
}
continue
}
var exists int64
_ = db.WithContext(ctx).Model(&domain.Order{}).Where("order_no = ?", clean.OrderNo).Count(&exists).Error
if exists > 0 {
fail++
_ = db.WithContext(ctx).Create(&domain.ImportJobError{
JobID: job.ID,
RowNo: rowNo,
Field: "order_no",
Message: "订单号已存在(与数据库冲突)",
RawData: "",
}).Error
continue
}
order := domain.Order{
OrderNo: clean.OrderNo,
OrderDate: clean.OrderDate,
ShipDate: clean.ShipDate,
AmountA: clean.AmountA,
AmountB: clean.AmountB,
CustomerID: clean.CustomerID,
StatusID: clean.StatusID,
Remark: clean.Remark,
Source: "import",
CreatedBy: createdBy,
}
if err := db.WithContext(ctx).Create(&order).Error; err != nil {
fail++
_ = db.WithContext(ctx).Create(&domain.ImportJobError{
JobID: job.ID,
RowNo: rowNo,
Field: "order_no",
Message: "入库失败:" + err.Error(),
RawData: "",
}).Error
continue
}
ok++
}
job.OKRows = ok
job.FailRows = fail
finished := time.Now()
job.FinishedAt = &finished
if fail == 0 {
job.Status = "success"
} else if ok == 0 {
job.Status = "failed"
} else {
job.Status = "partial"
}
_ = db.WithContext(ctx).Save(&job).Error
return &job, nil
}
// ---- helpers ----
func headerIndex(header []string) map[string]int {
idx := map[string]int{}
for i, h := range header {
h = strings.TrimSpace(h)
if h == "" {
continue
}
idx[h] = i
}
return idx
}
func rowToOrderInput(row []string, idx map[string]int) OrderInput {
get := func(name string) string {
i, ok := idx[name]
if !ok || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
return OrderInput{
OrderNo: get("订单号"),
OrderDate: get("下单日期"),
ShipDate: get("出货日期"),
AmountA: get("下单金额"),
AmountB: get("订单金额"),
CustomerName: get("客户名"),
Remark: get("备注"),
StatusName: get("状态"),
}
}
func cleanOneOrder(in OrderInput, rowNo int, customerMap map[string]domain.Customer, statusMap map[string]domain.Status) (OrderClean, []RowError) {
var errs []RowError
trim := func(s string) string { return strings.TrimSpace(strings.ReplaceAll(s, "\u3000", " ")) }
in.OrderNo = trim(in.OrderNo)
in.CustomerName = trim(in.CustomerName)
in.StatusName = trim(in.StatusName)
in.AmountA = trim(in.AmountA)
in.AmountB = trim(in.AmountB)
in.OrderDate = trim(in.OrderDate)
in.ShipDate = trim(in.ShipDate)
if in.OrderNo == "" {
errs = append(errs, RowError{RowNo: rowNo, Field: "order_no", Message: "订单号必填", RawData: in})
}
if in.OrderDate == "" {
errs = append(errs, RowError{RowNo: rowNo, Field: "order_date", Message: "下单日期必填", RawData: in})
}
if in.CustomerName == "" {
errs = append(errs, RowError{RowNo: rowNo, Field: "customer_name", Message: "客户名必填", RawData: in})
}
if in.StatusName == "" {
errs = append(errs, RowError{RowNo: rowNo, Field: "status_name", Message: "状态必填", RawData: in})
}
orderDate, err := parseDate(in.OrderDate)
if err != nil {
errs = append(errs, RowError{RowNo: rowNo, Field: "order_date", Message: "下单日期格式错误", RawData: in})
}
var shipDate *time.Time
if in.ShipDate != "" {
t, err := parseDate(in.ShipDate)
if err != nil {
errs = append(errs, RowError{RowNo: rowNo, Field: "ship_date", Message: "出货日期格式错误", RawData: in})
} else {
shipDate = &t
}
}
a, err := parseDecimal(in.AmountA)
if err != nil || a.IsNegative() {
errs = append(errs, RowError{RowNo: rowNo, Field: "amount_a", Message: "下单金额格式错误", RawData: in})
}
b, err := parseDecimal(in.AmountB)
if err != nil || b.IsNegative() {
errs = append(errs, RowError{RowNo: rowNo, Field: "amount_b", Message: "订单金额格式错误", RawData: in})
}
cust, ok := customerMap[in.CustomerName]
if !ok {
errs = append(errs, RowError{RowNo: rowNo, Field: "customer_name", Message: "客户不存在,请先在系统维护客户:"+in.CustomerName, RawData: in})
}
st, ok := statusMap[in.StatusName]
if !ok {
errs = append(errs, RowError{RowNo: rowNo, Field: "status_name", Message: "状态不存在,请先在系统维护状态:"+in.StatusName, RawData: in})
}
if len(errs) > 0 {
return OrderClean{}, errs
}
return OrderClean{
OrderNo: in.OrderNo,
OrderDate: orderDate,
ShipDate: shipDate,
AmountA: a.Round(2),
AmountB: b.Round(2),
CustomerID: cust.ID,
StatusID: st.ID,
Remark: in.Remark,
CustomerName: cust.Name,
StatusName: st.Name,
}, nil
}
func parseDecimal(s string) (decimal.Decimal, error) {
s = strings.ReplaceAll(s, ",", "")
s = strings.TrimSpace(s)
if s == "" {
return decimal.Zero, fmt.Errorf("empty")
}
return decimal.NewFromString(s)
}
func parseDate(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("empty")
}
// 支持 Excel serial整数字符串
if i, err := strconv.Atoi(s); err == nil && i > 20000 {
return excelize.ExcelDateToTime(float64(i), false)
}
layouts := []string{"2006-01-02", "2006/01/02", "2006.01.02"}
for _, layout := range layouts {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("invalid date")
}
func loadCustomerMap(ctx context.Context, db *gorm.DB) (map[string]domain.Customer, error) {
var list []domain.Customer
if err := db.WithContext(ctx).Find(&list).Error; err != nil {
return nil, err
}
m := make(map[string]domain.Customer, len(list))
for _, c := range list {
m[strings.TrimSpace(c.Name)] = c
if strings.TrimSpace(c.Code) != "" {
m[strings.TrimSpace(c.Code)] = c
}
}
return m, nil
}
func loadStatusMap(ctx context.Context, db *gorm.DB) (map[string]domain.Status, error) {
var list []domain.Status
if err := db.WithContext(ctx).Find(&list).Error; err != nil {
return nil, err
}
m := make(map[string]domain.Status, len(list))
for _, s := range list {
m[strings.TrimSpace(s.Name)] = s
}
return m, nil
}