chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
319
platform/internal/logic/applogic/impex.go
Normal file
319
platform/internal/logic/applogic/impex.go
Normal file
@@ -0,0 +1,319 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user