mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-26 08:15:43 +00:00
add adjust migration
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
type importOptions struct {
|
||||
FilePath string
|
||||
Sheet string
|
||||
Apply bool
|
||||
}
|
||||
|
||||
type headerIndexes struct {
|
||||
ProjectFlockID int
|
||||
TotalCost int
|
||||
CutoverDate int
|
||||
Note int
|
||||
}
|
||||
|
||||
type manualInputImportRow struct {
|
||||
RowNumber int
|
||||
ProjectFlockID uint
|
||||
TotalCost float64
|
||||
CutoverDate time.Time
|
||||
Note *string
|
||||
}
|
||||
|
||||
type validationIssue struct {
|
||||
Row int
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (i validationIssue) Error() string {
|
||||
if i.Row > 0 {
|
||||
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
|
||||
}
|
||||
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
|
||||
}
|
||||
|
||||
type farmResolver interface {
|
||||
ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error)
|
||||
}
|
||||
|
||||
type dbFarmResolver struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type manualInputStore interface {
|
||||
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
|
||||
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
|
||||
}
|
||||
|
||||
type txRunner interface {
|
||||
InTx(ctx context.Context, fn func(store manualInputStore) error) error
|
||||
}
|
||||
|
||||
type dbTxRunner struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type expenseDepreciationStore struct {
|
||||
repo repportRepo.ExpenseDepreciationRepository
|
||||
}
|
||||
|
||||
type farmIdentityRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
FarmName string `gorm:"column:farm_name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var opts importOptions
|
||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
|
||||
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
|
||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||
flag.Parse()
|
||||
|
||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
||||
opts.Sheet = strings.TrimSpace(opts.Sheet)
|
||||
|
||||
if opts.FilePath == "" {
|
||||
log.Fatal("--file is required")
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load timezone Asia/Jakarta: %v", err)
|
||||
}
|
||||
|
||||
sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location)
|
||||
if err != nil {
|
||||
log.Fatalf("failed reading excel: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
resolver := dbFarmResolver{db: db}
|
||||
|
||||
farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows))
|
||||
if err != nil {
|
||||
log.Fatalf("failed validating project_flock_id against project_flocks: %v", err)
|
||||
}
|
||||
|
||||
issues := append([]validationIssue{}, parseIssues...)
|
||||
issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...)
|
||||
sortValidationIssues(issues)
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
||||
fmt.Printf("File: %s\n", opts.FilePath)
|
||||
fmt.Printf("Sheet: %s\n", sheetName)
|
||||
fmt.Printf("Rows parsed: %d\n", len(rows))
|
||||
fmt.Printf("Rows invalid: %d\n", len(issues))
|
||||
fmt.Println()
|
||||
|
||||
if len(rows) > 0 {
|
||||
printPlanRows(rows, farmNameByID)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
fmt.Println("Validation errors:")
|
||||
for _, issue := range issues {
|
||||
fmt.Printf("ERROR %s\n", issue.Error())
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !opts.Apply {
|
||||
fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows))
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("Summary: planned=0 applied=0 failed=0")
|
||||
return
|
||||
}
|
||||
|
||||
if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil {
|
||||
log.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
fmt.Printf(
|
||||
"DONE row=%d project_flock_id=%d cutover_date=%s\n",
|
||||
row.RowNumber,
|
||||
row.ProjectFlockID,
|
||||
row.CutoverDate.In(location).Format(dateLayout),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows))
|
||||
}
|
||||
|
||||
func parseManualInputFile(
|
||||
filePath string,
|
||||
requestedSheet string,
|
||||
location *time.Location,
|
||||
) (string, []manualInputImportRow, []validationIssue, error) {
|
||||
workbook, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = workbook.Close()
|
||||
}()
|
||||
|
||||
sheetName, err := resolveSheetName(workbook, requestedSheet)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
if len(allRows) == 0 {
|
||||
return sheetName, nil, []validationIssue{
|
||||
{Field: "header", Message: "sheet is empty"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
indexes, headerIssues := parseHeaderIndexes(allRows[0])
|
||||
if len(headerIssues) > 0 {
|
||||
return sheetName, nil, headerIssues, nil
|
||||
}
|
||||
|
||||
rows := make([]manualInputImportRow, 0, len(allRows)-1)
|
||||
issues := make([]validationIssue, 0)
|
||||
seenProjectFlockIDs := make(map[uint]int)
|
||||
|
||||
for idx := 1; idx < len(allRows); idx++ {
|
||||
rowNumber := idx + 1
|
||||
rawRow := allRows[idx]
|
||||
|
||||
if isRowEmpty(rawRow) {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs)
|
||||
if len(rowIssues) > 0 {
|
||||
issues = append(issues, rowIssues...)
|
||||
continue
|
||||
}
|
||||
|
||||
rows = append(rows, *parsed)
|
||||
}
|
||||
|
||||
if len(rows) == 0 && len(issues) == 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "rows",
|
||||
Message: "no data rows found",
|
||||
})
|
||||
}
|
||||
|
||||
return sheetName, rows, issues, nil
|
||||
}
|
||||
|
||||
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
|
||||
if workbook == nil {
|
||||
return "", fmt.Errorf("workbook is nil")
|
||||
}
|
||||
|
||||
sheets := workbook.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return "", fmt.Errorf("workbook has no sheets")
|
||||
}
|
||||
|
||||
if requestedSheet == "" {
|
||||
return sheets[0], nil
|
||||
}
|
||||
|
||||
for _, sheet := range sheets {
|
||||
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
|
||||
return sheet, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("sheet %q not found", requestedSheet)
|
||||
}
|
||||
|
||||
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
|
||||
indexes := headerIndexes{
|
||||
ProjectFlockID: -1,
|
||||
TotalCost: -1,
|
||||
CutoverDate: -1,
|
||||
Note: -1,
|
||||
}
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
for idx, raw := range headerRow {
|
||||
header := normalizeHeader(raw)
|
||||
if header == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch header {
|
||||
case "project_flock_id":
|
||||
if indexes.ProjectFlockID >= 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "header",
|
||||
Message: "duplicate header project_flock_id",
|
||||
})
|
||||
}
|
||||
indexes.ProjectFlockID = idx
|
||||
case "total_cost":
|
||||
if indexes.TotalCost >= 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "header",
|
||||
Message: "duplicate header total_cost",
|
||||
})
|
||||
}
|
||||
indexes.TotalCost = idx
|
||||
case "cutover_date":
|
||||
if indexes.CutoverDate >= 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "header",
|
||||
Message: "duplicate header cutover_date",
|
||||
})
|
||||
}
|
||||
indexes.CutoverDate = idx
|
||||
case "note":
|
||||
if indexes.Note >= 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "header",
|
||||
Message: "duplicate header note",
|
||||
})
|
||||
}
|
||||
indexes.Note = idx
|
||||
}
|
||||
}
|
||||
|
||||
if indexes.ProjectFlockID < 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "project_flock_id",
|
||||
Message: "required header is missing",
|
||||
})
|
||||
}
|
||||
if indexes.TotalCost < 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "total_cost",
|
||||
Message: "required header is missing",
|
||||
})
|
||||
}
|
||||
if indexes.CutoverDate < 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "cutover_date",
|
||||
Message: "required header is missing",
|
||||
})
|
||||
}
|
||||
|
||||
return indexes, issues
|
||||
}
|
||||
|
||||
func parseDataRow(
|
||||
rawRow []string,
|
||||
rowNumber int,
|
||||
indexes headerIndexes,
|
||||
location *time.Location,
|
||||
seenProjectFlockIDs map[uint]int,
|
||||
) (*manualInputImportRow, []validationIssue) {
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID))
|
||||
projectFlockID, err := parsePositiveUint(projectFlockIDRaw)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "project_flock_id",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost))
|
||||
totalCost, err := parseNonNegativeFloat(totalCostRaw)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "total_cost",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate))
|
||||
cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "cutover_date",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var note *string
|
||||
noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note))
|
||||
if noteRaw != "" {
|
||||
if len([]rune(noteRaw)) > 1000 {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "note",
|
||||
Message: "must have at most 1000 characters",
|
||||
})
|
||||
} else {
|
||||
note = ¬eRaw
|
||||
}
|
||||
}
|
||||
|
||||
if projectFlockID > 0 {
|
||||
if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "project_flock_id",
|
||||
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow),
|
||||
})
|
||||
} else {
|
||||
seenProjectFlockIDs[projectFlockID] = rowNumber
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
return nil, issues
|
||||
}
|
||||
|
||||
return &manualInputImportRow{
|
||||
RowNumber: rowNumber,
|
||||
ProjectFlockID: projectFlockID,
|
||||
TotalCost: totalCost,
|
||||
CutoverDate: cutoverDate,
|
||||
Note: note,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parsePositiveUint(raw string) (uint, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("is required")
|
||||
}
|
||||
|
||||
uintValue, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err == nil {
|
||||
if uintValue == 0 {
|
||||
return 0, fmt.Errorf("must be greater than 0")
|
||||
}
|
||||
return uint(uintValue), nil
|
||||
}
|
||||
|
||||
floatValue, floatErr := strconv.ParseFloat(raw, 64)
|
||||
if floatErr != nil {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
if floatValue <= 0 {
|
||||
return 0, fmt.Errorf("must be greater than 0")
|
||||
}
|
||||
if floatValue != float64(uint(floatValue)) {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
|
||||
return uint(floatValue), nil
|
||||
}
|
||||
|
||||
func parseNonNegativeFloat(raw string) (float64, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("is required")
|
||||
}
|
||||
|
||||
value, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be numeric")
|
||||
}
|
||||
if value < 0 {
|
||||
return 0, fmt.Errorf("must be greater than or equal to 0")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("is required")
|
||||
}
|
||||
value, err := time.ParseInLocation(dateLayout, raw, location)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func isRowEmpty(row []string) bool {
|
||||
for _, cell := range row {
|
||||
if strings.TrimSpace(cell) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeHeader(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func cellValue(row []string, index int) string {
|
||||
if index < 0 || index >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return row[index]
|
||||
}
|
||||
|
||||
func collectProjectFlockIDs(rows []manualInputImportRow) []uint {
|
||||
ids := make([]uint, 0, len(rows))
|
||||
seen := make(map[uint]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.ProjectFlockID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[row.ProjectFlockID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[row.ProjectFlockID] = struct{}{}
|
||||
ids = append(ids, row.ProjectFlockID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r dbFarmResolver) ResolveActiveLayingFarms(
|
||||
ctx context.Context,
|
||||
projectFlockIDs []uint,
|
||||
) (map[uint]string, error) {
|
||||
result := make(map[uint]string)
|
||||
if len(projectFlockIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rows := make([]farmIdentityRow, 0, len(projectFlockIDs))
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("project_flocks").
|
||||
Select("id, flock_name AS farm_name").
|
||||
Where("id IN ?", projectFlockIDs).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("category = ?", utils.ProjectFlockCategoryLaying).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row.FarmName
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue {
|
||||
issues := make([]validationIssue, 0)
|
||||
for _, row := range rows {
|
||||
if _, exists := farmNameByID[row.ProjectFlockID]; exists {
|
||||
continue
|
||||
}
|
||||
issues = append(issues, validationIssue{
|
||||
Row: row.RowNumber,
|
||||
Field: "project_flock_id",
|
||||
Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID),
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) {
|
||||
for _, row := range rows {
|
||||
farmName := farmNameByID[row.ProjectFlockID]
|
||||
fmt.Printf(
|
||||
"PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n",
|
||||
row.RowNumber,
|
||||
row.ProjectFlockID,
|
||||
farmName,
|
||||
row.TotalCost,
|
||||
row.CutoverDate.Format(dateLayout),
|
||||
derefString(row.Note),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func sortValidationIssues(issues []validationIssue) {
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
if issues[i].Row == issues[j].Row {
|
||||
if issues[i].Field == issues[j].Field {
|
||||
return issues[i].Message < issues[j].Message
|
||||
}
|
||||
return issues[i].Field < issues[j].Field
|
||||
}
|
||||
return issues[i].Row < issues[j].Row
|
||||
})
|
||||
}
|
||||
|
||||
func applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error {
|
||||
if !apply || len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return applyImportRows(ctx, runner, rows)
|
||||
}
|
||||
|
||||
func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error {
|
||||
return runner.InTx(ctx, func(store manualInputStore) error {
|
||||
for _, row := range rows {
|
||||
payload := entity.FarmDepreciationManualInput{
|
||||
ProjectFlockId: row.ProjectFlockID,
|
||||
TotalCost: row.TotalCost,
|
||||
CutoverDate: row.CutoverDate,
|
||||
Note: row.Note,
|
||||
}
|
||||
|
||||
if err := store.UpsertManualInput(ctx, &payload); err != nil {
|
||||
return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err)
|
||||
}
|
||||
|
||||
if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil {
|
||||
return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
repo := repportRepo.NewExpenseDepreciationRepository(tx)
|
||||
store := expenseDepreciationStore{repo: repo}
|
||||
return fn(store)
|
||||
})
|
||||
}
|
||||
|
||||
func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error {
|
||||
return s.repo.UpsertManualInput(ctx, row)
|
||||
}
|
||||
|
||||
func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error {
|
||||
return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs)
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
Reference in New Issue
Block a user