Files
ai_site/platform/internal/logic/applogic/impex.go
whm cb56e6847e feat: add Z12/Z13 bind APIs, stock import, and sync docs
Enable auto default sync channels on agent activate, bind-code/phone confirm flows, publish ALTER, and align admin/yuheng docs with the production bind path.
2026-08-05 11:47:20 +08:00

364 lines
10 KiB
Go
Raw 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 applogic
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/meta"
"aijianzhan/platform/internal/types"
"github.com/lib/pq"
"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
}
// Z9f读路径内存兜底与 Z9e 扫库双保险)
ensureImportOpInMemory(ref)
if !hasOp(ref, "import") {
return nil, fmt.Errorf("蓝图未开启 importoperations 缺 import。请管理员执行「一键开启全部业务表导入」或再发布模块与账号权限 row.import 无关")
}
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 {
msg := err.Error()
if isUndefinedColumnErr(err) {
msg = fmt.Sprintf("%v蓝图字段与库表不一致请重新发布模块以自动补列或缩小 Excel 表头至库已有列)", err)
}
resp.Errors = append(resp.Errors, fmt.Sprintf("row %d: %s", rowNum+1, msg))
}
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
}
ensureImportOpInMemory(ref)
if !hasOp(ref, "export") {
return nil, "", fmt.Errorf("蓝图未开启 exportoperations 缺 export。请管理员执行「一键开启全部业务表导入」或再发布模块")
}
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
}
ensureImportOpInMemory(ref)
if !hasOp(ref, "export") {
return nil, fmt.Errorf("蓝图未开启 exportoperations 缺 export。请管理员执行「一键开启全部业务表导入」或再发布模块")
}
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 {
if ref == nil {
return false
}
for _, o := range ref.Resource.Operations {
if o == op {
return true
}
}
return false
}
// ensureImportOpInMemory Z9f对已加载蓝图做与 EnsureDefaultImportExport 相同的内存补齐,并刷新 ref.Resource。
func ensureImportOpInMemory(ref *meta.ResourceRef) {
if ref == nil || ref.App == nil || ref.App.Blueprint == nil {
return
}
ref.App.Blueprint.EnsureDefaultImportExport()
want := strings.TrimSpace(ref.Resource.Path)
if len(want) > 0 && want[0] == '/' {
want = want[1:]
}
for _, r := range ref.App.Blueprint.Apis.Resources {
path := r.Path
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
if path == want {
ref.Resource = r
return
}
}
}
func isUndefinedColumnErr(err error) bool {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "42703" {
return true
}
msg := err.Error()
return strings.Contains(msg, "42703") || strings.Contains(msg, "字段不存在") || strings.Contains(msg, "does not exist")
}
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
}