mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
639 lines
20 KiB
Go
639 lines
20 KiB
Go
// Command seed-house-depreciation-standards membaca kurva depresiasi per-day
|
|
// dari file Excel, lalu meng-generate file migration {up,down}.sql yang
|
|
// menyisipkan baris house_depreciation_standards dengan project_flock_ids
|
|
// berisi semua flock yang memakai kurva tersebut.
|
|
//
|
|
// Kurva disimpan SEKALI di DB sebagai satu baris dengan
|
|
// project_flock_ids = ARRAY[52,53,54]::bigint[]. Lookup di engine pakai
|
|
// ? = ANY(project_flock_ids), sehingga tidak ada duplikasi baris.
|
|
//
|
|
// Hanya multiplication_percentage yang di-override; house_type & standard_week
|
|
// diwarisi dari baris global (project_flock_ids IS NULL) untuk hari yang sama,
|
|
// dan depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
|
|
//
|
|
// Jalankan lokal (tidak ada API yang di-hit di production):
|
|
//
|
|
// go run ./cmd/seed-house-depreciation-standards \
|
|
// -project-flock-ids=52,53,54 -file=curve.xlsx # dry-run
|
|
// go run ./cmd/seed-house-depreciation-standards \
|
|
// -project-flock-ids=52,53,54 -file=curve.xlsx -apply # tulis migration
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"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"
|
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
dateLayout = "2006-01-02"
|
|
timestampLayout = "20060102150405"
|
|
defaultMigrations = "internal/database/migrations"
|
|
headerDay = "day"
|
|
headerMultiplier = "multiplication_percentage"
|
|
)
|
|
|
|
type options struct {
|
|
ProjectFlockIDs string // comma-separated flock IDs (wajib, min 1)
|
|
FilePath string
|
|
Sheet string
|
|
EffectiveDate string
|
|
HouseType string
|
|
OutDir string
|
|
Apply bool
|
|
}
|
|
|
|
type curveRow struct {
|
|
Day int
|
|
Mult float64
|
|
ColRef string // Excel column letter (e.g. "B"), untuk error reporting
|
|
}
|
|
|
|
type validationIssue struct {
|
|
Row int
|
|
Field string
|
|
Message string
|
|
}
|
|
|
|
func (i validationIssue) String() 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)
|
|
}
|
|
|
|
func main() {
|
|
var opts options
|
|
flag.StringVar(&opts.ProjectFlockIDs, "project-flock-ids", "", "Comma-separated LAYING project flock IDs yang memakai kurva ini (required, e.g. 52,53,54)")
|
|
flag.StringVar(&opts.FilePath, "file", "", "Path ke .xlsx — format horizontal: baris 'day' dan 'multiplication_percentage' (required)")
|
|
flag.StringVar(&opts.Sheet, "sheet", "", "Nama sheet (opsional; default: sheet pertama)")
|
|
flag.StringVar(&opts.EffectiveDate, "effective-date", "", "effective_date untuk baris yang di-insert (YYYY-MM-DD; default: hari ini)")
|
|
flag.StringVar(&opts.HouseType, "house-type", "", "Override house_type (open_house|close_house). Default: di-derive dari kandang flock")
|
|
flag.StringVar(&opts.OutDir, "out-dir", defaultMigrations, "Direktori output file migration")
|
|
flag.BoolVar(&opts.Apply, "apply", false, "Tulis file migration. Jika false: dry-run (cetak SQL ke stdout)")
|
|
flag.Parse()
|
|
|
|
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
|
opts.Sheet = strings.TrimSpace(opts.Sheet)
|
|
opts.OutDir = strings.TrimSpace(opts.OutDir)
|
|
|
|
if strings.TrimSpace(opts.ProjectFlockIDs) == "" {
|
|
log.Fatal("--project-flock-ids is required")
|
|
}
|
|
flockIDs, err := parseFlockIDs(opts.ProjectFlockIDs)
|
|
if err != nil {
|
|
log.Fatalf("--project-flock-ids: %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
effectiveDate, err := resolveEffectiveDate(opts.EffectiveDate, location)
|
|
if err != nil {
|
|
log.Fatalf("invalid --effective-date: %v", err)
|
|
}
|
|
opts.EffectiveDate = effectiveDate.Format(dateLayout)
|
|
|
|
sheetName, curve, parseIssues, err := parseCurveFile(opts.FilePath, opts.Sheet)
|
|
if err != nil {
|
|
log.Fatalf("failed reading excel: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
db := database.Connect(config.DBHost, config.DBName)
|
|
|
|
for _, id := range flockIDs {
|
|
if err := assertActiveLayingFlock(ctx, db, id); err != nil {
|
|
log.Fatalf("flock %d: %v", id, err)
|
|
}
|
|
}
|
|
|
|
houseTypes, err := resolveHouseTypesForFlocks(ctx, db, flockIDs, opts.HouseType)
|
|
if err != nil {
|
|
log.Fatalf("house_type resolution failed: %v", err)
|
|
}
|
|
|
|
// Days yang tidak ada di global standard cukup di-skip oleh JOIN LATERAL (inner join) —
|
|
// tidak perlu validasi coverage; tidak ada global row = tidak ada INSERT, bukan error.
|
|
issues := append([]validationIssue{}, parseIssues...)
|
|
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("Project flock IDs: %s\n", formatFlockIDs(flockIDs))
|
|
fmt.Printf("House types: %s\n", strings.Join(houseTypes, ", "))
|
|
fmt.Printf("Effective date: %s\n", opts.EffectiveDate)
|
|
fmt.Printf("Curve rows parsed: %d\n", len(curve))
|
|
if len(curve) > 0 {
|
|
minDay, maxDay := dayRange(curve)
|
|
fmt.Printf("Day range: %d..%d\n", minDay, maxDay)
|
|
}
|
|
fmt.Printf("Validation errors: %d\n", len(issues))
|
|
fmt.Println()
|
|
|
|
if len(issues) > 0 {
|
|
fmt.Println("Validation errors:")
|
|
for _, issue := range issues {
|
|
fmt.Printf("ERROR %s\n", issue.String())
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
upSQL := buildUpSQL(opts, houseTypes, curve, flockIDs)
|
|
downSQL := buildDownSQL(opts, flockIDs)
|
|
|
|
prefix := time.Now().In(location).Format(timestampLayout)
|
|
suffix := formatFlockIDsForFilename(flockIDs)
|
|
upName := fmt.Sprintf("%s_seed_house_depreciation_flocks_%s.up.sql", prefix, suffix)
|
|
downName := fmt.Sprintf("%s_seed_house_depreciation_flocks_%s.down.sql", prefix, suffix)
|
|
|
|
if !opts.Apply {
|
|
fmt.Printf("--- %s ---\n%s\n", upName, upSQL)
|
|
fmt.Printf("--- %s ---\n%s\n", downName, downSQL)
|
|
fmt.Printf("Dry-run: would write 2 files to %s. Re-run with -apply to create them.\n", opts.OutDir)
|
|
return
|
|
}
|
|
|
|
upPath := filepath.Join(opts.OutDir, upName)
|
|
downPath := filepath.Join(opts.OutDir, downName)
|
|
if err := os.WriteFile(upPath, []byte(upSQL), 0o644); err != nil {
|
|
log.Fatalf("failed writing %s: %v", upPath, err)
|
|
}
|
|
if err := os.WriteFile(downPath, []byte(downSQL), 0o644); err != nil {
|
|
log.Fatalf("failed writing %s: %v", downPath, err)
|
|
}
|
|
|
|
fmt.Printf("WROTE %s\n", upPath)
|
|
fmt.Printf("WROTE %s\n", downPath)
|
|
fmt.Println("Review the SQL, commit it, then deploy runs `make migrate-up`.")
|
|
}
|
|
|
|
// --- validation helpers -------------------------------------------------------
|
|
|
|
func parseFlockIDs(raw string) ([]uint, error) {
|
|
parts := strings.Split(raw, ",")
|
|
ids := make([]uint, 0, len(parts))
|
|
seen := make(map[uint]bool)
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
n, err := strconv.ParseUint(p, 10, 64)
|
|
if err != nil || n == 0 {
|
|
return nil, fmt.Errorf("invalid flock ID %q: must be a positive integer", p)
|
|
}
|
|
id := uint(n)
|
|
if seen[id] {
|
|
return nil, fmt.Errorf("duplicate flock ID %d", id)
|
|
}
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, fmt.Errorf("at least one project flock ID required")
|
|
}
|
|
// Sort IDs so generated SQL and filename are deterministic.
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
return ids, nil
|
|
}
|
|
|
|
func formatFlockIDs(ids []uint) string {
|
|
parts := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
parts[i] = strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// formatFlockIDsForFilename returns "52_53_54" for use in migration filenames.
|
|
// Truncates to first 4 IDs if many, to keep filename reasonable.
|
|
func formatFlockIDsForFilename(ids []uint) string {
|
|
display := ids
|
|
suffix := ""
|
|
if len(ids) > 4 {
|
|
display = ids[:4]
|
|
suffix = fmt.Sprintf("_and_%d_more", len(ids)-4)
|
|
}
|
|
parts := make([]string, len(display))
|
|
for i, id := range display {
|
|
parts[i] = strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
return strings.Join(parts, "_") + suffix
|
|
}
|
|
|
|
// --- effective date -----------------------------------------------------------
|
|
|
|
func resolveEffectiveDate(raw string, location *time.Location) (time.Time, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
now := time.Now().In(location)
|
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location), nil
|
|
}
|
|
parsed, err := time.ParseInLocation(dateLayout, raw, location)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
// --- excel parsing -----------------------------------------------------------
|
|
|
|
// parseCurveFile membaca format horizontal dari Excel:
|
|
//
|
|
// Baris 1 (label "day"): day | 1 | 2 | 3 | ...
|
|
// Baris 2 (label "multiplication_percentage"): mp | 0.997.. | 0.997.. | ...
|
|
//
|
|
// Kedua baris bisa ada di urutan berapa pun, dideteksi lewat label di kolom A.
|
|
// Kolom A adalah label; data mulai dari kolom B seterusnya.
|
|
func parseCurveFile(filePath, requestedSheet string) (string, []curveRow, []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: "sheet", Message: "sheet is empty"}}, nil
|
|
}
|
|
|
|
var dayRow, multRow []string
|
|
for _, row := range allRows {
|
|
if len(row) == 0 {
|
|
continue
|
|
}
|
|
switch normalizeHeader(row[0]) {
|
|
case headerDay:
|
|
dayRow = row
|
|
case headerMultiplier:
|
|
multRow = row
|
|
}
|
|
}
|
|
|
|
issues := make([]validationIssue, 0)
|
|
if dayRow == nil {
|
|
issues = append(issues, validationIssue{Field: headerDay, Message: `baris dengan label "day" di kolom A tidak ditemukan`})
|
|
}
|
|
if multRow == nil {
|
|
issues = append(issues, validationIssue{Field: headerMultiplier, Message: `baris dengan label "multiplication_percentage" di kolom A tidak ditemukan`})
|
|
}
|
|
if len(issues) > 0 {
|
|
return sheetName, nil, issues, nil
|
|
}
|
|
|
|
maxCols := len(dayRow)
|
|
if len(multRow) > maxCols {
|
|
maxCols = len(multRow)
|
|
}
|
|
|
|
rows := make([]curveRow, 0, maxCols-1)
|
|
seenDays := make(map[int]string)
|
|
|
|
for colIdx := 1; colIdx < maxCols; colIdx++ {
|
|
dayRaw := strings.TrimSpace(cellValue(dayRow, colIdx))
|
|
multRaw := strings.TrimSpace(cellValue(multRow, colIdx))
|
|
if dayRaw == "" && multRaw == "" {
|
|
continue
|
|
}
|
|
|
|
colName, _ := excelize.ColumnNumberToName(colIdx + 1)
|
|
|
|
var colIssues []validationIssue
|
|
day, dayErr := parsePositiveInt(dayRaw)
|
|
if dayErr != nil {
|
|
colIssues = append(colIssues, validationIssue{
|
|
Field: headerDay,
|
|
Message: fmt.Sprintf("col=%s: %s", colName, dayErr.Error()),
|
|
})
|
|
}
|
|
mult, multErr := parseMultiplication(multRaw)
|
|
if multErr != nil {
|
|
colIssues = append(colIssues, validationIssue{
|
|
Field: headerMultiplier,
|
|
Message: fmt.Sprintf("col=%s: %s", colName, multErr.Error()),
|
|
})
|
|
}
|
|
if day > 0 {
|
|
if prevCol, exists := seenDays[day]; exists {
|
|
colIssues = append(colIssues, validationIssue{
|
|
Field: headerDay,
|
|
Message: fmt.Sprintf("col=%s: duplicate day %d (already in col %s)", colName, day, prevCol),
|
|
})
|
|
} else {
|
|
seenDays[day] = colName
|
|
}
|
|
}
|
|
if len(colIssues) > 0 {
|
|
issues = append(issues, colIssues...)
|
|
continue
|
|
}
|
|
rows = append(rows, curveRow{Day: day, Mult: mult, ColRef: colName})
|
|
}
|
|
|
|
if len(rows) == 0 && len(issues) == 0 {
|
|
issues = append(issues, validationIssue{Field: "data", Message: "tidak ada kolom data setelah kolom A (label)"})
|
|
}
|
|
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Day < rows[j].Day })
|
|
return sheetName, rows, issues, nil
|
|
}
|
|
|
|
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
|
|
sheets := workbook.GetSheetList()
|
|
if len(sheets) == 0 {
|
|
return "", fmt.Errorf("workbook has no sheets")
|
|
}
|
|
if strings.TrimSpace(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 parsePositiveInt(raw string) (int, error) {
|
|
if raw == "" {
|
|
return 0, fmt.Errorf("is required")
|
|
}
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
floatValue, floatErr := strconv.ParseFloat(raw, 64)
|
|
if floatErr != nil || floatValue != float64(int(floatValue)) {
|
|
return 0, fmt.Errorf("must be a positive integer")
|
|
}
|
|
value = int(floatValue)
|
|
}
|
|
if value < 1 {
|
|
return 0, fmt.Errorf("must be greater than or equal to 1")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func parseMultiplication(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 || value > 1 {
|
|
return 0, fmt.Errorf("must be between 0 and 1 (inclusive)")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
|
|
// --- SQL generation ----------------------------------------------------------
|
|
|
|
// buildUpSQL generates INSERT blocks for each houseType in houseTypes.
|
|
// If flocks span multiple house_types, one INSERT block is generated per type,
|
|
// all sharing the same project_flock_ids array.
|
|
func buildUpSQL(opts options, houseTypes []string, curve []curveRow, flockIDs []uint) string {
|
|
var b strings.Builder
|
|
|
|
fmt.Fprintf(&b, "-- Kurva depresiasi khusus flock %s (house_types=%s, effective_date=%s).\n",
|
|
formatFlockIDs(flockIDs), strings.Join(houseTypes, ","), opts.EffectiveDate)
|
|
b.WriteString("-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.\n")
|
|
b.WriteString("-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.\n")
|
|
b.WriteString("-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.\n\n")
|
|
|
|
valTuples := formatValuesTuples(curve)
|
|
arrayLit := formatArrayLiteral(flockIDs)
|
|
|
|
for _, houseType := range houseTypes {
|
|
fmt.Fprintf(&b, "-- house_type: %s\n", houseType)
|
|
b.WriteString("INSERT INTO house_depreciation_standards\n")
|
|
b.WriteString(" (project_flock_ids, house_type, day, effective_date,\n")
|
|
b.WriteString(" multiplication_percentage, depreciation_percent, standard_week, name)\n")
|
|
b.WriteString("SELECT\n")
|
|
fmt.Fprintf(&b, " %s, g.house_type, g.day, DATE '%s',\n", arrayLit, opts.EffectiveDate)
|
|
b.WriteString(" v.mult, (1 - v.mult) * 100, g.standard_week,\n")
|
|
fmt.Fprintf(&b, " 'Custom flocks %s (eff %s)'\n", formatFlockIDs(flockIDs), opts.EffectiveDate)
|
|
b.WriteString("FROM (VALUES\n")
|
|
b.WriteString(valTuples)
|
|
b.WriteString("\n) AS v(day, mult)\n")
|
|
b.WriteString("JOIN LATERAL (\n")
|
|
b.WriteString(" SELECT DISTINCT ON (day) house_type, day, standard_week\n")
|
|
b.WriteString(" FROM house_depreciation_standards\n")
|
|
b.WriteString(" WHERE project_flock_ids IS NULL\n")
|
|
fmt.Fprintf(&b, " AND house_type = '%s'::house_type_enum\n", houseType)
|
|
b.WriteString(" AND day = v.day\n")
|
|
b.WriteString(" ORDER BY day, effective_date DESC NULLS LAST\n")
|
|
b.WriteString(") g ON TRUE;\n\n")
|
|
}
|
|
|
|
b.WriteString("-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.\n")
|
|
fmt.Fprintf(&b, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (%s);\n", inClause(flockIDs))
|
|
return b.String()
|
|
}
|
|
|
|
func buildDownSQL(opts options, flockIDs []uint) string {
|
|
var b strings.Builder
|
|
b.WriteString("-- Hapus baris kurva custom dari house_depreciation_standards.\n")
|
|
b.WriteString("-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).\n")
|
|
fmt.Fprintf(&b, "DELETE FROM house_depreciation_standards\nWHERE project_flock_ids = %s\n AND effective_date = DATE '%s';\n\n",
|
|
formatArrayLiteral(flockIDs), opts.EffectiveDate)
|
|
b.WriteString("-- Recompute snapshot depresiasi.\n")
|
|
fmt.Fprintf(&b, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (%s);\n", inClause(flockIDs))
|
|
return b.String()
|
|
}
|
|
|
|
// formatArrayLiteral renders []uint{52,53,54} as ARRAY[52,53,54]::bigint[].
|
|
func formatArrayLiteral(ids []uint) string {
|
|
parts := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
parts[i] = strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
return fmt.Sprintf("ARRAY[%s]::bigint[]", strings.Join(parts, ","))
|
|
}
|
|
|
|
// formatValuesTuples renders (day, mult) tuples 5 per line.
|
|
// The first multiplier is cast ::numeric so PostgreSQL infers the column type correctly.
|
|
func formatValuesTuples(curve []curveRow) string {
|
|
tuples := make([]string, len(curve))
|
|
for i, row := range curve {
|
|
mult := formatFloat(row.Mult)
|
|
if i == 0 {
|
|
mult += "::numeric"
|
|
}
|
|
tuples[i] = fmt.Sprintf("(%d, %s)", row.Day, mult)
|
|
}
|
|
|
|
var b strings.Builder
|
|
const perLine = 5
|
|
for i := 0; i < len(tuples); i += perLine {
|
|
end := i + perLine
|
|
if end > len(tuples) {
|
|
end = len(tuples)
|
|
}
|
|
b.WriteString(" ")
|
|
b.WriteString(strings.Join(tuples[i:end], ", "))
|
|
if end < len(tuples) {
|
|
b.WriteString(",\n")
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// inClause formats []uint{52,53,54} as "52, 53, 54" for SQL IN (...).
|
|
func inClause(ids []uint) string {
|
|
parts := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
parts[i] = strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func formatFloat(value float64) string {
|
|
return strconv.FormatFloat(value, 'g', -1, 64)
|
|
}
|
|
|
|
// --- DB helpers --------------------------------------------------------------
|
|
|
|
func assertActiveLayingFlock(ctx context.Context, db *gorm.DB, projectFlockID uint) error {
|
|
var count int64
|
|
err := db.WithContext(ctx).
|
|
Table("project_flocks").
|
|
Where("id = ?", projectFlockID).
|
|
Where("deleted_at IS NULL").
|
|
Where("category = ?", string(utils.ProjectFlockCategoryLaying)).
|
|
Count(&count).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return fmt.Errorf("project_flock_id %d must reference an active LAYING project_flock", projectFlockID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveHouseTypesForFlocks mengembalikan SEMUA distinct house_type dari kandang
|
|
// semua flock yang diberikan. Kalau -house-type di-pass → hanya type itu.
|
|
// Tidak error kalau multiple (generate INSERT block per type secara otomatis).
|
|
func resolveHouseTypesForFlocks(ctx context.Context, db *gorm.DB, flockIDs []uint, override string) ([]string, error) {
|
|
if strings.TrimSpace(override) != "" {
|
|
ht, err := normalizeHouseType(override)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{ht}, nil
|
|
}
|
|
|
|
houseTypes := make([]string, 0)
|
|
err := db.WithContext(ctx).Raw(`
|
|
SELECT DISTINCT k.house_type::text AS house_type
|
|
FROM project_flock_kandangs pfk
|
|
JOIN kandangs k ON k.id = pfk.kandang_id
|
|
WHERE pfk.project_flock_id IN ? AND k.house_type IS NOT NULL
|
|
ORDER BY house_type
|
|
`, flockIDs).Scan(&houseTypes).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(houseTypes) == 0 {
|
|
return nil, fmt.Errorf("no kandang house_type found for any of the specified flocks; pass -house-type explicitly")
|
|
}
|
|
|
|
// Bisa 1 atau lebih — generate INSERT block per type.
|
|
return houseTypes, nil
|
|
}
|
|
|
|
func normalizeHouseType(raw string) (string, error) {
|
|
normalized := strings.ToLower(strings.TrimSpace(raw))
|
|
switch normalized {
|
|
case "open_house", "close_house":
|
|
return normalized, nil
|
|
default:
|
|
return "", fmt.Errorf("house_type %q must be open_house or close_house", raw)
|
|
}
|
|
}
|
|
|
|
|
|
// --- misc helpers ------------------------------------------------------------
|
|
|
|
func dayRange(curve []curveRow) (int, int) {
|
|
minDay, maxDay := curve[0].Day, curve[0].Day
|
|
for _, row := range curve {
|
|
if row.Day < minDay {
|
|
minDay = row.Day
|
|
}
|
|
if row.Day > maxDay {
|
|
maxDay = row.Day
|
|
}
|
|
}
|
|
return minDay, maxDay
|
|
}
|
|
|
|
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 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 modeLabel(apply bool) string {
|
|
if apply {
|
|
return "APPLY"
|
|
}
|
|
return "DRY-RUN"
|
|
}
|