320 lines
8.6 KiB
Go
320 lines
8.6 KiB
Go
package applogic
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/csv"
|
||
"fmt"
|
||
"io"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"aijianzhan/platform/internal/authx"
|
||
"aijianzhan/platform/internal/meta"
|
||
"aijianzhan/platform/internal/types"
|
||
|
||
"github.com/xuri/excelize/v2"
|
||
)
|
||
|
||
// ImportRows accepts .xlsx (preferred) or .csv.
|
||
func (l *CrudLogic) ImportRows(slug, resource, filename string, r io.Reader) (*types.ImportResp, error) {
|
||
tenantID := authx.TenantID(l.ctx)
|
||
userID := authx.UserID(l.ctx)
|
||
ref, err := l.svcCtx.Meta.ResolveResource(l.ctx, tenantID, slug, resource)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !hasOp(ref, "import") {
|
||
return nil, fmt.Errorf("operation import not allowed")
|
||
}
|
||
|
||
ext := strings.ToLower(filepath.Ext(filename))
|
||
raw, err := io.ReadAll(r)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(raw) == 0 {
|
||
return nil, fmt.Errorf("empty file")
|
||
}
|
||
|
||
var rows [][]string
|
||
switch {
|
||
case ext == ".xlsx" || ext == ".xlsm" || isZipOOXML(raw):
|
||
rows, err = readExcelRows(raw)
|
||
case ext == ".csv" || ext == ".txt" || looksLikeCSV(raw):
|
||
rows, err = readCSVRows(raw)
|
||
default:
|
||
// try excel then csv
|
||
rows, err = readExcelRows(raw)
|
||
if err != nil {
|
||
rows, err = readCSVRows(raw)
|
||
}
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse file: %w", err)
|
||
}
|
||
if len(rows) < 1 {
|
||
return nil, fmt.Errorf("no header row")
|
||
}
|
||
|
||
headers := make([]string, len(rows[0]))
|
||
for i, h := range rows[0] {
|
||
headers[i] = strings.TrimSpace(h)
|
||
}
|
||
|
||
resp := &types.ImportResp{Errors: []string{}}
|
||
for rowNum := 1; rowNum < len(rows); rowNum++ {
|
||
rec := rows[rowNum]
|
||
if rowEmpty(rec) {
|
||
continue
|
||
}
|
||
body := map[string]any{}
|
||
for i, h := range headers {
|
||
if i >= len(rec) || h == "" {
|
||
continue
|
||
}
|
||
fname := mapHeaderToField(ref, h)
|
||
if fname == "" || fname == ref.Entity.PrimaryKey {
|
||
continue
|
||
}
|
||
body[fname] = coerceValue(ref, fname, strings.TrimSpace(rec[i]))
|
||
}
|
||
if len(body) == 0 {
|
||
resp.Skipped++
|
||
continue
|
||
}
|
||
if _, err := l.svcCtx.CRUD.Create(l.ctx, ref, tenantID, userID, body); err != nil {
|
||
resp.Skipped++
|
||
if len(resp.Errors) < 100 {
|
||
resp.Errors = append(resp.Errors, fmt.Sprintf("row %d: %v", rowNum+1, err))
|
||
}
|
||
continue
|
||
}
|
||
resp.Inserted++
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
// ImportCSV kept for callers; prefers CSV parsing.
|
||
func (l *CrudLogic) ImportCSV(slug, resource string, r io.Reader) (*types.ImportResp, error) {
|
||
return l.ImportRows(slug, resource, "import.csv", r)
|
||
}
|
||
|
||
// ExportExcel writes .xlsx workbook.
|
||
func (l *CrudLogic) ExportExcel(slug, resource string) ([]byte, string, error) {
|
||
tenantID := authx.TenantID(l.ctx)
|
||
ref, err := l.svcCtx.Meta.ResolveResource(l.ctx, tenantID, slug, resource)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if !hasOp(ref, "export") {
|
||
return nil, "", fmt.Errorf("operation export not allowed")
|
||
}
|
||
items, _, err := l.svcCtx.CRUD.List(l.ctx, ref, tenantID, 1, 5000, nil, "")
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
headers := make([]string, 0, len(ref.Entity.Fields))
|
||
for _, f := range ref.Entity.Fields {
|
||
headers = append(headers, f.Name)
|
||
}
|
||
|
||
f := excelize.NewFile()
|
||
sheet := f.GetSheetName(0)
|
||
for i, h := range headers {
|
||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||
_ = f.SetCellValue(sheet, cell, h)
|
||
}
|
||
for ri, item := range items {
|
||
for ci, h := range headers {
|
||
cell, _ := excelize.CoordinatesToCellName(ci+1, ri+2)
|
||
if v, ok := item[h]; ok && v != nil {
|
||
_ = f.SetCellValue(sheet, cell, v)
|
||
}
|
||
}
|
||
}
|
||
buf, err := f.WriteToBuffer()
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return buf.Bytes(), resource + ".xlsx", nil
|
||
}
|
||
|
||
// ExportCSV kept for format=csv.
|
||
func (l *CrudLogic) ExportCSV(slug, resource string) ([]byte, error) {
|
||
tenantID := authx.TenantID(l.ctx)
|
||
ref, err := l.svcCtx.Meta.ResolveResource(l.ctx, tenantID, slug, resource)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !hasOp(ref, "export") {
|
||
return nil, fmt.Errorf("operation export not allowed")
|
||
}
|
||
items, _, err := l.svcCtx.CRUD.List(l.ctx, ref, tenantID, 1, 5000, nil, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
headers := make([]string, 0, len(ref.Entity.Fields))
|
||
for _, f := range ref.Entity.Fields {
|
||
headers = append(headers, f.Name)
|
||
}
|
||
buf := &bytes.Buffer{}
|
||
w := csv.NewWriter(buf)
|
||
_ = w.Write(headers)
|
||
for _, item := range items {
|
||
row := make([]string, len(headers))
|
||
for i, h := range headers {
|
||
if v, ok := item[h]; ok && v != nil {
|
||
row[i] = fmt.Sprint(v)
|
||
}
|
||
}
|
||
_ = w.Write(row)
|
||
}
|
||
w.Flush()
|
||
return buf.Bytes(), w.Error()
|
||
}
|
||
|
||
func readExcelRows(raw []byte) ([][]string, error) {
|
||
f, err := excelize.OpenReader(bytes.NewReader(raw))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer func() { _ = f.Close() }()
|
||
sheets := f.GetSheetList()
|
||
if len(sheets) == 0 {
|
||
return nil, fmt.Errorf("excel has no sheets")
|
||
}
|
||
// 优先业务数据页,跳过说明/超限汇总
|
||
prefer := ""
|
||
for _, name := range sheets {
|
||
n := strings.ToLower(name)
|
||
if strings.Contains(n, "settlement") || (strings.Contains(name, "测点") && !strings.Contains(name, "超限") && !strings.Contains(name, "说明")) {
|
||
prefer = name
|
||
break
|
||
}
|
||
}
|
||
if prefer == "" {
|
||
for _, name := range sheets {
|
||
if !strings.Contains(name, "说明") && !strings.Contains(name, "超限") {
|
||
prefer = name
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if prefer == "" {
|
||
prefer = sheets[0]
|
||
}
|
||
return f.GetRows(prefer)
|
||
}
|
||
|
||
func readCSVRows(raw []byte) ([][]string, error) {
|
||
cr := csv.NewReader(bytes.NewReader(raw))
|
||
cr.FieldsPerRecord = -1
|
||
return cr.ReadAll()
|
||
}
|
||
|
||
func isZipOOXML(raw []byte) bool {
|
||
// xlsx is a zip archive
|
||
return len(raw) >= 4 && raw[0] == 'P' && raw[1] == 'K' && raw[2] == 3 && raw[3] == 4
|
||
}
|
||
|
||
func looksLikeCSV(raw []byte) bool {
|
||
sample := raw
|
||
if len(sample) > 512 {
|
||
sample = sample[:512]
|
||
}
|
||
s := string(sample)
|
||
return strings.Contains(s, ",") || strings.Contains(s, "\t") || strings.Contains(s, ";")
|
||
}
|
||
|
||
func rowEmpty(rec []string) bool {
|
||
for _, c := range rec {
|
||
if strings.TrimSpace(c) != "" {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func hasOp(ref *meta.ResourceRef, op string) bool {
|
||
for _, o := range ref.Resource.Operations {
|
||
if o == op {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func mapHeaderToField(ref *meta.ResourceRef, header string) string {
|
||
h := strings.TrimSpace(header)
|
||
if h == "" {
|
||
return ""
|
||
}
|
||
norm := normalizeHeader(h)
|
||
aliases := map[string]string{
|
||
"编号dk": "dkilo", "测点dk": "dkilo", "dkilo": "dkilo",
|
||
"里程": "chainage", "chainage": "chainage",
|
||
"测点编号": "point_code", "测点": "point_code", "point_code": "point_code",
|
||
"断面类型": "section_type", "section_type": "section_type",
|
||
"工点": "worksite", "worksite": "worksite",
|
||
"cjl数值": "cjl_value", "观测值": "cjl_value", "观测值mm": "cjl_value", "cjl_value": "cjl_value",
|
||
"cjl颜色": "cjl_color", "观测色": "cjl_color", "cjl_color": "cjl_color",
|
||
"设计沉降": "design_settlement_mm", "设计总沉降量mm": "design_settlement_mm", "design_settlement_mm": "design_settlement_mm",
|
||
"累积沉降": "cum_settlement_mm", "累积沉降量mm": "cum_settlement_mm", "cum_settlement_mm": "cum_settlement_mm",
|
||
"预测沉降": "pred_settlement_mm", "预测沉降mm": "pred_settlement_mm", "pred_settlement_mm": "pred_settlement_mm",
|
||
"超限量": "exceed_mm", "超限量mm": "exceed_mm", "exceed_mm": "exceed_mm",
|
||
"超限累计天数": "exceed_days", "累计天数天": "exceed_days", "exceed_days": "exceed_days",
|
||
"监督天": "supervise_days", "监督周期天": "supervise_days", "supervise_days": "supervise_days",
|
||
"超期天数": "overdue_days", "overdue_days": "overdue_days",
|
||
"前一日占比": "before_day", "before_day": "before_day", "beforeday": "before_day",
|
||
"后一日占比": "next_day", "next_day": "next_day", "nextday": "next_day",
|
||
"频率": "frequency", "frequency": "frequency",
|
||
"cljd标识": "workinfo_kilo", "workinfo_kilo": "workinfo_kilo",
|
||
"状态": "status", "status": "status",
|
||
"里程米": "mileage_m", "mileage_m": "mileage_m",
|
||
}
|
||
if a, ok := aliases[norm]; ok {
|
||
h = a
|
||
norm = a
|
||
}
|
||
for _, f := range ref.Entity.Fields {
|
||
if f.Name == h || f.Label == header || normalizeHeader(f.Label) == norm || f.Name == norm {
|
||
return f.Name
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func normalizeHeader(s string) string {
|
||
s = strings.TrimSpace(strings.ToLower(s))
|
||
repl := strings.NewReplacer(" ", "", "_", "", "-", "", "(", "", ")", "", "(", "", ")", "", ".", "")
|
||
return repl.Replace(s)
|
||
}
|
||
|
||
func coerceValue(ref *meta.ResourceRef, fieldName, raw string) any {
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
for _, f := range ref.Entity.Fields {
|
||
if f.Name != fieldName {
|
||
continue
|
||
}
|
||
switch f.Type {
|
||
case "int", "bigint":
|
||
n, err := strconv.ParseInt(raw, 10, 64)
|
||
if err == nil {
|
||
return n
|
||
}
|
||
case "decimal":
|
||
n, err := strconv.ParseFloat(raw, 64)
|
||
if err == nil {
|
||
return n
|
||
}
|
||
case "boolean":
|
||
return raw == "1" || strings.EqualFold(raw, "true") || raw == "是"
|
||
}
|
||
return raw
|
||
}
|
||
return raw
|
||
}
|