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("蓝图未开启 import(operations 缺 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("蓝图未开启 export(operations 缺 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("蓝图未开启 export(operations 缺 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 }