diff --git a/cmd/import-adjustment-stock-prices/main.go b/cmd/import-adjustment-stock-prices/main.go new file mode 100644 index 00000000..e4092e79 --- /dev/null +++ b/cmd/import-adjustment-stock-prices/main.go @@ -0,0 +1,587 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gorm.io/gorm" +) + +type importOptions struct { + FilePath string + Sheet string + Apply bool +} + +type headerIndexes struct { + AdjustmentID int + Weight int +} + +type adjustmentPriceImportRow struct { + RowNumber int + AdjustmentID uint + Weight float64 +} + +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 adjustmentResolver interface { + ResolveExistingAdjustmentIDs(ctx context.Context, adjustmentIDs []uint) (map[uint]struct{}, error) +} + +type dbAdjustmentResolver struct { + db *gorm.DB +} + +type adjustmentPriceStore interface { + UpdatePrice(ctx context.Context, adjustmentID uint, price float64) (bool, error) +} + +type txRunner interface { + InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error +} + +type dbTxRunner struct { + db *gorm.DB +} + +type dbAdjustmentPriceStore struct { + db *gorm.DB +} + +type applyRowResult struct { + RowNumber int + AdjustmentID uint + Price float64 + Changed bool +} + +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") + } + + sheetName, rows, parseIssues, err := parseAdjustmentPriceFile(opts.FilePath, opts.Sheet) + if err != nil { + log.Fatalf("failed reading excel: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + resolver := dbAdjustmentResolver{db: db} + + existingAdjustmentIDs, err := resolver.ResolveExistingAdjustmentIDs(ctx, collectAdjustmentIDs(rows)) + if err != nil { + log.Fatalf("failed checking adjustment_id against adjustment_stocks: %v", err) + } + + processableRows, skippedRows := splitRowsByExistingIDs(rows, existingAdjustmentIDs) + 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("Rows parsed: %d\n", len(rows)) + fmt.Printf("Rows invalid: %d\n", len(issues)) + fmt.Printf("Rows processable: %d\n", len(processableRows)) + fmt.Printf("Rows skipped_missing: %d\n", len(skippedRows)) + fmt.Println() + + if len(processableRows) > 0 { + printPlanRows(processableRows) + } + if len(skippedRows) > 0 { + printSkippedRows(skippedRows) + } + if len(processableRows) > 0 || len(skippedRows) > 0 { + 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 processable=%d skipped_missing=%d applied=0 failed=%d\n", + len(rows), + len(processableRows), + len(skippedRows), + len(issues), + ) + os.Exit(1) + } + + if !opts.Apply { + fmt.Printf( + "Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=0\n", + len(rows), + len(processableRows), + len(skippedRows), + ) + return + } + + results, err := applyIfRequested(ctx, true, dbTxRunner{db: db}, processableRows) + if err != nil { + log.Fatalf("apply failed: %v", err) + } + + for _, result := range results { + fmt.Printf( + "DONE row=%d adjustment_id=%d price=%.3f status=%s\n", + result.RowNumber, + result.AdjustmentID, + result.Price, + applyStatus(result.Changed), + ) + } + + appliedCount := countChangedRows(results) + if len(results) > 0 { + fmt.Println() + } + fmt.Printf( + "Summary: planned=%d processable=%d skipped_missing=%d applied=%d failed=0\n", + len(rows), + len(processableRows), + len(skippedRows), + appliedCount, + ) +} + +func parseAdjustmentPriceFile( + filePath string, + requestedSheet string, +) (string, []adjustmentPriceImportRow, []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 + } + + rowsByAdjustmentID := make(map[uint]adjustmentPriceImportRow) + issues := make([]validationIssue, 0) + + for idx := 1; idx < len(allRows); idx++ { + rowNumber := idx + 1 + rawRow := allRows[idx] + + if isRowEmpty(rawRow) { + continue + } + + parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes) + if len(rowIssues) > 0 { + issues = append(issues, rowIssues...) + continue + } + + rowsByAdjustmentID[parsed.AdjustmentID] = *parsed + } + + rows := make([]adjustmentPriceImportRow, 0, len(rowsByAdjustmentID)) + for _, row := range rowsByAdjustmentID { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].RowNumber < rows[j].RowNumber + }) + + 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{AdjustmentID: -1, Weight: -1} + issues := make([]validationIssue, 0) + + for idx, raw := range headerRow { + header := normalizeHeader(raw) + if header == "" { + continue + } + + switch header { + case "adjustment_id": + if indexes.AdjustmentID >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header adjustment_id"}) + } + indexes.AdjustmentID = idx + case "weight": + if indexes.Weight >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header weight"}) + } + indexes.Weight = idx + } + } + + if indexes.AdjustmentID < 0 { + issues = append(issues, validationIssue{Field: "adjustment_id", Message: "required header is missing"}) + } + if indexes.Weight < 0 { + issues = append(issues, validationIssue{Field: "weight", Message: "required header is missing"}) + } + + return indexes, issues +} + +func parseDataRow( + rawRow []string, + rowNumber int, + indexes headerIndexes, +) (*adjustmentPriceImportRow, []validationIssue) { + issues := make([]validationIssue, 0) + + adjustmentIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.AdjustmentID)) + adjustmentID, err := parsePositiveUint(adjustmentIDRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "adjustment_id", Message: err.Error()}) + } + + weightRaw := strings.TrimSpace(cellValue(rawRow, indexes.Weight)) + weight, err := parseNonNegativeFloat(weightRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "weight", Message: err.Error()}) + } + + if len(issues) > 0 { + return nil, issues + } + + return &adjustmentPriceImportRow{ + RowNumber: rowNumber, + AdjustmentID: adjustmentID, + Weight: weight, + }, 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 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 collectAdjustmentIDs(rows []adjustmentPriceImportRow) []uint { + ids := make([]uint, 0, len(rows)) + seen := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + if row.AdjustmentID == 0 { + continue + } + if _, exists := seen[row.AdjustmentID]; exists { + continue + } + seen[row.AdjustmentID] = struct{}{} + ids = append(ids, row.AdjustmentID) + } + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + return ids +} + +func (r dbAdjustmentResolver) ResolveExistingAdjustmentIDs( + ctx context.Context, + adjustmentIDs []uint, +) (map[uint]struct{}, error) { + result := make(map[uint]struct{}) + if len(adjustmentIDs) == 0 { + return result, nil + } + + type adjustmentIDRow struct { + ID uint `gorm:"column:id"` + } + + rows := make([]adjustmentIDRow, 0, len(adjustmentIDs)) + if err := r.db.WithContext(ctx). + Table("adjustment_stocks"). + Select("id"). + Where("id IN ?", adjustmentIDs). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ID] = struct{}{} + } + + return result, nil +} + +func splitRowsByExistingIDs( + rows []adjustmentPriceImportRow, + existing map[uint]struct{}, +) ([]adjustmentPriceImportRow, []adjustmentPriceImportRow) { + processable := make([]adjustmentPriceImportRow, 0, len(rows)) + skipped := make([]adjustmentPriceImportRow, 0) + + for _, row := range rows { + if _, exists := existing[row.AdjustmentID]; exists { + processable = append(processable, row) + continue + } + skipped = append(skipped, row) + } + + return processable, skipped +} + +func printPlanRows(rows []adjustmentPriceImportRow) { + for _, row := range rows { + fmt.Printf( + "PLAN row=%d adjustment_id=%d price=%.3f\n", + row.RowNumber, + row.AdjustmentID, + row.Weight, + ) + } +} + +func printSkippedRows(rows []adjustmentPriceImportRow) { + for _, row := range rows { + fmt.Printf( + "SKIP row=%d adjustment_id=%d reason=adjustment_id not found\n", + row.RowNumber, + row.AdjustmentID, + ) + } +} + +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 []adjustmentPriceImportRow, +) ([]applyRowResult, error) { + if !apply || len(rows) == 0 { + return nil, nil + } + return applyImportRows(ctx, runner, rows) +} + +func applyImportRows( + ctx context.Context, + runner txRunner, + rows []adjustmentPriceImportRow, +) ([]applyRowResult, error) { + results := make([]applyRowResult, 0, len(rows)) + + err := runner.InTx(ctx, func(store adjustmentPriceStore) error { + for _, row := range rows { + changed, err := store.UpdatePrice(ctx, row.AdjustmentID, row.Weight) + if err != nil { + return fmt.Errorf("row %d adjustment_id=%d update failed: %w", row.RowNumber, row.AdjustmentID, err) + } + + results = append(results, applyRowResult{ + RowNumber: row.RowNumber, + AdjustmentID: row.AdjustmentID, + Price: row.Weight, + Changed: changed, + }) + } + return nil + }) + if err != nil { + return nil, err + } + + return results, nil +} + +func (r dbTxRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(dbAdjustmentPriceStore{db: tx}) + }) +} + +func (s dbAdjustmentPriceStore) UpdatePrice( + ctx context.Context, + adjustmentID uint, + price float64, +) (bool, error) { + result := s.db.WithContext(ctx).Exec(` + UPDATE adjustment_stocks + SET price = ?, + updated_at = NOW() + WHERE id = ? + AND price IS DISTINCT FROM ? + `, price, adjustmentID, price) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func applyStatus(changed bool) string { + if changed { + return "UPDATED" + } + return "UNCHANGED" +} + +func countChangedRows(results []applyRowResult) int { + count := 0 + for _, result := range results { + if result.Changed { + count++ + } + } + return count +} diff --git a/cmd/import-adjustment-stock-prices/main_test.go b/cmd/import-adjustment-stock-prices/main_test.go new file mode 100644 index 00000000..121ddc81 --- /dev/null +++ b/cmd/import-adjustment-stock-prices/main_test.go @@ -0,0 +1,362 @@ +package main + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestParseAdjustmentPriceFile_ValidSingleRow(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "weight"}, + [][]string{{"101", "12.345"}}, + ) + + sheet, rows, issues, err := parseAdjustmentPriceFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if sheet != "adjustment_prices" { + t.Fatalf("expected selected sheet adjustment_prices, got %q", sheet) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].AdjustmentID != 101 { + t.Fatalf("expected adjustment_id 101, got %d", rows[0].AdjustmentID) + } + if rows[0].Weight != 12.345 { + t.Fatalf("expected weight 12.345, got %v", rows[0].Weight) + } +} + +func TestParseAdjustmentPriceFile_ValidMultiRow(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{" Adjustment_ID ", "WEIGHT"}, + [][]string{{"101", "10"}, {"102", "11.5"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(filePath, "adjustment_prices") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } +} + +func TestParseAdjustmentPriceFile_MissingRequiredHeader(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "price"}, + [][]string{{"101", "12"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows)) + } + if !hasIssue(issues, 0, "weight", "required header is missing") { + t.Fatalf("expected missing weight header issue, got %+v", issues) + } +} + +func TestParseAdjustmentPriceFile_InvalidAdjustmentID(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "weight"}, + [][]string{{"abc", "10"}, {"0", "12"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "adjustment_id", "must be a positive integer") { + t.Fatalf("expected non numeric adjustment_id issue, got %+v", issues) + } + if !hasIssue(issues, 3, "adjustment_id", "must be greater than 0") { + t.Fatalf("expected adjustment_id >0 issue, got %+v", issues) + } +} + +func TestParseAdjustmentPriceFile_InvalidWeight(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "weight"}, + [][]string{{"101", "abc"}, {"102", "-1"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "weight", "must be numeric") { + t.Fatalf("expected weight numeric issue, got %+v", issues) + } + if !hasIssue(issues, 3, "weight", "must be greater than or equal to 0") { + t.Fatalf("expected weight >=0 issue, got %+v", issues) + } +} + +func TestParseAdjustmentPriceFile_DuplicateAdjustmentID_LastRowWins(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "weight"}, + [][]string{{"101", "10"}, {"102", "20"}, {"101", "30"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 2 { + t.Fatalf("expected 2 deduped rows, got %d", len(rows)) + } + + row101, ok := findRowByAdjustmentID(rows, 101) + if !ok { + t.Fatalf("expected adjustment_id 101 to exist, got %+v", rows) + } + if row101.Weight != 30 { + t.Fatalf("expected duplicate adjustment_id to keep last weight 30, got %v", row101.Weight) + } + if row101.RowNumber != 4 { + t.Fatalf("expected duplicate adjustment_id to keep last row number 4, got %d", row101.RowNumber) + } +} + +func TestSplitRowsByExistingIDs_SkipMissing(t *testing.T) { + rows := []adjustmentPriceImportRow{ + {RowNumber: 2, AdjustmentID: 101, Weight: 10}, + {RowNumber: 3, AdjustmentID: 102, Weight: 11}, + {RowNumber: 4, AdjustmentID: 103, Weight: 12}, + } + existing := map[uint]struct{}{101: {}, 103: {}} + + processable, skipped := splitRowsByExistingIDs(rows, existing) + if len(processable) != 2 { + t.Fatalf("expected 2 processable rows, got %d", len(processable)) + } + if len(skipped) != 1 { + t.Fatalf("expected 1 skipped row, got %d", len(skipped)) + } + if skipped[0].AdjustmentID != 102 { + t.Fatalf("expected adjustment_id 102 skipped, got %+v", skipped) + } +} + +func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) { + runner := &fakeTransactionRunner{} + rows := []adjustmentPriceImportRow{{RowNumber: 2, AdjustmentID: 101, Weight: 10}} + + results, err := applyIfRequested(context.Background(), false, runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if results != nil { + t.Fatalf("expected nil results on dry-run, got %+v", results) + } + if runner.txCalls != 0 { + t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls) + } +} + +func TestApplyImportRows_Success(t *testing.T) { + runner := &fakeTransactionRunner{ + changedByID: map[uint]bool{101: true, 102: false}, + } + rows := []adjustmentPriceImportRow{ + {RowNumber: 2, AdjustmentID: 101, Weight: 10}, + {RowNumber: 3, AdjustmentID: 102, Weight: 11}, + } + + results, err := applyImportRows(context.Background(), runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedCalls) != 2 { + t.Fatalf("expected 2 committed updates, got %d", len(runner.committedCalls)) + } + if len(results) != 2 { + t.Fatalf("expected 2 row results, got %d", len(results)) + } + if !results[0].Changed || results[1].Changed { + t.Fatalf("unexpected changed flags: %+v", results) + } +} + +func TestApplyImportRows_RollbackOnError(t *testing.T) { + runner := &fakeTransactionRunner{ + errByID: map[uint]error{102: errors.New("boom")}, + } + rows := []adjustmentPriceImportRow{ + {RowNumber: 2, AdjustmentID: 101, Weight: 10}, + {RowNumber: 3, AdjustmentID: 102, Weight: 11}, + } + + _, err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatal("expected error due to update failure") + } + if !strings.Contains(err.Error(), "row 3 adjustment_id=102 update failed") { + t.Fatalf("unexpected error message: %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedCalls) != 0 { + t.Fatalf("expected no committed updates on rollback, got %d", len(runner.committedCalls)) + } +} + +func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string { + t.Helper() + + f := excelize.NewFile() + defaultSheet := f.GetSheetName(f.GetActiveSheetIndex()) + if sheetName == "" { + sheetName = defaultSheet + } else if sheetName != defaultSheet { + f.SetSheetName(defaultSheet, sheetName) + } + + for idx, header := range headers { + cell, err := excelize.CoordinatesToCellName(idx+1, 1) + if err != nil { + t.Fatalf("failed resolving header cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, header); err != nil { + t.Fatalf("failed setting header cell: %v", err) + } + } + + for rowIdx, row := range rows { + for colIdx, value := range row { + cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) + if err != nil { + t.Fatalf("failed resolving data cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, value); err != nil { + t.Fatalf("failed setting data cell: %v", err) + } + } + } + + path := filepath.Join(t.TempDir(), "adjustment_prices.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatalf("failed saving workbook: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("failed closing workbook: %v", err) + } + + return path +} + +func hasIssue(issues []validationIssue, row int, field, messageContains string) bool { + for _, issue := range issues { + if issue.Row != row { + continue + } + if issue.Field != field { + continue + } + if strings.Contains(issue.Message, messageContains) { + return true + } + } + return false +} + +func findRowByAdjustmentID(rows []adjustmentPriceImportRow, adjustmentID uint) (adjustmentPriceImportRow, bool) { + for _, row := range rows { + if row.AdjustmentID == adjustmentID { + return row, true + } + } + return adjustmentPriceImportRow{}, false +} + +type updateCall struct { + adjustmentID uint + price float64 +} + +type fakeAdjustmentPriceStore struct { + changedByID map[uint]bool + errByID map[uint]error + calls []updateCall +} + +func (s *fakeAdjustmentPriceStore) UpdatePrice(_ context.Context, adjustmentID uint, price float64) (bool, error) { + s.calls = append(s.calls, updateCall{adjustmentID: adjustmentID, price: price}) + if err, exists := s.errByID[adjustmentID]; exists { + return false, fmt.Errorf("forced update failure for adjustment_id=%d: %w", adjustmentID, err) + } + if changed, exists := s.changedByID[adjustmentID]; exists { + return changed, nil + } + return true, nil +} + +type fakeTransactionRunner struct { + txCalls int + changedByID map[uint]bool + errByID map[uint]error + committedCalls []updateCall +} + +func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error { + r.txCalls++ + + txStore := &fakeAdjustmentPriceStore{ + changedByID: r.changedByID, + errByID: r.errByID, + calls: make([]updateCall, 0), + } + + if err := fn(txStore); err != nil { + return err + } + + r.committedCalls = append(r.committedCalls, txStore.calls...) + return nil +} + +var _ txRunner = (*fakeTransactionRunner)(nil) +var _ adjustmentPriceStore = (*fakeAdjustmentPriceStore)(nil) diff --git a/cmd/import-farm-depreciation-manual-inputs/main.go b/cmd/import-farm-depreciation-manual-inputs/main.go new file mode 100644 index 00000000..51c0fe66 --- /dev/null +++ b/cmd/import-farm-depreciation-manual-inputs/main.go @@ -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 +} diff --git a/cmd/import-farm-depreciation-manual-inputs/main_test.go b/cmd/import-farm-depreciation-manual-inputs/main_test.go new file mode 100644 index 00000000..0a2d8439 --- /dev/null +++ b/cmd/import-farm-depreciation-manual-inputs/main_test.go @@ -0,0 +1,563 @@ +package main + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/xuri/excelize/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func TestParseManualInputFile_ValidSingleRow(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "12345.678", "2026-06-01", "manual seed"}, + }, + ) + + location := mustJakartaLocation(t) + sheet, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if sheet != "manual_inputs" { + t.Fatalf("expected selected sheet manual_inputs, got %q", sheet) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].ProjectFlockID != 101 { + t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID) + } + if rows[0].TotalCost != 12345.678 { + t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost) + } + if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" { + t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout)) + } + if rows[0].Note == nil || *rows[0].Note != "manual seed" { + t.Fatalf("expected note manual seed, got %+v", rows[0].Note) + } +} + +func TestParseManualInputFile_ValidMultiRow(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"102", "1300.5", "2026-06-02", "second"}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if rows[0].Note != nil { + t.Fatalf("expected first row note nil, got %+v", rows[0].Note) + } + if rows[1].Note == nil || *rows[1].Note != "second" { + t.Fatalf("expected second row note second, got %+v", rows[1].Note) + } +} + +func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "totalcost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows)) + } + if !hasIssue(issues, 0, "total_cost", "required header is missing") { + t.Fatalf("expected missing total_cost header issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"abc", "1200", "2026-06-01", ""}, + {"0", "1300", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") { + t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues) + } + if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") { + t.Fatalf("expected project_flock_id >0 issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidTotalCost(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "abc", "2026-06-01", ""}, + {"102", "-1", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "total_cost", "must be numeric") { + t.Fatalf("expected total_cost numeric issue, got %+v", issues) + } + if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") { + t.Fatalf("expected total_cost >=0 issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "06-01-2026", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") { + t.Fatalf("expected cutover_date format issue, got %+v", issues) + } +} + +func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"101", "1300", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows)) + } + if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") { + t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues) + } +} + +func TestParseManualInputFile_NoteValidation(t *testing.T) { + longNote := strings.Repeat("a", 1001) + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"102", "1300", "2026-06-02", longNote}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected only first row valid, got %d", len(rows)) + } + if rows[0].Note != nil { + t.Fatalf("expected first row note nil, got %+v", rows[0].Note) + } + if !hasIssue(issues, 3, "note", "at most 1000 characters") { + t.Fatalf("expected note length issue, got %+v", issues) + } +} + +func TestApplyImportRows_Success(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{} + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 2000, + CutoverDate: mustDateInLocation(t, "2026-06-02", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedUpserts) != 2 { + t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts)) + } + if len(runner.committedInvalidations) != 2 { + t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations)) + } + if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 { + t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations) + } +} + +func TestApplyImportRows_RollbackOnError(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{ + failUpsertOnProjectFlockID: 102, + } + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 2000, + CutoverDate: mustDateInLocation(t, "2026-06-02", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatal("expected error due to upsert failure") + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedUpserts) != 0 { + t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts)) + } + if len(runner.committedInvalidations) != 0 { + t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations)) + } +} + +func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{} + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + err := applyIfRequested(context.Background(), false, runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 0 { + t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls) + } +} + +func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string { + t.Helper() + + f := excelize.NewFile() + defaultSheet := f.GetSheetName(f.GetActiveSheetIndex()) + if sheetName == "" { + sheetName = defaultSheet + } else if sheetName != defaultSheet { + f.SetSheetName(defaultSheet, sheetName) + } + + for idx, header := range headers { + cell, err := excelize.CoordinatesToCellName(idx+1, 1) + if err != nil { + t.Fatalf("failed resolving header cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, header); err != nil { + t.Fatalf("failed setting header cell: %v", err) + } + } + + for rowIdx, row := range rows { + for colIdx, value := range row { + cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) + if err != nil { + t.Fatalf("failed resolving data cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, value); err != nil { + t.Fatalf("failed setting data cell: %v", err) + } + } + } + + path := filepath.Join(t.TempDir(), "manual_inputs.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatalf("failed saving workbook: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("failed closing workbook: %v", err) + } + + return path +} + +func mustJakartaLocation(t *testing.T) *time.Location { + t.Helper() + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading Asia/Jakarta location: %v", err) + } + return location +} + +func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time { + t.Helper() + value, err := time.ParseInLocation(dateLayout, raw, location) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + return value +} + +func hasIssue(issues []validationIssue, row int, field, messageContains string) bool { + for _, issue := range issues { + if issue.Row != row { + continue + } + if issue.Field != field { + continue + } + if strings.Contains(issue.Message, messageContains) { + return true + } + } + return false +} + +type fakeInvalidation struct { + fromDate time.Time + farmIDs []uint +} + +type fakeManualInputStore struct { + failUpsertOnProjectFlockID uint + failDeleteOnProjectFlockID uint + upserts []entity.FarmDepreciationManualInput + invalidations []fakeInvalidation +} + +func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error { + if row == nil { + return nil + } + if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID { + return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId) + } + cloned := *row + s.upserts = append(s.upserts, cloned) + return nil +} + +func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { + if s.failDeleteOnProjectFlockID > 0 { + for _, farmID := range farmIDs { + if farmID == s.failDeleteOnProjectFlockID { + return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID) + } + } + } + copiedFarmIDs := append([]uint{}, farmIDs...) + s.invalidations = append(s.invalidations, fakeInvalidation{ + fromDate: fromDate, + farmIDs: copiedFarmIDs, + }) + return nil +} + +type fakeTransactionRunner struct { + txCalls int + failUpsertOnProjectFlockID uint + failDeleteOnProjectFlockID uint + committedUpserts []entity.FarmDepreciationManualInput + committedInvalidations []fakeInvalidation +} + +func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error { + r.txCalls++ + + txStore := &fakeManualInputStore{ + failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID, + failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID, + } + + if err := fn(txStore); err != nil { + return err + } + + r.committedUpserts = append(r.committedUpserts, txStore.upserts...) + r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...) + return nil +} + +var _ txRunner = (*fakeTransactionRunner)(nil) +var _ manualInputStore = (*fakeManualInputStore)(nil) + +func TestBuildMissingFarmIssues(t *testing.T) { + location := mustJakartaLocation(t) + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + issues := buildMissingFarmIssues(rows, map[uint]string{ + 101: "Farm A", + }) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %+v", issues) + } + if issues[0].Row != 3 || issues[0].Field != "project_flock_id" { + t.Fatalf("unexpected issue: %+v", issues[0]) + } +} + +func TestApplyImportRows_PropagatesDeleteError(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{ + failDeleteOnProjectFlockID: 101, + } + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatal("expected delete failure") + } + if !strings.Contains(err.Error(), "snapshot invalidation failed") { + t.Fatalf("expected snapshot invalidation error message, got %v", err) + } +} + +func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) { + workbook := excelize.NewFile() + defer func() { + _ = workbook.Close() + }() + + _, err := resolveSheetName(workbook, "unknown") + if err == nil { + t.Fatal("expected error when sheet is missing") + } +} + +func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) { + location := mustJakartaLocation(t) + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + runner := &errorTxRunner{err: errors.New("tx failed")} + + err := applyIfRequested(context.Background(), true, runner, rows) + if err == nil { + t.Fatal("expected transaction error") + } + if err.Error() != "tx failed" { + t.Fatalf("unexpected error: %v", err) + } +} + +type errorTxRunner struct { + err error +} + +func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error { + return r.err +} diff --git a/cmd/import-kandang-house-types/main.go b/cmd/import-kandang-house-types/main.go new file mode 100644 index 00000000..27e97ffb --- /dev/null +++ b/cmd/import-kandang-house-types/main.go @@ -0,0 +1,602 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + + "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" +) + +type importOptions struct { + FilePath string + Sheet string + Apply bool +} + +type headerIndexes struct { + KandangID int + KandangName int + HouseType int +} + +type kandangHouseTypeImportRow struct { + RowNumber int + KandangID uint + KandangName string + HouseType 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 kandangResolver interface { + ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) +} + +type dbKandangResolver struct { + db *gorm.DB +} + +type txRunner interface { + InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error +} + +type dbTxRunner struct { + db *gorm.DB +} + +type kandangHouseTypeStore interface { + UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) + NormalizeNullHouseType(ctx context.Context) (int64, error) +} + +type dbKandangHouseTypeStore struct { + db *gorm.DB +} + +type kandangIdentityRow struct { + ID uint `gorm:"column:id"` + Name string `gorm:"column:name"` +} + +type applyRowResult struct { + RowNumber int + KandangID uint + HouseType string + Changed bool +} + +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") + } + + sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet) + if err != nil { + log.Fatalf("failed reading excel: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + resolver := dbKandangResolver{db: db} + + kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows)) + if err != nil { + log.Fatalf("failed validating kandang_id against kandangs: %v", err) + } + + issues := append([]validationIssue{}, parseIssues...) + issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...) + issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...) + 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, kandangNameByID) + 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 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues)) + os.Exit(1) + } + + if !opts.Apply { + fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows)) + return + } + + rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows) + if err != nil { + log.Fatalf("apply failed: %v", err) + } + + for _, result := range rowResults { + fmt.Printf( + "DONE row=%d kandang_id=%d house_type=%s status=%s\n", + result.RowNumber, + result.KandangID, + result.HouseType, + applyStatus(result.Changed), + ) + } + + appliedCount := countChangedRows(rowResults) + fmt.Println() + fmt.Printf( + "Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n", + len(rows), + appliedCount, + normalizedCount, + ) +} + +func parseKandangHouseTypeFile( + filePath string, + requestedSheet string, +) (string, []kandangHouseTypeImportRow, []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([]kandangHouseTypeImportRow, 0, len(allRows)-1) + issues := make([]validationIssue, 0) + seenKandangIDs := 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, seenKandangIDs) + 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{KandangID: -1, KandangName: -1, HouseType: -1} + issues := make([]validationIssue, 0) + + for idx, raw := range headerRow { + header := normalizeHeader(raw) + if header == "" { + continue + } + + switch header { + case "kandang_id": + if indexes.KandangID >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"}) + } + indexes.KandangID = idx + case "kandang_name": + if indexes.KandangName >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"}) + } + indexes.KandangName = idx + case "house_type", "type_house": + if indexes.HouseType >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"}) + } + indexes.HouseType = idx + } + } + + if indexes.KandangID < 0 { + issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"}) + } + if indexes.KandangName < 0 { + issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"}) + } + if indexes.HouseType < 0 { + issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"}) + } + + return indexes, issues +} + +func parseDataRow( + rawRow []string, + rowNumber int, + indexes headerIndexes, + seenKandangIDs map[uint]int, +) (*kandangHouseTypeImportRow, []validationIssue) { + issues := make([]validationIssue, 0) + + kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID)) + kandangID, err := parsePositiveUint(kandangIDRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()}) + } + + kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName)) + if kandangNameRaw == "" { + issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"}) + } + + houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType)) + houseType, err := normalizeHouseType(houseTypeRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()}) + } + + if kandangID > 0 { + if previousRow, exists := seenKandangIDs[kandangID]; exists { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "kandang_id", + Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow), + }) + } else { + seenKandangIDs[kandangID] = rowNumber + } + } + + if len(issues) > 0 { + return nil, issues + } + + return &kandangHouseTypeImportRow{ + RowNumber: rowNumber, + KandangID: kandangID, + KandangName: kandangNameRaw, + HouseType: houseType, + }, nil +} + +func normalizeHouseType(raw string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" { + return string(utils.HouseTypeOpenHouse), nil + } + + switch normalized { + case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse): + return normalized, nil + default: + return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)") + } +} + +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 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 collectKandangIDs(rows []kandangHouseTypeImportRow) []uint { + ids := make([]uint, 0, len(rows)) + seen := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + if row.KandangID == 0 { + continue + } + if _, exists := seen[row.KandangID]; exists { + continue + } + seen[row.KandangID] = struct{}{} + ids = append(ids, row.KandangID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) { + result := make(map[uint]string) + if len(kandangIDs) == 0 { + return result, nil + } + + rows := make([]kandangIdentityRow, 0, len(kandangIDs)) + if err := r.db.WithContext(ctx). + Table("kandangs"). + Select("id, name"). + Where("id IN ?", kandangIDs). + Where("deleted_at IS NULL"). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ID] = row.Name + } + + return result, nil +} + +func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue { + issues := make([]validationIssue, 0) + for _, row := range rows { + if _, exists := kandangNameByID[row.KandangID]; exists { + continue + } + issues = append(issues, validationIssue{ + Row: row.RowNumber, + Field: "kandang_id", + Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID), + }) + } + return issues +} + +func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue { + issues := make([]validationIssue, 0) + for _, row := range rows { + dbName, exists := kandangNameByID[row.KandangID] + if !exists { + continue + } + if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) { + continue + } + + issues = append(issues, validationIssue{ + Row: row.RowNumber, + Field: "kandang_name", + Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName), + }) + } + return issues +} + +func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) { + for _, row := range rows { + fmt.Printf( + "PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n", + row.RowNumber, + row.KandangID, + row.KandangName, + kandangNameByID[row.KandangID], + row.HouseType, + ) + } +} + +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 applyImportRows( + ctx context.Context, + runner txRunner, + rows []kandangHouseTypeImportRow, +) ([]applyRowResult, int64, error) { + results := make([]applyRowResult, 0, len(rows)) + normalizedNullCount := int64(0) + + err := runner.InTx(ctx, func(store kandangHouseTypeStore) error { + for _, row := range rows { + changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType) + if err != nil { + return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err) + } + + results = append(results, applyRowResult{ + RowNumber: row.RowNumber, + KandangID: row.KandangID, + HouseType: row.HouseType, + Changed: changed, + }) + } + + normalized, err := store.NormalizeNullHouseType(ctx) + if err != nil { + return fmt.Errorf("normalize null house_type to open_house failed: %w", err) + } + normalizedNullCount = normalized + + return nil + }) + if err != nil { + return nil, 0, err + } + + return results, normalizedNullCount, nil +} + +func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(dbKandangHouseTypeStore{db: tx}) + }) +} + +func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) { + result := s.db.WithContext(ctx).Exec(` + UPDATE kandangs + SET house_type = ?::house_type_enum, + updated_at = NOW() + WHERE id = ? + AND deleted_at IS NULL + AND house_type IS DISTINCT FROM ?::house_type_enum + `, houseType, kandangID, houseType) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) { + result := s.db.WithContext(ctx).Exec(` + UPDATE kandangs + SET house_type = 'open_house'::house_type_enum, + updated_at = NOW() + WHERE deleted_at IS NULL + AND house_type IS NULL + `) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func applyStatus(changed bool) string { + if changed { + return "UPDATED" + } + return "UNCHANGED" +} + +func countChangedRows(results []applyRowResult) int { + count := 0 + for _, item := range results { + if item.Changed { + count++ + } + } + return count +} diff --git a/cmd/import-kandang-house-types/main_test.go b/cmd/import-kandang-house-types/main_test.go new file mode 100644 index 00000000..bc733ed9 --- /dev/null +++ b/cmd/import-kandang-house-types/main_test.go @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{{"101", "Kandang A1", ""}}, + ) + + sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if sheet != "kandang_house_type" { + t.Fatalf("expected sheet kandang_house_type, got %q", sheet) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].KandangID != 101 { + t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID) + } + if rows[0].KandangName != "Kandang A1" { + t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName) + } + if rows[0].HouseType != "open_house" { + t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType) + } +} + +func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "type_house"}, + [][]string{{"101", "Kandang A1", "close_house"}}, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 || rows[0].HouseType != "close_house" { + t.Fatalf("expected parsed close_house row, got %+v", rows) + } +} + +func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{{"101", "Kandang A1", "semi_house"}}, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "house_type", "must be one of") { + t.Fatalf("expected invalid house_type issue, got %+v", issues) + } +} + +func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{ + {"101", "Kandang A1", "open_house"}, + {"101", "Kandang A2", "close_house"}, + }, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected first row valid and second invalid, got %d", len(rows)) + } + if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") { + t.Fatalf("expected duplicate kandang_id issue, got %+v", issues) + } +} + +func TestBuildNameMismatchIssues(t *testing.T) { + rows := []kandangHouseTypeImportRow{{ + RowNumber: 2, + KandangID: 10, + KandangName: "Kandang Salah", + HouseType: "open_house", + }} + + issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"}) + if !hasIssue(issues, 2, "kandang_name", "does not match") { + t.Fatalf("expected name mismatch issue, got %+v", issues) + } +} + +func TestApplyImportRows_Success(t *testing.T) { + store := &fakeStore{ + changedByID: map[uint]bool{101: true, 102: false}, + normalizeResult: 3, + } + runner := &fakeTransactionRunner{store: store} + + rows := []kandangHouseTypeImportRow{ + {RowNumber: 2, KandangID: 101, HouseType: "open_house"}, + {RowNumber: 3, KandangID: 102, HouseType: "close_house"}, + } + + results, normalized, err := applyImportRows(context.Background(), runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 tx call, got %d", runner.txCalls) + } + if len(results) != 2 { + t.Fatalf("expected 2 row results, got %d", len(results)) + } + if normalized != 3 { + t.Fatalf("expected normalized count 3, got %d", normalized) + } + if !results[0].Changed || results[1].Changed { + t.Fatalf("unexpected changed flags: %+v", results) + } + if len(store.updateCalls) != 2 { + t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls)) + } +} + +func TestApplyImportRows_FailOnUpdate(t *testing.T) { + store := &fakeStore{ + updateErrByID: map[uint]error{101: errors.New("boom")}, + } + runner := &fakeTransactionRunner{store: store} + + rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}} + + _, _, err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), "update failed") { + t.Fatalf("expected update failed error, got %v", err) + } +} + +func TestCountChangedRows(t *testing.T) { + count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}}) + if count != 2 { + t.Fatalf("expected 2 changed rows, got %d", count) + } +} + +type fakeTransactionRunner struct { + store *fakeStore + txCalls int +} + +func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error { + f.txCalls++ + return fn(f.store) +} + +type updateCall struct { + kandangID uint + houseType string +} + +type fakeStore struct { + updateCalls []updateCall + changedByID map[uint]bool + updateErrByID map[uint]error + normalizeResult int64 + normalizeErr error +} + +func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) { + f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType}) + if err, exists := f.updateErrByID[kandangID]; exists { + return false, err + } + if changed, exists := f.changedByID[kandangID]; exists { + return changed, nil + } + return true, nil +} + +func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) { + if f.normalizeErr != nil { + return 0, f.normalizeErr + } + return f.normalizeResult, nil +} + +func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string { + t.Helper() + + f := excelize.NewFile() + if sheetName == "" { + sheetName = "Sheet1" + } + defaultSheet := f.GetSheetName(0) + if defaultSheet != sheetName { + idx, err := f.NewSheet(sheetName) + if err != nil { + t.Fatalf("failed creating sheet: %v", err) + } + f.SetActiveSheet(idx) + _ = f.DeleteSheet(defaultSheet) + } + + for idx, header := range headers { + cell, err := excelize.CoordinatesToCellName(idx+1, 1) + if err != nil { + t.Fatalf("failed computing header cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, header); err != nil { + t.Fatalf("failed setting header cell: %v", err) + } + } + + for rowIdx, row := range rows { + for colIdx, value := range row { + cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) + if err != nil { + t.Fatalf("failed computing row cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, value); err != nil { + t.Fatalf("failed setting row cell: %v", err) + } + } + } + + path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatalf("failed saving workbook: %v", err) + } + + return path +} + +func hasIssue(issues []validationIssue, row int, field string, contains string) bool { + for _, issue := range issues { + if issue.Row != row { + continue + } + if issue.Field != field { + continue + } + if strings.Contains(issue.Message, contains) { + return true + } + } + return false +} diff --git a/cmd/run-sql-file/main.go b/cmd/run-sql-file/main.go new file mode 100644 index 00000000..6c8cb094 --- /dev/null +++ b/cmd/run-sql-file/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gorm.io/gorm" +) + +type options struct { + FilePath string + Apply bool +} + +func main() { + var opts options + flag.StringVar(&opts.FilePath, "file", "", "Path to .sql file (required)") + flag.BoolVar(&opts.Apply, "apply", false, "Apply SQL to database. If false, run as dry-run") + flag.Parse() + + opts.FilePath = strings.TrimSpace(opts.FilePath) + if opts.FilePath == "" { + log.Fatal("--file is required") + } + + sqlContent, err := readSQLFile(opts.FilePath) + if err != nil { + log.Fatalf("failed reading sql file: %v", err) + } + + mode := "dry-run" + if opts.Apply { + mode = "apply" + } + fmt.Printf("Mode: %s\n", mode) + fmt.Printf("File: %s\n", opts.FilePath) + fmt.Printf("SQL bytes: %d\n", len(sqlContent)) + + if !opts.Apply { + fmt.Println("Dry-run only. Add --apply to execute the SQL file.") + return + } + + db := database.Connect(config.DBHost, config.DBName) + if err := executeSQL(db, sqlContent); err != nil { + log.Fatalf("failed executing sql file: %v", err) + } + + fmt.Println("DONE: SQL executed successfully") +} + +func readSQLFile(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + + sql := strings.TrimSpace(strings.TrimPrefix(string(raw), "\ufeff")) + if sql == "" { + return "", fmt.Errorf("sql file is empty") + } + + return sql, nil +} + +func executeSQL(db *gorm.DB, sql string) error { + return db.Transaction(func(tx *gorm.DB) error { + return tx.Exec(sql).Error + }) +} diff --git a/docs/farm_depreciation_manual_inputs_import.md b/docs/farm_depreciation_manual_inputs_import.md new file mode 100644 index 00000000..2198bfdd --- /dev/null +++ b/docs/farm_depreciation_manual_inputs_import.md @@ -0,0 +1,76 @@ +# Farm Depreciation Manual Inputs Import + +Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`). + +## Command + +```bash +go run ./cmd/import-farm-depreciation-manual-inputs --file [--sheet ] [--apply] +``` + +## Flags + +- `--file` (required): path file `.xlsx`. +- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama. +- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database. + +## Mode + +- Dry-run (default): + - parsing dan validasi semua baris. + - validasi `project_flock_id` terhadap farm aktif kategori `LAYING`. + - menampilkan `PLAN` + daftar error. + - tidak menulis data. + +- Apply (`--apply`): + - semua validasi tetap dijalankan dulu. + - jika ada 1 error, proses dihentikan. + - jika valid, upsert dijalankan dalam 1 transaksi (fail-fast). + - setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait. + +## Format Excel + +Template tersedia di: + +- `docs/templates/farm_depreciation_manual_inputs.xlsx` + +Header wajib ada di baris 1 (case-insensitive, trim-spaces): + +- `project_flock_id` (required, integer > 0) +- `total_cost` (required, numeric >= 0) +- `cutover_date` (required, format `YYYY-MM-DD`) +- `note` (optional, max 1000 karakter) + +Catatan: + +- Dalam 1 file tidak boleh ada duplikat `project_flock_id`. +- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`. + +## Contoh + +Dry-run: + +```bash +env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \ +go run ./cmd/import-farm-depreciation-manual-inputs \ + --file docs/templates/farm_depreciation_manual_inputs.xlsx +``` + +Apply: + +```bash +env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \ +go run ./cmd/import-farm-depreciation-manual-inputs \ + --file /path/to/farm_depreciation_manual_inputs.xlsx \ + --sheet manual_inputs \ + --apply +``` + +## Error Umum + +- `required header is missing`: header wajib tidak ditemukan. +- `must be a positive integer`: `project_flock_id` bukan integer valid. +- `must be greater than or equal to 0`: `total_cost` negatif. +- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format. +- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama. +- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini. diff --git a/docs/openapi/read-api.json b/docs/openapi/read-api.json index f2f91f7d..dab696fa 100644 --- a/docs/openapi/read-api.json +++ b/docs/openapi/read-api.json @@ -8559,6 +8559,218 @@ ] } }, + "/api/reports/expense/depreciation": { + "get": { + "description": "Read access to `/api/reports/expense/depreciation`.", + "parameters": [ + { + "description": "Page number.", + "example": 1, + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Page size.", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Daily period filter (YYYY-MM-DD).", + "example": "2026-01-01", + "in": "query", + "name": "period", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated project flock ids.", + "example": "1,2", + "in": "query", + "name": "project_flock_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated area ids.", + "example": "1,2", + "in": "query", + "name": "area_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated location ids.", + "example": "1,2", + "in": "query", + "name": "location_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedEnvelope" + } + } + }, + "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "GET api / reports / expense / depreciation", + "tags": [ + "Reports" + ] + } + }, + "/api/reports/expense/depreciation/manual-inputs": { + "get": { + "description": "Read access to `/api/reports/expense/depreciation/manual-inputs`.", + "parameters": [ + { + "description": "Page number.", + "example": 1, + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Page size.", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Comma separated project flock ids.", + "example": "1,2", + "in": "query", + "name": "project_flock_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated area ids.", + "example": "1,2", + "in": "query", + "name": "area_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated location ids.", + "example": "1,2", + "in": "query", + "name": "location_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedEnvelope" + } + } + }, + "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "GET api / reports / expense / depreciation / manual inputs", + "tags": [ + "Reports" + ] + } + }, "/api/reports/hpp-per-kandang": { "get": { "description": "Read access to `/api/reports/hpp-per-kandang`.", diff --git a/docs/openapi/read-api.yaml b/docs/openapi/read-api.yaml index 7e62562f..f12674a4 100644 --- a/docs/openapi/read-api.yaml +++ b/docs/openapi/read-api.yaml @@ -5318,6 +5318,141 @@ paths: summary: GET api / reports / expense tags: - Reports + /api/reports/expense/depreciation: + get: + description: Read access to `/api/reports/expense/depreciation`. + parameters: + - description: Page number. + example: 1 + in: query + name: page + required: false + schema: + type: integer + - description: Page size. + example: 10 + in: query + name: limit + required: false + schema: + type: integer + - description: Daily period filter (YYYY-MM-DD). + example: "2026-01-01" + in: query + name: period + required: true + schema: + type: string + - description: Comma separated project flock ids. + example: 1,2 + in: query + name: project_flock_id + required: false + schema: + type: string + - description: Comma separated area ids. + example: 1,2 + in: query + name: area_id + required: false + schema: + type: string + - description: Comma separated location ids. + example: 1,2 + in: query + name: location_id + required: false + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEnvelope' + description: Successful response + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Forbidden + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: GET api / reports / expense / depreciation + tags: + - Reports + /api/reports/expense/depreciation/manual-inputs: + get: + description: Read access to `/api/reports/expense/depreciation/manual-inputs`. + parameters: + - description: Page number. + example: 1 + in: query + name: page + required: false + schema: + type: integer + - description: Page size. + example: 10 + in: query + name: limit + required: false + schema: + type: integer + - description: Comma separated project flock ids. + example: 1,2 + in: query + name: project_flock_id + required: false + schema: + type: string + - description: Comma separated area ids. + example: 1,2 + in: query + name: area_id + required: false + schema: + type: string + - description: Comma separated location ids. + example: 1,2 + in: query + name: location_id + required: false + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEnvelope' + description: Successful response + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Forbidden + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: GET api / reports / expense / depreciation / manual inputs + tags: + - Reports /api/reports/hpp-per-kandang: get: description: Read access to `/api/reports/hpp-per-kandang`. diff --git a/docs/postman/read-api.collection.json b/docs/postman/read-api.collection.json index 0ccdba5c..aa262c80 100644 --- a/docs/postman/read-api.collection.json +++ b/docs/postman/read-api.collection.json @@ -1439,6 +1439,32 @@ "url": "{{base_url}}/api/reports/expense?page=1\u0026limit=10\u0026search=operasional\u0026category=BOP\u0026supplier_id=1\u0026kandang_id=1\u0026project_flock_kandang_id=1\u0026nonstock_id=1\u0026location_id=1\u0026area_id=1\u0026realization_date=2026-01-15" } }, + { + "name": "GET api / reports / expense / depreciation", + "request": { + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": "{{base_url}}/api/reports/expense/depreciation?page=1\u0026limit=10\u0026period=2026-01-01\u0026project_flock_id=1,2\u0026area_id=1,2\u0026location_id=1,2" + } + }, + { + "name": "GET api / reports / expense / depreciation / manual inputs", + "request": { + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": "{{base_url}}/api/reports/expense/depreciation/manual-inputs?page=1\u0026limit=10\u0026project_flock_id=1,2\u0026area_id=1,2\u0026location_id=1,2" + } + }, { "name": "GET api / reports / hpp per kandang", "request": { diff --git a/docs/templates/adjustment_stock_prices.xlsx b/docs/templates/adjustment_stock_prices.xlsx new file mode 100644 index 00000000..3e1ce3de Binary files /dev/null and b/docs/templates/adjustment_stock_prices.xlsx differ diff --git a/docs/templates/farm_depreciation_manual_inputs.xlsx b/docs/templates/farm_depreciation_manual_inputs.xlsx new file mode 100644 index 00000000..c26db926 Binary files /dev/null and b/docs/templates/farm_depreciation_manual_inputs.xlsx differ diff --git a/docs/templates/kandang_house_type.xlsx b/docs/templates/kandang_house_type.xlsx new file mode 100644 index 00000000..24c7b045 Binary files /dev/null and b/docs/templates/kandang_house_type.xlsx differ diff --git a/docs/templates/~$adjustment_stock_prices.xlsx b/docs/templates/~$adjustment_stock_prices.xlsx new file mode 100644 index 00000000..5a932052 Binary files /dev/null and b/docs/templates/~$adjustment_stock_prices.xlsx differ diff --git a/docs/templates/~$farm_depreciation_manual_inputs.xlsx b/docs/templates/~$farm_depreciation_manual_inputs.xlsx new file mode 100644 index 00000000..5a932052 Binary files /dev/null and b/docs/templates/~$farm_depreciation_manual_inputs.xlsx differ diff --git a/docs/templates/~$kandang_house_type.xlsx b/docs/templates/~$kandang_house_type.xlsx new file mode 100644 index 00000000..5a932052 Binary files /dev/null and b/docs/templates/~$kandang_house_type.xlsx differ diff --git a/internal/apikeys/defaults.go b/internal/apikeys/defaults.go index 29daeda5..33662187 100644 --- a/internal/apikeys/defaults.go +++ b/internal/apikeys/defaults.go @@ -82,6 +82,7 @@ func DefaultDashboardPermissions() []string { "lti.repport.debtsupplier.list", "lti.repport.delivery.list", "lti.repport.expense.list", + "lti.repport.expense.depreciation.manage", "lti.repport.gethppperkandang.list", "lti.repport.production_result.list", "lti.repport.purchasesupplier.list", diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index bc5037ec..d41387af 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -23,6 +24,7 @@ type HppCostRepository interface { GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) + GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) } type HppRepositoryImpl struct { @@ -48,12 +50,32 @@ func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, proje } func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableProjectChickin := fifo.UsableKeyProjectChickin.String() + var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). - Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). - Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Select(` + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableAdjustment, + ). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableProjectChickin, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeTraceChickin, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Scan(&total).Error if err != nil { @@ -85,7 +107,7 @@ func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockK Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock). Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs). - Where("f.name = ?", utils.FlagEkspedisi). + // Where("f.name = ?", utils.FlagEkspedisi). Scan(&total).Error if err != nil { return 0, err @@ -100,15 +122,35 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa date = &now } + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() + var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Select(` + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableAdjustment, + ). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). - Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("f.name = ?", utils.FlagPakan). @@ -132,15 +174,34 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan utils.FlagVitamin, utils.FlagKimia, } + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Select(` + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableAdjustment, + ). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). - Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). @@ -169,22 +230,28 @@ func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlock func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { stockablePurchase := fifo.StockableKeyPurchaseItems.String() stockableTransferIn := fifo.StockableKeyStockTransferIn.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() usableProjectChickin := fifo.UsableKeyProjectChickin.String() var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select(` - COALESCE(SUM(sa.qty * CASE - WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) - ELSE 0 - END), 0)`, - stockablePurchase, stockableTransferIn). + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableTransferIn, + stockableAdjustment, + ). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id"). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). Scan(&total).Error if err != nil { @@ -215,6 +282,33 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang return 0, 0, err } + var adjustmentTotalWeight float64 + adjustmentSubQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT ast.id AS adjustment_id, ast.price AS price"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyStockTransferOut.String(), + fifo.StockableKeyAdjustmentIn.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date) + + err = r.db.WithContext(ctx). + Table("(?) AS adjustment_sources", adjustmentSubQuery). + Select("COALESCE(SUM(adjustment_sources.price), 0)"). + Scan(&adjustmentTotalWeight).Error + if err != nil { + return 0, 0, err + } + + totals.TotalWeightKg += adjustmentTotalWeight + return totals.TotalPieces, totals.TotalWeightKg, nil } @@ -311,3 +405,25 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec return summary.ProjectFlockID, summary.TotalQty, nil } + +func (r *HppRepositoryImpl) GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) { + type row struct { + TotalCost float64 + } + + var selected row + err := r.db.WithContext(ctx). + Table("farm_depreciation_manual_inputs"). + Select("total_cost"). + Where("project_flock_id = ?", projectFlockId). + Limit(1). + Take(&selected).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + + return selected.TotalCost, nil +} diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go new file mode 100644 index 00000000..81b59829 --- /dev/null +++ b/internal/common/repository/common.hppv2.repository.go @@ -0,0 +1,1114 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type HppV2ProjectFlockKandangContext struct { + ProjectFlockKandangID uint + ProjectFlockID uint + ProjectFlockCategory string + KandangID uint + KandangName string + LocationID uint + HouseType string +} + +type HppV2UsageCostRow struct { + StockableType string + StockableID uint + SourceProductID uint + SourceProductName string + Qty float64 + UnitPrice float64 + TotalCost float64 + FirstUsedAt time.Time + LastUsedAt time.Time +} + +type HppV2AdjustmentCostRow struct { + AdjustmentID uint + ProjectFlockKandangID *uint + ProductWarehouseID uint + ProductID uint + ProductName string + WarehouseID uint + WarehouseType string + Qty float64 + Price float64 + GrandTotal float64 + CreatedAt time.Time +} + +type HppV2ExpenseCostRow struct { + ExpenseRealizationID uint + ExpenseNonstockID uint + ExpenseID uint + NonstockID uint + NonstockName string + Qty float64 + Price float64 + TotalCost float64 + RealizationDate time.Time +} + +type HppV2ChickinCostRow struct { + ProjectChickinID uint + ProjectFlockKandangID uint + ChickInDate time.Time + StockableType string + StockableID uint + SourceProductID uint + SourceProductName string + Qty float64 + UnitPrice float64 + TotalCost float64 +} + +type HppV2LatestTransferInputRow struct { + ProjectFlockKandangID uint + SourceProjectFlockID uint + TransferDate time.Time + TransferQty float64 + TransferID uint +} + +type HppV2ManualDepreciationInputRow struct { + ID uint + ProjectFlockID uint + TotalCost float64 + CutoverDate time.Time + Note *string +} + +type HppV2FarmDepreciationSnapshotRow struct { + ID uint + ProjectFlockID uint + PeriodDate time.Time + DepreciationPercentEffective float64 + DepreciationValue float64 + PulletCostDayNTotal float64 +} + +type HppV2CostRepository interface { + GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) + GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) + GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error) + GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error) + GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error) + GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) + GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) + ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error) + ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error) + ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) + ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) + ListChickinCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time, excludeTransferToLaying bool) ([]HppV2ChickinCostRow, error) + GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) + GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) + GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) + GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) + GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) +} + +type HppV2RepositoryImpl struct { + db *gorm.DB +} + +func NewHppV2CostRepository(db *gorm.DB) HppV2CostRepository { + return &HppV2RepositoryImpl{db: db} +} + +func (r *HppV2RepositoryImpl) GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) { + var row HppV2ProjectFlockKandangContext + err := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + pfk.id AS project_flock_kandang_id, + pf.id AS project_flock_id, + pf.category AS project_flock_category, + k.id AS kandang_id, + k.name AS kandang_name, + k.location_id AS location_id, + k.house_type::text AS house_type + `). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pfk.id = ?", projectFlockKandangId). + Scan(&row).Error + if err != nil { + return nil, err + } + if row.ProjectFlockKandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + + return &row, nil +} + +func (r *HppV2RepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) { + var ids []uint + err := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Select("id"). + Where("project_flock_id = ?", projectFlockId). + Scan(&ids).Error + if err != nil { + return nil, err + } + + return ids, nil +} + +func (r *HppV2RepositoryImpl) GetLatestTransferInputByProjectFlockKandangID( + ctx context.Context, + projectFlockKandangId uint, + period time.Time, +) (*HppV2LatestTransferInputRow, error) { + var row HppV2LatestTransferInputRow + query := ` +WITH latest_transfer_approval AS ( + SELECT a.approvable_id, a.action + FROM approvals a + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = @approval_type + GROUP BY approvable_id + ) la + ON la.approvable_id = a.approvable_id + AND la.latest_action_at = a.action_at + WHERE a.approvable_type = @approval_type +), +approved_transfers AS ( + SELECT + lt.id, + lt.from_project_flock_id, + COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) AS effective_date + FROM laying_transfers lt + JOIN latest_transfer_approval lta ON lta.approvable_id = lt.id + WHERE lt.deleted_at IS NULL + AND lt.executed_at IS NOT NULL + AND lta.action = 'APPROVED' +) +SELECT + ltt.target_project_flock_kandang_id AS project_flock_kandang_id, + at.from_project_flock_id AS source_project_flock_id, + at.effective_date AS transfer_date, + ltt.total_qty AS transfer_qty, + at.id AS transfer_id +FROM laying_transfer_targets ltt +JOIN approved_transfers at ON at.id = ltt.laying_transfer_id +WHERE ltt.deleted_at IS NULL + AND ltt.target_project_flock_kandang_id = @project_flock_kandang_id + AND at.effective_date <= DATE(@period_date) +ORDER BY at.effective_date DESC, at.id DESC +LIMIT 1 +` + + err := r.db.WithContext(ctx).Raw(query, map[string]any{ + "approval_type": utils.ApprovalWorkflowTransferToLaying.String(), + "project_flock_kandang_id": projectFlockKandangId, + "period_date": period, + }).Scan(&row).Error + if err != nil { + return nil, err + } + if row.TransferID == 0 { + return nil, nil + } + + return &row, nil +} + +func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID( + ctx context.Context, + projectFlockID uint, +) (*HppV2ManualDepreciationInputRow, error) { + var row HppV2ManualDepreciationInputRow + err := r.db.WithContext(ctx). + Table("farm_depreciation_manual_inputs"). + Select("id, project_flock_id, total_cost, cutover_date, note"). + Where("project_flock_id = ?", projectFlockID). + Limit(1). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + + return &row, nil +} + +func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod( + ctx context.Context, + projectFlockID uint, + periodDate time.Time, +) (*HppV2FarmDepreciationSnapshotRow, error) { + var row HppV2FarmDepreciationSnapshotRow + err := r.db.WithContext(ctx). + Table("farm_depreciation_snapshots"). + Select("id, project_flock_id, period_date, depreciation_percent_effective, depreciation_value, pullet_cost_day_n_total"). + Where("project_flock_id = ?", projectFlockID). + Where("period_date = DATE(?)", periodDate). + Limit(1). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + + return &row, nil +} + +func (r *HppV2RepositoryImpl) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) { + type row struct { + ChickInDate *time.Time + } + + var selected row + err := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select("MIN(pc.chick_in_date) AS chick_in_date"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Where("pc.deleted_at IS NULL"). + Where("pfk.project_flock_id = ?", projectFlockID). + Scan(&selected).Error + if err != nil { + return nil, err + } + if selected.ChickInDate == nil || selected.ChickInDate.IsZero() { + return nil, nil + } + + return selected.ChickInDate, nil +} + +func (r *HppV2RepositoryImpl) GetDepreciationPercents( + ctx context.Context, + houseTypes []string, + maxDay int, +) (map[string]map[int]float64, error) { + result := make(map[string]map[int]float64) + if len(houseTypes) == 0 || maxDay <= 0 { + return result, nil + } + + type row struct { + HouseType string + Day int + DepreciationPercent float64 + } + + rows := make([]row, 0) + err := r.db.WithContext(ctx). + Table("house_depreciation_standards"). + Select("house_type::text AS house_type, day, depreciation_percent"). + Where("house_type::text IN ?", houseTypes). + Where("day <= ?", maxDay). + Order("house_type ASC, day ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, item := range rows { + if _, exists := result[item.HouseType]; !exists { + result[item.HouseType] = make(map[int]float64) + } + result[item.HouseType][item.Day] = item.DepreciationPercent + } + + return result, nil +} + +func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, +) ([]HppV2UsageCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2UsageCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() + + rows := make([]HppV2UsageCostRow, 0) + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + sa.stockable_type AS stockable_type, + sa.stockable_id AS stockable_id, + COALESCE(pi.product_id, ast_pw.product_id, 0) AS source_product_id, + COALESCE(pi_prod.name, ast_prod.name, '') AS source_product_name, + COALESCE(SUM(sa.qty), 0) AS qty, + COALESCE(MAX(CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0) AS unit_price, + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0) AS total_cost, + MIN(r.record_datetime) AS first_used_at, + MAX(r.record_datetime) AS last_used_at + `, + stockablePurchase, + stockableAdjustment, + stockablePurchase, + stockableAdjustment, + ). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id"). + Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id"). + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). + Group(` + sa.stockable_type, + sa.stockable_id, + COALESCE(pi.product_id, ast_pw.product_id, 0), + COALESCE(pi_prod.name, ast_prod.name, '') + `). + Order("MIN(r.record_datetime) ASC, sa.stockable_type ASC, sa.stockable_id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, +) ([]HppV2AdjustmentCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2AdjustmentCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2AdjustmentCostRow, 0) + err := r.db.WithContext(ctx). + Table("adjustment_stocks AS ast"). + Select(` + ast.id AS adjustment_id, + pw.project_flock_kandang_id AS project_flock_kandang_id, + ast.product_warehouse_id AS product_warehouse_id, + pw.product_id AS product_id, + p.name AS product_name, + w.id AS warehouse_id, + w.type AS warehouse_type, + COALESCE(ast.total_qty, 0) AS qty, + COALESCE(ast.price, 0) AS price, + COALESCE(ast.grand_total, 0) AS grand_total, + ast.created_at AS created_at + `). + Joins("JOIN product_warehouses AS pw ON pw.id = ast.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN warehouses AS w ON w.id = pw.warehouse_id"). + Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + // Where("ast.created_at <= ?", *date). + Where("COALESCE(ast.total_qty, 0) > 0"). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). + Order("ast.created_at ASC, ast.id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListChickinCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, + excludeTransferToLaying bool, +) ([]HppV2ChickinCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2ChickinCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + stockableTransferIn := fifo.StockableKeyStockTransferIn.String() + stockableTransferToLaying := fifo.StockableKeyTransferToLayingIn.String() + usableProjectChickin := fifo.UsableKeyProjectChickin.String() + usableStockTransferOut := fifo.UsableKeyStockTransferOut.String() + unitPriceExpr := fmt.Sprintf(` + CASE + WHEN sa.stockable_type = '%s' THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = '%s' THEN COALESCE(ast.price, 0) + WHEN sa.stockable_type = '%s' THEN COALESCE(spi.price, sast.price, 0) + WHEN sa.stockable_type = '%s' THEN COALESCE(tpi.price, tast.price, 0) + ELSE 0 + END + `, stockablePurchase, stockableAdjustment, stockableTransferIn, stockableTransferToLaying) + + rows := make([]HppV2ChickinCostRow, 0) + query := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select(` + pc.id AS project_chickin_id, + pc.project_flock_kandang_id AS project_flock_kandang_id, + pc.chick_in_date AS chick_in_date, + sa.stockable_type AS stockable_type, + sa.stockable_id AS stockable_id, + COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ) AS source_product_id, + COALESCE( + pi_prod.name, + ast_prod.name, + tpi_prod.name, + tast_prod.name, + spi_prod.name, + sast_prod.name, + '' + ) AS source_product_name, + COALESCE(SUM(sa.qty), 0) AS qty, + `+unitPriceExpr+` AS unit_price, + COALESCE(SUM(sa.qty * (`+unitPriceExpr+`)), 0) AS total_cost + `). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", + usableProjectChickin, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeTraceChickin, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id"). + Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id"). + Joins( + "LEFT JOIN stock_allocations AS tsa_transfer ON tsa_transfer.usable_type = ? AND tsa_transfer.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa_transfer.status = ? AND tsa_transfer.allocation_purpose = ?", + stockableTransferToLaying, + stockableTransferToLaying, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa_transfer.stockable_id AND tsa_transfer.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS tpi_prod ON tpi_prod.id = tpi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS tast ON tast.id = tsa_transfer.stockable_id AND tsa_transfer.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS tast_pw ON tast_pw.id = tast.product_warehouse_id"). + Joins("LEFT JOIN products AS tast_prod ON tast_prod.id = tast_pw.product_id"). + Joins( + "LEFT JOIN stock_allocations AS tsa_stock ON tsa_stock.usable_type = ? AND tsa_stock.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa_stock.status = ? AND tsa_stock.allocation_purpose = ?", + usableStockTransferOut, + stockableTransferIn, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS spi ON spi.id = tsa_stock.stockable_id AND tsa_stock.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS spi_prod ON spi_prod.id = spi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS sast ON sast.id = tsa_stock.stockable_id AND tsa_stock.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS sast_pw ON sast_pw.id = sast.product_warehouse_id"). + Joins("LEFT JOIN products AS sast_prod ON sast_prod.id = sast_pw.product_id"). + Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("pc.chick_in_date <= ?", *date). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ) + AND f.name IN ? + ) + `, entity.FlagableTypeProduct, flagNames) + + if excludeTransferToLaying { + query = query.Where("sa.stockable_type <> ?", stockableTransferToLaying) + } + + err := query. + Group(fmt.Sprintf(` + pc.id, + pc.project_flock_kandang_id, + pc.chick_in_date, + sa.stockable_type, + sa.stockable_id, + COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ), + COALESCE( + pi_prod.name, + ast_prod.name, + tpi_prod.name, + tast_prod.name, + spi_prod.name, + sast_prod.name, + '' + ), + %s + `, unitPriceExpr)). + Order("pc.chick_in_date ASC, pc.id ASC, sa.stockable_type ASC, sa.stockable_id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListExpenseRealizationRowsByProjectFlockKandangIDs( + ctx context.Context, + projectFlockKandangIDs []uint, + date *time.Time, + ekspedisi bool, +) ([]HppV2ExpenseCostRow, error) { + if len(projectFlockKandangIDs) == 0 { + return []HppV2ExpenseCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2ExpenseCostRow, 0) + query := r.db.WithContext(ctx). + Table("expense_realizations AS er"). + Select(` + er.id AS expense_realization_id, + en.id AS expense_nonstock_id, + e.id AS expense_id, + COALESCE(n.id, 0) AS nonstock_id, + COALESCE(n.name, '') AS nonstock_name, + COALESCE(er.qty, 0) AS qty, + COALESCE(er.price, 0) AS price, + COALESCE(er.qty, 0) * COALESCE(er.price, 0) AS total_cost, + COALESCE(e.realization_date, DATE(er.created_at)) AS realization_date + `). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("e.deleted_at IS NULL"). + Where("e.category = ?", utils.ExpenseCategoryBOP). + Where("en.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("COALESCE(e.realization_date, DATE(er.created_at)) <= ?", *date) + + if ekspedisi { + query = query.Where("f.id IS NOT NULL") + } else { + query = query.Where("f.id IS NULL") + } + + if err := query. + Order("COALESCE(e.realization_date, DATE(er.created_at)) ASC, er.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListExpenseRealizationRowsByProjectFlockID( + ctx context.Context, + projectFlockID uint, + date *time.Time, + ekspedisi bool, +) ([]HppV2ExpenseCostRow, error) { + if projectFlockID == 0 { + return []HppV2ExpenseCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2ExpenseCostRow, 0) + query := r.db.WithContext(ctx). + Table("expense_realizations AS er"). + Select(` + er.id AS expense_realization_id, + en.id AS expense_nonstock_id, + e.id AS expense_id, + COALESCE(n.id, 0) AS nonstock_id, + COALESCE(n.name, '') AS nonstock_name, + COALESCE(er.qty, 0) AS qty, + COALESCE(er.price, 0) AS price, + COALESCE(er.qty, 0) * COALESCE(er.price, 0) AS total_cost, + COALESCE(e.realization_date, DATE(er.created_at)) AS realization_date + `). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("e.deleted_at IS NULL"). + Where("e.category = ?", utils.ExpenseCategoryBOP). + Where("en.project_flock_kandang_id IS NULL"). + Where("e.project_flock_id IS NOT NULL"). + Where("e.project_flock_id::jsonb @> ?::jsonb", fmt.Sprintf("[%d]", projectFlockID)). + Where("COALESCE(e.realization_date, DATE(er.created_at)) <= ?", *date) + + if ekspedisi { + query = query.Where("f.id IS NOT NULL") + } else { + query = query.Where("f.id IS NULL") + } + + if err := query. + Order("COALESCE(e.realization_date, DATE(er.created_at)) ASC, er.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() + + var total float64 + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableAdjustment, + ). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Where("f.name = ?", utils.FlagPakan). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppV2RepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select("COALESCE(SUM(pc.usage_qty), 0)"). + Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + var totals struct { + TotalPieces float64 + TotalWeightKg float64 + } + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Scan(&totals).Error + if err != nil { + return 0, 0, err + } + + var adjustmentTotals struct { + TotalQty float64 + TotalWeight float64 + } + adjustmentSubQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT ast.id AS adjustment_id, ast.total_qty AS total_qty, ast.price AS price"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyStockTransferOut.String(), + fifo.StockableKeyAdjustmentIn.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date) + + err = r.db.WithContext(ctx). + Table("(?) AS adjustment_sources", adjustmentSubQuery). + Select("COALESCE(SUM(adjustment_sources.total_qty), 0) AS total_qty, COALESCE(SUM(adjustment_sources.price), 0) AS total_weight"). + Scan(&adjustmentTotals).Error + if err != nil { + return 0, 0, err + } + + totals.TotalPieces += adjustmentTotals.TotalQty + totals.TotalWeightKg += adjustmentTotals.TotalWeight + + return totals.TotalPieces, totals.TotalWeightKg, nil +} + +func (r *HppV2RepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds( + ctx context.Context, + projectFlockKandangIDs []uint, + startDate *time.Time, + endDate *time.Time, +) (float64, float64, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, 0, nil + } + if endDate == nil { + now := time.Now() + endDate = &now + } + if startDate == nil { + startDate = endDate + } + + eggFlags := []string{ + string(utils.FlagTelur), + string(utils.FlagTelurUtuh), + string(utils.FlagTelurPecah), + string(utils.FlagTelurPutih), + string(utils.FlagTelurRetak), + string(utils.FlagTelurPapacal), + string(utils.FlagTelurJumbo), + } + + query := ` +WITH selected_pfk AS ( + SELECT pfk.id, k.location_id + FROM project_flock_kandangs pfk + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE pfk.id IN ? +), +selected_locations AS ( + SELECT DISTINCT location_id + FROM selected_pfk +), +sales_kandang AS ( + SELECT DISTINCT + mdp.id AS mdp_id, + COALESCE(mdp.usage_qty, 0) AS usage_qty, + COALESCE(mdp.total_weight, 0) AS total_weight + FROM marketing_delivery_products mdp + JOIN marketing_products mp ON mp.id = mdp.marketing_product_id + JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE mdp.delivery_date IS NOT NULL + AND mdp.delivery_date <= ? + AND UPPER(COALESCE(w.type, '')) = 'KANDANG' + AND pw.project_flock_kandang_id IN (SELECT id FROM selected_pfk) + AND EXISTS ( + SELECT 1 + FROM recording_eggs re + JOIN recordings rr ON rr.id = re.recording_id + WHERE re.product_warehouse_id = mp.product_warehouse_id + AND COALESCE(re.project_flock_kandang_id, rr.project_flock_kandangs_id) IN (SELECT id FROM selected_pfk) + AND rr.deleted_at IS NULL + AND DATE(rr.record_datetime) <= DATE(mdp.delivery_date) + ) + AND EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = pw.product_id + AND f.name IN ? + ) +), +sales_lokasi AS ( + SELECT DISTINCT + mdp.id AS mdp_id, + COALESCE(mdp.usage_qty, 0) AS usage_qty, + COALESCE(mdp.total_weight, 0) AS total_weight, + mdp.delivery_date AS delivery_date, + pw.id AS lokasi_pw_id, + pw.product_id AS product_id, + w.location_id AS location_id + FROM marketing_delivery_products mdp + JOIN marketing_products mp ON mp.id = mdp.marketing_product_id + JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE mdp.delivery_date IS NOT NULL + AND mdp.delivery_date <= ? + AND UPPER(COALESCE(w.type, '')) = 'LOKASI' + AND w.location_id IN (SELECT location_id FROM selected_locations) + AND EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = pw.product_id + AND f.name IN ? + ) +), +transfer_pairs AS ( + SELECT + std.source_product_warehouse_id AS source_pw_id, + std.dest_product_warehouse_id AS dest_pw_id, + MIN(st.transfer_date) AS first_transfer_date + FROM stock_transfer_details std + JOIN stock_transfers st ON st.id = std.stock_transfer_id + WHERE std.source_product_warehouse_id IS NOT NULL + AND std.dest_product_warehouse_id IS NOT NULL + GROUP BY std.source_product_warehouse_id, std.dest_product_warehouse_id +), +adj_pool AS ( + SELECT + sl.mdp_id, + SUM(CASE + WHEN spw.project_flock_kandang_id IN (SELECT id FROM selected_pfk) + THEN COALESCE(ast.usage_qty, 0) + ELSE 0 + END) AS sel_usage_qty, + SUM(COALESCE(ast.usage_qty, 0)) AS farm_usage_qty, + SUM(CASE + WHEN spw.project_flock_kandang_id IN (SELECT id FROM selected_pfk) + THEN COALESCE(ast.price, 0) + ELSE 0 + END) AS sel_price_sum, + SUM(COALESCE(ast.price, 0)) AS farm_price_sum + FROM sales_lokasi sl + JOIN transfer_pairs tf + ON tf.dest_pw_id = sl.lokasi_pw_id + AND DATE(tf.first_transfer_date) <= DATE(sl.delivery_date) + JOIN product_warehouses spw + ON spw.id = tf.source_pw_id + AND spw.product_id = sl.product_id + JOIN warehouses sw ON sw.id = spw.warehouse_id + JOIN adjustment_stocks ast ON ast.product_warehouse_id = tf.source_pw_id + WHERE UPPER(COALESCE(sw.type, '')) = 'KANDANG' + AND sw.location_id = sl.location_id + AND UPPER(COALESCE(ast.function_code, '')) = UPPER(?) + AND UPPER(COALESCE(ast.transaction_type, '')) = UPPER(?) + AND DATE(ast.created_at) <= DATE(sl.delivery_date) + GROUP BY sl.mdp_id +), +sales_lokasi_adj AS ( + SELECT sl.* + FROM sales_lokasi sl + JOIN adj_pool ap ON ap.mdp_id = sl.mdp_id + WHERE COALESCE(ap.farm_usage_qty, 0) > 0 + OR COALESCE(ap.farm_price_sum, 0) > 0 +), +sales_lokasi_rec AS ( + SELECT sl.* + FROM sales_lokasi sl + WHERE NOT EXISTS ( + SELECT 1 FROM sales_lokasi_adj sla WHERE sla.mdp_id = sl.mdp_id + ) +), +rec_pool AS ( + SELECT + sl.mdp_id, + SUM(CASE + WHEN COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) IN (SELECT id FROM selected_pfk) + THEN COALESCE(re.qty, 0) + ELSE 0 + END) AS sel_qty, + SUM(COALESCE(re.qty, 0)) AS farm_qty, + SUM(CASE + WHEN COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) IN (SELECT id FROM selected_pfk) + THEN COALESCE(re.weight, 0) + ELSE 0 + END) AS sel_weight, + SUM(COALESCE(re.weight, 0)) AS farm_weight + FROM sales_lokasi_rec sl + JOIN recordings r + ON r.deleted_at IS NULL + AND DATE(r.record_datetime) <= DATE(sl.delivery_date) + JOIN recording_eggs re + ON re.recording_id = r.id + AND re.product_warehouse_id = sl.lokasi_pw_id + JOIN project_flock_kandangs pfk + ON pfk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE k.location_id = sl.location_id + GROUP BY sl.mdp_id +), +kandang_totals AS ( + SELECT + COALESCE(SUM(sk.usage_qty), 0) AS total_pieces, + COALESCE(SUM(sk.total_weight), 0) AS total_weight + FROM sales_kandang sk +), +lokasi_adj_totals AS ( + SELECT + COALESCE(SUM( + sla.usage_qty * + CASE + WHEN COALESCE(ap.farm_usage_qty, 0) > 0 THEN (COALESCE(ap.sel_usage_qty, 0) * 1.0) / NULLIF(ap.farm_usage_qty, 0) + ELSE 0 + END + ), 0) AS total_pieces, + COALESCE(SUM( + sla.total_weight * + CASE + WHEN COALESCE(ap.farm_price_sum, 0) > 0 THEN (COALESCE(ap.sel_price_sum, 0) * 1.0) / NULLIF(ap.farm_price_sum, 0) + ELSE 0 + END + ), 0) AS total_weight + FROM sales_lokasi_adj sla + JOIN adj_pool ap ON ap.mdp_id = sla.mdp_id +), +lokasi_rec_totals AS ( + SELECT + COALESCE(SUM( + slr.usage_qty * + CASE + WHEN COALESCE(rp.farm_qty, 0) > 0 THEN (COALESCE(rp.sel_qty, 0) * 1.0) / NULLIF(rp.farm_qty, 0) + ELSE 0 + END + ), 0) AS total_pieces, + COALESCE(SUM( + slr.total_weight * + CASE + WHEN COALESCE(rp.farm_weight, 0) > 0 THEN (COALESCE(rp.sel_weight, 0) * 1.0) / NULLIF(rp.farm_weight, 0) + ELSE 0 + END + ), 0) AS total_weight + FROM sales_lokasi_rec slr + LEFT JOIN rec_pool rp ON rp.mdp_id = slr.mdp_id +) +SELECT + COALESCE(kt.total_pieces, 0) + COALESCE(lat.total_pieces, 0) + COALESCE(lrt.total_pieces, 0) AS total_pieces, + COALESCE(kt.total_weight, 0) + COALESCE(lat.total_weight, 0) + COALESCE(lrt.total_weight, 0) AS total_weight +FROM kandang_totals kt +CROSS JOIN lokasi_adj_totals lat +CROSS JOIN lokasi_rec_totals lrt +` + + var totals struct { + TotalPieces float64 + TotalWeight float64 + } + + err := r.db.WithContext(ctx). + Raw( + query, + projectFlockKandangIDs, + *endDate, + entity.FlagableTypeProduct, + eggFlags, + *endDate, + entity.FlagableTypeProduct, + eggFlags, + string(utils.AdjustmentTransactionSubtypeRecordingEggIn), + string(utils.AdjustmentTransactionTypeRecording), + ). + Scan(&totals).Error + + if err != nil { + return 0, 0, err + } + + return totals.TotalPieces, totals.TotalWeight, nil +} + +func (r *HppV2RepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) { + var summary struct { + ProjectFlockID uint + TotalQty float64 + } + err := r.db.WithContext(ctx). + Table("laying_transfer_targets AS ltt"). + Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty"). + Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id"). + Where("lt.deleted_at IS NULL"). + Where("ltt.deleted_at IS NULL"). + Where("lt.executed_at IS NOT NULL"). + Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId). + Group("lt.from_project_flock_id"). + Scan(&summary).Error + if err != nil { + return 0, 0, err + } + + return summary.ProjectFlockID, summary.TotalQty, nil +} diff --git a/internal/common/repository/common.hppv2.repository_test.go b/internal/common/repository/common.hppv2.repository_test.go new file mode 100644 index 00000000..7aefad44 --- /dev/null +++ b/internal/common/repository/common.hppv2.repository_test.go @@ -0,0 +1,248 @@ +package repository + +import ( + "context" + "math" + "testing" + "time" + + "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +func TestHppV2RepositoryGetEggProduksiIncludesTransferredAdjustmentStock(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime) VALUES (1, 101, '2026-04-19 10:00:00')`, + `INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 401, 80, 8, 101)`, + `INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00')`, + `INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 301, 401)`, + `INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose) VALUES (1, 'STOCK_TRANSFER_OUT', 1, 'ADJUSTMENT_IN', 501, 'ACTIVE', 'CONSUME')`, + `INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, price, created_at) VALUES (501, 301, 20, 2.5, '2026-04-18 07:30:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 100) + assertFloatEquals(t, totalWeightKg, 10.5) +} + +func TestHppV2RepositoryGetEggTerjualUsesEndDateForSameDayFarmSales(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`, + `INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`, + `INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10)`, + `INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL)`, + `INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`, + `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES (1, 101, '2026-04-19 08:00:00', NULL), (2, 102, '2026-04-19 09:00:00', NULL)`, + `INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 301, 60, 6, 101), (2, 2, 301, 40, 4, 102)`, + `INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`, + `INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 50, 5, '2026-04-19 12:00:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + startDate := mustJakartaTime(t, "2026-04-19 00:00:00") + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 30) + assertFloatEquals(t, totalWeightKg, 3) +} + +func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`, + `INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`, + `INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10), (211, 'KANDANG', 10), (212, 'KANDANG', 10)`, + `INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL), (311, 211, 900, 101), (312, 212, 900, 102)`, + `INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`, + `INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00'), (2, '2026-04-18 08:15:00')`, + `INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 311, 301), (2, 2, 312, 301)`, + `INSERT INTO adjustment_stocks (id, product_warehouse_id, usage_qty, price, function_code, transaction_type, created_at) VALUES + (801, 311, 70, 7, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:00:00'), + (802, 312, 30, 3, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:30:00')`, + `INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`, + `INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 20, 2, '2026-04-19 12:00:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + startDate := mustJakartaTime(t, "2026-04-19 00:00:00") + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 14) + assertFloatEquals(t, totalWeightKg, 1.4) +} + +func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + mustExecHppV2(t, db, + `CREATE TABLE recordings ( + id INTEGER PRIMARY KEY, + project_flock_kandangs_id INTEGER NULL, + record_datetime DATETIME NULL, + deleted_at DATETIME NULL + )`, + `CREATE TABLE recording_eggs ( + id INTEGER PRIMARY KEY, + recording_id INTEGER NULL, + product_warehouse_id INTEGER NULL, + qty NUMERIC(15,3) NULL, + weight NUMERIC(15,3) NULL, + project_flock_kandang_id INTEGER NULL + )`, + `CREATE TABLE stock_transfers ( + id INTEGER PRIMARY KEY, + transfer_date DATETIME NULL + )`, + `CREATE TABLE stock_transfer_details ( + id INTEGER PRIMARY KEY, + stock_transfer_id INTEGER NULL, + source_product_warehouse_id INTEGER NULL, + dest_product_warehouse_id INTEGER NULL + )`, + `CREATE TABLE stock_allocations ( + id INTEGER PRIMARY KEY, + usable_type TEXT NULL, + usable_id INTEGER NULL, + stockable_type TEXT NULL, + stockable_id INTEGER NULL, + status TEXT NULL, + allocation_purpose TEXT NULL, + qty NUMERIC(15,3) NULL + )`, + `CREATE TABLE adjustment_stocks ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER NULL, + total_qty NUMERIC(15,3) NULL, + usage_qty NUMERIC(15,3) NULL, + price NUMERIC(15,3) NULL, + grand_total NUMERIC(15,3) NULL, + function_code TEXT NULL, + transaction_type TEXT NULL, + created_at DATETIME NULL + )`, + `CREATE TABLE kandangs ( + id INTEGER PRIMARY KEY, + location_id INTEGER NULL + )`, + `CREATE TABLE project_flock_kandangs ( + id INTEGER PRIMARY KEY, + kandang_id INTEGER NULL, + project_flock_id INTEGER NULL + )`, + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY, + type TEXT NULL, + location_id INTEGER NULL + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY, + warehouse_id INTEGER NULL, + product_id INTEGER NULL, + project_flock_kandang_id INTEGER NULL + )`, + `CREATE TABLE marketing_products ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER NULL + )`, + `CREATE TABLE marketing_delivery_products ( + id INTEGER PRIMARY KEY, + marketing_product_id INTEGER NULL, + usage_qty NUMERIC(15,3) NULL, + total_weight NUMERIC(15,3) NULL, + delivery_date DATETIME NULL + )`, + `CREATE TABLE flags ( + id INTEGER PRIMARY KEY, + flagable_type TEXT NULL, + flagable_id INTEGER NULL, + name TEXT NULL + )`, + ) + + return db +} + +func mustExecHppV2(t *testing.T, db *gorm.DB, statements ...string) { + t.Helper() + + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("failed executing statement %q: %v", statement, err) + } + } +} + +func mustJakartaTime(t *testing.T, raw string) time.Time { + t.Helper() + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading timezone: %v", err) + } + + value, err := time.ParseInLocation("2006-01-02 15:04:05", raw, location) + if err != nil { + t.Fatalf("failed parsing time %q: %v", raw, err) + } + + return value +} + +func assertFloatEquals(t *testing.T, got float64, want float64) { + t.Helper() + + if math.Abs(got-want) > 0.000001 { + t.Fatalf("expected %.6f, got %.6f", want, got) + } +} + +func TestHppV2RepositoryConstantsStayAlignedWithProductionQueries(t *testing.T) { + if fifo.UsableKeyStockTransferOut.String() != "STOCK_TRANSFER_OUT" { + t.Fatalf("unexpected stock transfer usable key: %s", fifo.UsableKeyStockTransferOut.String()) + } + if fifo.StockableKeyAdjustmentIn.String() != "ADJUSTMENT_IN" { + t.Fatalf("unexpected adjustment stockable key: %s", fifo.StockableKeyAdjustmentIn.String()) + } + if entity.StockAllocationStatusActive != "ACTIVE" { + t.Fatalf("unexpected active stock allocation status: %s", entity.StockAllocationStatusActive) + } + if entity.StockAllocationPurposeConsume != "CONSUME" { + t.Fatalf("unexpected consume stock allocation purpose: %s", entity.StockAllocationPurposeConsume) + } + if string(utils.AdjustmentTransactionSubtypeRecordingEggIn) != "RECORDING_EGG_IN" { + t.Fatalf("unexpected adjustment function code: %s", utils.AdjustmentTransactionSubtypeRecordingEggIn) + } + if string(utils.AdjustmentTransactionTypeRecording) != "RECORDING" { + t.Fatalf("unexpected adjustment transaction type: %s", utils.AdjustmentTransactionTypeRecording) + } +} diff --git a/internal/common/service/common.depreciation.service.go b/internal/common/service/common.depreciation.service.go new file mode 100644 index 00000000..6f12e077 --- /dev/null +++ b/internal/common/service/common.depreciation.service.go @@ -0,0 +1,104 @@ +package service + +import ( + "strings" + "time" +) + +const ( + depreciationStartAgeDayCloseHouse = 155 + depreciationStartAgeDayOpenHouse = 176 +) + +func NormalizeDepreciationHouseType(raw string) string { + return strings.TrimSpace(strings.ToLower(raw)) +} + +func DepreciationStartAgeDay(houseType string) int { + switch NormalizeDepreciationHouseType(houseType) { + case "close_house": + return depreciationStartAgeDayCloseHouse + case "open_house": + return depreciationStartAgeDayOpenHouse + default: + return 0 + } +} + +func FlockAgeDay(originDate time.Time, periodDate time.Time) int { + origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location()) + period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location()) + if period.Before(origin) { + return 0 + } + return int(period.Sub(origin).Hours()/24) + 1 +} + +func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int { + ageDay := FlockAgeDay(originDate, periodDate) + startAgeDay := DepreciationStartAgeDay(houseType) + if ageDay <= 0 || startAgeDay <= 0 || ageDay < startAgeDay { + return 0 + } + return ageDay - startAgeDay + 1 +} + +func CalculateDepreciationAtDayN( + initialPulletCost float64, + dayN int, + houseType string, + percentByHouseType map[string]map[int]float64, +) (float64, float64, float64) { + return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType) +} + +func CalculateDepreciationFromDayRange( + initialPulletCost float64, + startDay int, + endDay int, + houseType string, + percentByHouseType map[string]map[int]float64, +) (float64, float64, float64) { + if initialPulletCost <= 0 || endDay <= 0 { + return 0, 0, 0 + } + if startDay <= 0 { + startDay = 1 + } + if endDay < startDay { + return 0, 0, 0 + } + + normalizedHouseType := NormalizeDepreciationHouseType(houseType) + housePercent, exists := percentByHouseType[normalizedHouseType] + if !exists { + return 0, 0, 0 + } + + current := initialPulletCost + pulletCostDayN := 0.0 + depreciationValue := 0.0 + depreciationPercent := 0.0 + for day := startDay; day <= endDay; day++ { + pct := housePercent[day] + dep := current * (pct / 100) + if day == endDay { + pulletCostDayN = current + depreciationValue = dep + depreciationPercent = pct + } + current -= dep + if current < 0 { + current = 0 + } + } + + return pulletCostDayN, depreciationValue, depreciationPercent +} + +func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 { + if totalPulletCostDayN <= 0 { + return 0 + } + return (totalDepreciationValue / totalPulletCostDayN) * 100 +} diff --git a/internal/common/service/common.depreciation.service_test.go b/internal/common/service/common.depreciation.service_test.go new file mode 100644 index 00000000..6897f926 --- /dev/null +++ b/internal/common/service/common.depreciation.service_test.go @@ -0,0 +1,81 @@ +package service + +import ( + "testing" + "time" +) + +func TestDepreciationScheduleDay_UsesHouseTypeOffsets(t *testing.T) { + openOrigin := mustDepreciationDate(t, "2026-01-01") + if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-24"), "open_house"); got != 0 { + t.Fatalf("expected open house day before start to be 0, got %d", got) + } + if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-25"), "open_house"); got != 1 { + t.Fatalf("expected open house start day to map to schedule day 1, got %d", got) + } + + closeOrigin := mustDepreciationDate(t, "2026-01-01") + if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-03"), "close_house"); got != 0 { + t.Fatalf("expected close house day before start to be 0, got %d", got) + } + if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-04"), "close_house"); got != 1 { + t.Fatalf("expected close house start day to map to schedule day 1, got %d", got) + } +} + +func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) { + percentByHouseType := map[string]map[int]float64{ + "close_house": { + 1: 10, + 2: 20, + }, + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(1000, 2, "close_house", percentByHouseType) + if pulletCostDayN != 900 { + t.Fatalf("expected remaining basis entering day 2 to be 900, got %v", pulletCostDayN) + } + if depreciationValue != 180 { + t.Fatalf("expected day 2 depreciation to be 180, got %v", depreciationValue) + } + if depreciationPercent != 20 { + t.Fatalf("expected day 2 depreciation percent to be 20, got %v", depreciationPercent) + } +} + +func TestCalculateDepreciationFromDayRange_StartsFromProvidedScheduleDay(t *testing.T) { + percentByHouseType := map[string]map[int]float64{ + "close_house": { + 1: 10, + 2: 20, + 3: 5, + }, + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(1000, 2, 3, "close_house", percentByHouseType) + if pulletCostDayN != 800 { + t.Fatalf("expected remaining basis entering day 3 to be 800, got %v", pulletCostDayN) + } + if depreciationValue != 40 { + t.Fatalf("expected day 3 depreciation to be 40, got %v", depreciationValue) + } + if depreciationPercent != 5 { + t.Fatalf("expected day 3 depreciation percent to be 5, got %v", depreciationPercent) + } +} + +func mustDepreciationDate(t *testing.T, raw string) time.Time { + t.Helper() + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading timezone: %v", err) + } + + value, err := time.ParseInLocation("2006-01-02", raw, location) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + + return value +} diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go index b1f1a1b1..db83d5a6 100644 --- a/internal/common/service/common.hpp.service.go +++ b/internal/common/service/common.hpp.service.go @@ -46,6 +46,7 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim location, err := time.LoadLocation("Asia/Jakarta") if err != nil { + return nil, err } @@ -54,16 +55,21 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay) if err != nil { + return nil, err } totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer) if err != nil { + return nil, err } + result, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) + if err != nil { - return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) - + return nil, err + } + return result, nil } func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) { @@ -73,40 +79,48 @@ func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, d } if s.hppRepo == nil { + return 0, nil } kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { + return 0, err } docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs) if err != nil { + return 0, err } budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID) if err != nil { + return 0, err } expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs) if err != nil { + return 0, err } feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date) if err != nil { + return 0, err } ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date) if err != nil { + return 0, err } - return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil + total := docCost + budgetCost + expedisionCost + feedCost + ovkCost + return total, nil } func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) { @@ -117,30 +131,40 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId) if err != nil { + return 0, err } costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + return 0, err } costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + return 0, err } costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId}) if err != nil { + return 0, err } costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate) if err != nil { + return 0, err } - return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil + // fmt.Println(costBudget, costExpedision, costOvk, costFeed, costPullet, depresiasiTransfer) + + // depresiasiTransfer = 0 + + total := depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget + return total, nil } func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) { @@ -150,48 +174,57 @@ func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate // } if s.hppRepo == nil { + return 0, nil } projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) if err != nil { + return 0, err } projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId) if err != nil { + return 0, err } eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate) if err != nil { + return 0, err } eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + return 0, err } totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId) if err != nil { + return 0, err } if eggProduksiPiecesFlock == 0 { + return 0, nil } - return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil + result := (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock + return result, nil } func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - // if endDate == nil { - // now := time.Now() - // endDate = &now - // } + if endDate == nil { + now := time.Now() + endDate = &now + } if s.hppRepo == nil { + return 0, nil } @@ -199,6 +232,13 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate * if err != nil { return 0, err } + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + result, fallbackErr := s.getManualDepresiasiTransferFallback(projectFlockKandangId) + if fallbackErr != nil { + return 0, fallbackErr + } + return result, nil + } kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { @@ -218,22 +258,81 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate * return 0, err } - return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil + result := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing + return result, nil +} + +func (s *hppService) getManualDepresiasiTransferFallback(projectFlockKandangId uint) (float64, error) { + projectFlockID, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) + if err != nil { + + return 0, err + } + if projectFlockID == 0 { + + return 0, nil + } + + manualCost, err := s.hppRepo.GetManualDepreciationCostByProjectFlockID(context.Background(), projectFlockID) + if err != nil { + + return 0, err + } + if manualCost <= 0 { + + return 0, nil + } + + kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockID) + if err != nil { + + return 0, err + } + if len(kandangIDs) == 0 { + + return 0, nil + } + + totalUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDs) + if err != nil { + + return 0, err + } + if totalUsageQty <= 0 { + + return 0, nil + } + + kandangUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId}) + if err != nil { + + return 0, err + } + if kandangUsageQty <= 0 { + + return 0, nil + } + + result := manualCost * (kandangUsageQty / totalUsageQty) + return result, nil } func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { if s.hppRepo == nil { + return &HppCostResponse{}, nil } estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + return nil, err } realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) if err != nil { + return nil, err } @@ -261,12 +360,21 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) } - return &HppCostResponse{ + result := &HppCostResponse{ Estimation: estimation, Real: real, - }, nil + } + return result, nil } func roundToTwoDecimals(value float64) float64 { - return math.Round(value*100) / 100 + result := math.Round(value*100) / 100 + return result +} + +func formatTimePtr(value *time.Time) string { + if value == nil { + return "" + } + return value.Format(time.RFC3339) } diff --git a/internal/common/service/common.hppv2.model.go b/internal/common/service/common.hppv2.model.go new file mode 100644 index 00000000..f6f94bf9 --- /dev/null +++ b/internal/common/service/common.hppv2.model.go @@ -0,0 +1,61 @@ +package service + +type HppV2DateWindow struct { + Start string `json:"start"` + End string `json:"end"` +} + +type HppV2Proration struct { + Basis string `json:"basis"` + Numerator float64 `json:"numerator"` + Denominator float64 `json:"denominator"` + Ratio float64 `json:"ratio"` +} + +type HppV2Reference struct { + Type string `json:"type"` + ID uint `json:"id"` + StockableType string `json:"stockable_type,omitempty"` + ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"` + ProductID uint `json:"product_id,omitempty"` + ProductName string `json:"product_name,omitempty"` + Date string `json:"date,omitempty"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + Total float64 `json:"total"` + AppliedTotal float64 `json:"applied_total"` +} + +type HppV2ComponentPart struct { + Code string `json:"code"` + Title string `json:"title"` + Scopes []string `json:"scopes,omitempty"` + Total float64 `json:"total"` + Proration *HppV2Proration `json:"proration,omitempty"` + Details map[string]any `json:"details,omitempty"` + References []HppV2Reference `json:"references,omitempty"` +} + +type HppV2Component struct { + Code string `json:"code"` + Title string `json:"title"` + Scopes []string `json:"scopes,omitempty"` + Total float64 `json:"total"` + Parts []HppV2ComponentPart `json:"parts"` +} + +type HppV2Breakdown struct { + ProjectFlockKandangID uint `json:"project_flock_kandang_id"` + ProjectFlockID uint `json:"project_flock_id"` + ProjectFlockCategory string `json:"project_flock_category,omitempty"` + HouseType string `json:"house_type,omitempty"` + KandangID uint `json:"kandang_id,omitempty"` + KandangName string `json:"kandang_name,omitempty"` + LocationID uint `json:"location_id,omitempty"` + PeriodDate string `json:"period_date"` + Window HppV2DateWindow `json:"window"` + TotalPulletCost float64 `json:"total_pullet_cost"` + TotalProductionCost float64 `json:"total_production_cost"` + Components []HppV2Component `json:"components"` + Hpp HppCostResponse `json:"hpp"` +} diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go new file mode 100644 index 00000000..c392cf8b --- /dev/null +++ b/internal/common/service/common.hppv2.service.go @@ -0,0 +1,1620 @@ +package service + +import ( + "context" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +const ( + hppV2ComponentPakan = "PAKAN" + hppV2ComponentOvk = "OVK" + hppV2ComponentDocChickin = "DOC_CHICKIN" + hppV2ComponentDirectPulletPurchase = "DIRECT_PULLET_PURCHASE" + hppV2ComponentBopRegular = "BOP_REGULAR" + hppV2ComponentBopEksp = "BOP_EKSPEDISI" + hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST" + hppV2ComponentDepreciation = "DEPRECIATION" + hppV2PartGrowingNormal = "growing_normal" + hppV2PartGrowingCutover = "growing_cutover" + hppV2PartLayingNormal = "laying_normal" + hppV2PartLayingCutover = "laying_cutover" + hppV2PartGrowingDirect = "growing_direct" + hppV2PartGrowingFarm = "growing_farm" + hppV2PartLayingDirect = "laying_direct" + hppV2PartLayingFarm = "laying_farm" + hppV2PartManualCutover = "manual_cutover" + hppV2PartDepreciationNormal = "normal_transfer" + hppV2PartDepreciationCutover = "manual_cutover" + hppV2PartDepreciationFarmSnapshot = "farm_snapshot" + hppV2ProrationPopulation = "growing_population_share" + hppV2ProrationEggWeight = "laying_egg_weight_share" + hppV2ProrationEggPiece = "laying_egg_piece_share" + hppV2ScopePulletCost = "pullet_cost" + hppV2ScopeProductionCost = "production_cost" + hppV2CutoverFlagPakan = "PAKAN-CUTOVER" + hppV2CutoverFlagOvk = "OVK" +) + +type HppV2Service interface { + CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) + CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error) + GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostDocChickin(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostDirectPulletPurchase(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetDocChickinBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) +} + +type hppV2Service struct { + hppRepo commonRepo.HppV2CostRepository +} + +type hppV2StockComponentConfig struct { + Code string + Title string + NormalFlags []string + CutoverFlags []string +} + +type hppV2ExpenseComponentConfig struct { + Code string + Title string + Ekspedisi bool +} + +func NewHppV2Service(hppRepo commonRepo.HppV2CostRepository) HppV2Service { + return &hppV2Service{hppRepo: hppRepo} +} + +func (s *hppV2Service) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { + breakdown, err := s.CalculateHppBreakdown(projectFlockKandangId, date) + if err != nil { + return nil, err + } + if breakdown == nil { + return &HppCostResponse{}, nil + } + + result := breakdown.Hpp + return &result, nil +} + +func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error) { + if s.hppRepo == nil { + return &HppV2Breakdown{ + ProjectFlockKandangID: projectFlockKandangId, + Hpp: HppCostResponse{}, + }, nil + } + + startOfDay, endOfDay, err := hppV2DayWindow(date) + if err != nil { + return nil, err + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + pakanComponent, err := s.GetPakanBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + + totalPulletCost := 0.0 + totalProductionCost := 0.0 + components := make([]HppV2Component, 0, 8) + appendComponent := func(requestedCode string, component *HppV2Component) { + pulletBefore := totalPulletCost + productionBefore := totalProductionCost + + if component == nil || (component.Total == 0 && len(component.Parts) == 0) { + utils.Log.Infof( + "HPP v2 component skipped: project_flock_kandang_id=%d period_date=%s component=%s reason=empty_or_nil total_pullet_cost=%.2f total_production_cost=%.2f", + projectFlockKandangId, + startOfDay.Format("2006-01-02"), + requestedCode, + totalPulletCost, + totalProductionCost, + ) + return + } + + pulletAdded := componentScopeTotal(component, hppV2ScopePulletCost) + productionAdded := componentScopeTotal(component, hppV2ScopeProductionCost) + components = append(components, *component) + totalPulletCost += pulletAdded + totalProductionCost += productionAdded + utils.Log.Infof( + "HPP v2 component applied: project_flock_kandang_id=%d period_date=%s component=%s component_total=%.2f pullet_added=%.2f production_added=%.2f total_pullet_before=%.2f total_pullet_after=%.2f total_production_before=%.2f total_production_after=%.2f parts_count=%d", + projectFlockKandangId, + startOfDay.Format("2006-01-02"), + component.Code, + component.Total, + pulletAdded, + productionAdded, + pulletBefore, + totalPulletCost, + productionBefore, + totalProductionCost, + len(component.Parts), + ) + } + appendComponent(hppV2ComponentPakan, pakanComponent) + + ovkComponent, err := s.GetOvkBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + appendComponent(hppV2ComponentOvk, ovkComponent) + + docComponent, err := s.GetDocChickinBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + appendComponent(hppV2ComponentDocChickin, docComponent) + + directPulletComponent, err := s.GetDirectPulletPurchaseBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + appendComponent(hppV2ComponentDirectPulletPurchase, directPulletComponent) + + bopRegularComponent, err := s.GetBopRegularBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + appendComponent(hppV2ComponentBopRegular, bopRegularComponent) + + bopEkspedisiComponent, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + appendComponent(hppV2ComponentBopEksp, bopEkspedisiComponent) + + manualPulletComponent, err := s.getManualPulletCostComponent(projectFlockKandangId, contextRow, startOfDay) + if err != nil { + return nil, err + } + appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent) + + depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost) + if err != nil { + return nil, err + } + + depreciationCostToProduction := componentScopeTotal(depreciationComponent, hppV2ScopeProductionCost) + depreciationSource := "" + if depreciationComponent != nil && len(depreciationComponent.Parts) > 0 { + depreciationSource = depreciationComponent.Parts[0].Code + } + productionCostBeforeDepreciation := totalProductionCost + appendComponent(hppV2ComponentDepreciation, depreciationComponent) + utils.Log.Infof( + "HPP v2 depreciation cost applied: project_flock_kandang_id=%d period_date=%s depreciation_source=%s depreciation_cost=%.2f production_cost_before=%.2f production_cost_after=%.2f", + projectFlockKandangId, + startOfDay.Format("2006-01-02"), + depreciationSource, + depreciationCostToProduction, + productionCostBeforeDepreciation, + totalProductionCost, + ) + + hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) + if err != nil { + return nil, err + } + if hppCost == nil { + hppCost = &HppCostResponse{} + } + + return &HppV2Breakdown{ + ProjectFlockKandangID: projectFlockKandangId, + ProjectFlockID: contextRow.ProjectFlockID, + ProjectFlockCategory: contextRow.ProjectFlockCategory, + HouseType: contextRow.HouseType, + KandangID: contextRow.KandangID, + KandangName: contextRow.KandangName, + LocationID: contextRow.LocationID, + PeriodDate: startOfDay.Format("2006-01-02"), + Window: HppV2DateWindow{ + Start: startOfDay.Format(time.RFC3339), + End: endOfDay.Format(time.RFC3339), + }, + TotalPulletCost: totalPulletCost, + TotalProductionCost: totalProductionCost, + Components: components, + Hpp: *hppCost, + }, nil +} + +func (s *hppV2Service) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetPakanBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getStockUsageComponent(projectFlockKandangId, endDate, hppV2StockComponentConfig{ + Code: hppV2ComponentPakan, + Title: "Pakan", + NormalFlags: []string{string(utils.FlagPakan)}, + CutoverFlags: []string{hppV2CutoverFlagPakan}, + }) +} + +func (s *hppV2Service) GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetOvkBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getStockUsageComponent(projectFlockKandangId, endDate, hppV2StockComponentConfig{ + Code: hppV2ComponentOvk, + Title: "OVK", + NormalFlags: []string{ + string(utils.FlagOVK), + string(utils.FlagObat), + string(utils.FlagVitamin), + string(utils.FlagKimia), + }, + CutoverFlags: []string{hppV2CutoverFlagOvk}, + }) +} + +func (s *hppV2Service) GetCostDocChickin(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetDocChickinBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetCostDirectPulletPurchase(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetDirectPulletPurchaseBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetDocChickinBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + if s.hppRepo == nil { + return &HppV2Component{ + Code: hppV2ComponentDocChickin, + Title: "DOC Chick-in", + Scopes: []string{hppV2ScopePulletCost}, + Parts: []HppV2ComponentPart{}, + }, nil + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + part, err := s.buildGrowingChickinPart(projectFlockKandangId, contextRow, endDate, []string{string(utils.FlagDOC)}, false, hppV2PartGrowingDirect, "Growing DOC") + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 1) + total := 0.0 + if part != nil { + parts = append(parts, *part) + total += part.Total + } + + return &HppV2Component{ + Code: hppV2ComponentDocChickin, + Title: "DOC Chick-in", + Scopes: []string{hppV2ScopePulletCost}, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + part, err := s.buildLayingChickinPart(projectFlockKandangId, endDate, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true, hppV2PartLayingDirect, "Laying Direct Pullet") + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 1) + total := 0.0 + if part != nil { + parts = append(parts, *part) + total += part.Total + } + + return &HppV2Component{ + Code: hppV2ComponentDirectPulletPurchase, + Title: "Direct Pullet Purchase", + Scopes: []string{hppV2ScopeProductionCost}, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetBopRegularBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getExpenseComponent(projectFlockKandangId, endDate, hppV2ExpenseComponentConfig{ + Code: hppV2ComponentBopRegular, + Title: "BOP Regular", + Ekspedisi: false, + }) +} + +func (s *hppV2Service) GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getExpenseComponent(projectFlockKandangId, endDate, hppV2ExpenseComponentConfig{ + Code: hppV2ComponentBopEksp, + Title: "BOP Ekspedisi", + Ekspedisi: true, + }) +} + +func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2StockComponentConfig) (*HppV2Component, error) { + if s.hppRepo == nil { + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Parts: []HppV2ComponentPart{}, + }, nil + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 4) + total := 0.0 + + growingPart, err := s.buildGrowingUsagePart(projectFlockKandangId, contextRow, endDate, config, false) + if err != nil { + return nil, err + } + if growingPart != nil { + parts = append(parts, *growingPart) + total += growingPart.Total + } + + growingCutoverPart, err := s.buildGrowingUsagePart(projectFlockKandangId, contextRow, endDate, config, true) + if err != nil { + return nil, err + } + if growingCutoverPart != nil { + parts = append(parts, *growingCutoverPart) + total += growingCutoverPart.Total + } + + layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, false) + if err != nil { + return nil, err + } + if layingNormalPart != nil { + parts = append(parts, *layingNormalPart) + total += layingNormalPart.Total + } + + layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, true) + if err != nil { + return nil, err + } + if layingCutoverPart != nil { + parts = append(parts, *layingCutoverPart) + total += layingCutoverPart.Total + } + + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) getExpenseComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2ExpenseComponentConfig) (*HppV2Component, error) { + if s.hppRepo == nil { + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Parts: []HppV2ComponentPart{}, + }, nil + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 4) + total := 0.0 + + growingDirect, err := s.buildGrowingExpenseDirectPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if growingDirect != nil { + parts = append(parts, *growingDirect) + total += growingDirect.Total + } + + growingFarm, err := s.buildGrowingExpenseFarmPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if growingFarm != nil { + parts = append(parts, *growingFarm) + total += growingFarm.Total + } + + layingDirect, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, endDate, config) + if err != nil { + return nil, err + } + if layingDirect != nil { + parts = append(parts, *layingDirect) + total += layingDirect.Total + } + + layingFarm, err := s.buildLayingExpenseFarmPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if layingFarm != nil { + parts = append(parts, *layingFarm) + total += layingFarm.Total + } + + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) buildGrowingChickinPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + flagNames []string, + excludeTransferToLaying bool, + partCode string, + partTitle string, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + return nil, nil + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + if err != nil { + return nil, err + } + if len(kandangIDsGrowing) == 0 { + return nil, nil + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return nil, err + } + if totalPopulationFlockGrowing <= 0 { + return nil, nil + } + + ratio := transferTotalQty / totalPopulationFlockGrowing + if ratio <= 0 { + return nil, nil + } + + rows, err := s.hppRepo.ListChickinCostRowsByProductFlags(context.Background(), kandangIDsGrowing, flagNames, endDate, excludeTransferToLaying) + if err != nil { + return nil, err + } + + return buildChickinPartFromRows( + rows, + partCode, + partTitle, + []string{hppV2ScopePulletCost}, + &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: transferTotalQty, + Denominator: totalPopulationFlockGrowing, + Ratio: ratio, + }, + ratio, + ), nil +} + +func (s *hppV2Service) buildLayingChickinPart( + projectFlockKandangId uint, + endDate *time.Time, + flagNames []string, + excludeTransferToLaying bool, + partCode string, + partTitle string, +) (*HppV2ComponentPart, error) { + rows, err := s.hppRepo.ListChickinCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, flagNames, endDate, excludeTransferToLaying) + if err != nil { + return nil, err + } + + return buildChickinPartFromRows(rows, partCode, partTitle, []string{hppV2ScopeProductionCost}, nil, 1), nil +} + +func (s *hppV2Service) buildGrowingUsagePart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2StockComponentConfig, + cutover bool, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + return nil, nil + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + if err != nil { + return nil, err + } + if len(kandangIDsGrowing) == 0 { + return nil, nil + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return nil, err + } + if totalPopulationFlockGrowing == 0 { + return nil, nil + } + + ratio := transferTotalQty / totalPopulationFlockGrowing + if ratio <= 0 { + return nil, nil + } + + partCode := hppV2PartGrowingNormal + partTitle := "Growing" + baseRows := make([]HppV2Reference, 0) + baseTotal := 0.0 + + if cutover { + partCode = hppV2PartGrowingCutover + partTitle = "Growing Cut-over" + + rows, err := s.hppRepo.ListAdjustmentCostRowsByProductFlags(context.Background(), kandangIDsGrowing, config.CutoverFlags, endDate) + if err != nil { + return nil, err + } + for _, row := range rows { + rowTotal := adjustmentRowTotalCost(row) + baseTotal += rowTotal + baseRows = append(baseRows, HppV2Reference{ + Type: "adjustment_stock", + ID: row.AdjustmentID, + ProjectFlockKandangID: row.ProjectFlockKandangID, + ProductID: row.ProductID, + ProductName: row.ProductName, + Date: row.CreatedAt.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: rowTotal, + AppliedTotal: rowTotal * ratio, + }) + } + } else { + rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), kandangIDsGrowing, config.NormalFlags, endDate) + if err != nil { + return nil, err + } + for _, row := range rows { + baseTotal += row.TotalCost + refDate := row.LastUsedAt + if refDate.IsZero() { + refDate = row.FirstUsedAt + } + baseRows = append(baseRows, HppV2Reference{ + Type: "stock_allocation", + ID: row.StockableID, + StockableType: row.StockableType, + ProductID: row.SourceProductID, + ProductName: row.SourceProductName, + Date: refDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.UnitPrice, + Total: row.TotalCost, + AppliedTotal: row.TotalCost * ratio, + }) + } + } + + if baseTotal == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: partCode, + Title: partTitle, + Scopes: []string{hppV2ScopePulletCost}, + Total: baseTotal * ratio, + Proration: &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: transferTotalQty, + Denominator: totalPopulationFlockGrowing, + Ratio: ratio, + }, + References: baseRows, + }, nil +} + +func (s *hppV2Service) buildLayingUsagePart( + projectFlockKandangId uint, + endDate *time.Time, + config hppV2StockComponentConfig, + cutover bool, +) (*HppV2ComponentPart, error) { + if cutover { + rows, err := s.hppRepo.ListAdjustmentCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.CutoverFlags, endDate) + if err != nil { + return nil, err + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + rowTotal := adjustmentRowTotalCost(row) + total += rowTotal + references = append(references, HppV2Reference{ + Type: "adjustment_stock", + ID: row.AdjustmentID, + ProjectFlockKandangID: row.ProjectFlockKandangID, + ProductID: row.ProductID, + ProductName: row.ProductName, + Date: row.CreatedAt.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: rowTotal, + AppliedTotal: rowTotal, + }) + } + if total == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartLayingCutover, + Title: "Laying Cut-over", + Scopes: []string{hppV2ScopeProductionCost}, + Total: total, + References: references, + }, nil + } + + rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate) + if err != nil { + return nil, err + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost + refDate := row.LastUsedAt + if refDate.IsZero() { + refDate = row.FirstUsedAt + } + references = append(references, HppV2Reference{ + Type: "stock_allocation", + ID: row.StockableID, + StockableType: row.StockableType, + ProductID: row.SourceProductID, + ProductName: row.SourceProductName, + Date: refDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.UnitPrice, + Total: row.TotalCost, + AppliedTotal: row.TotalCost, + }) + } + if total == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartLayingNormal, + Title: "Laying", + Scopes: []string{hppV2ScopeProductionCost}, + Total: total, + References: references, + }, nil +} + +func (s *hppV2Service) buildGrowingExpenseDirectPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + return s.buildGrowingExpensePart(projectFlockKandangId, contextRow, endDate, config, false) +} + +func (s *hppV2Service) buildGrowingExpenseFarmPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + return s.buildGrowingExpensePart(projectFlockKandangId, contextRow, endDate, config, true) +} + +func (s *hppV2Service) buildGrowingExpensePart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, + farmLevel bool, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + return nil, nil + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + if err != nil { + return nil, err + } + if len(kandangIDsGrowing) == 0 { + return nil, nil + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return nil, err + } + if totalPopulationFlockGrowing <= 0 { + return nil, nil + } + + ratio := transferTotalQty / totalPopulationFlockGrowing + if ratio <= 0 { + return nil, nil + } + + var rows []commonRepo.HppV2ExpenseCostRow + if farmLevel { + rows, err = s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), sourceProjectFlockID, endDate, config.Ekspedisi) + } else { + rows, err = s.hppRepo.ListExpenseRealizationRowsByProjectFlockKandangIDs(context.Background(), kandangIDsGrowing, endDate, config.Ekspedisi) + } + if err != nil { + return nil, err + } + + return buildExpensePartFromRows( + rows, + map[bool]string{false: hppV2PartGrowingDirect, true: hppV2PartGrowingFarm}[farmLevel], + map[bool]string{false: "Growing Direct", true: "Growing Farm"}[farmLevel], + []string{hppV2ScopePulletCost}, + &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: transferTotalQty, + Denominator: totalPopulationFlockGrowing, + Ratio: ratio, + }, + ratio, + ), nil +} + +func (s *hppV2Service) buildLayingExpenseDirectPart( + projectFlockKandangId uint, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + rows, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockKandangIDs(context.Background(), []uint{projectFlockKandangId}, endDate, config.Ekspedisi) + if err != nil { + return nil, err + } + + return buildExpensePartFromRows(rows, hppV2PartLayingDirect, "Laying Direct", []string{hppV2ScopeProductionCost}, nil, 1), nil +} + +func (s *hppV2Service) buildLayingExpenseFarmPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + rows, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, endDate, config.Ekspedisi) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + + farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) + if err != nil { + return nil, err + } + farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate) + if err != nil { + return nil, err + } + + basis := hppV2ProrationEggWeight + numerator := targetWeight + denominator := farmWeight + if denominator <= 0 { + basis = hppV2ProrationEggPiece + numerator = targetPieces + denominator = farmPieces + } + if denominator <= 0 { + return nil, nil + } + + ratio := numerator / denominator + if ratio <= 0 { + return nil, nil + } + + return buildExpensePartFromRows( + rows, + hppV2PartLayingFarm, + "Laying Farm", + []string{hppV2ScopeProductionCost}, + &HppV2Proration{ + Basis: basis, + Numerator: numerator, + Denominator: denominator, + Ratio: ratio, + }, + ratio, + ), nil +} + +func (s *hppV2Service) getManualPulletCostComponent( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, +) (*HppV2Component, error) { + if s.hppRepo == nil || contextRow == nil { + return nil, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + if sourceProjectFlockID != 0 && transferTotalQty > 0 { + return nil, nil + } + + manualInput, err := s.hppRepo.GetManualDepreciationInputByProjectFlockID(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if manualInput == nil || manualInput.TotalCost <= 0 || manualInput.CutoverDate.IsZero() { + return nil, nil + } + if dateOnly(periodDate).Before(dateOnly(manualInput.CutoverDate)) { + return nil, nil + } + + farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if len(farmPFKIDs) == 0 { + return nil, nil + } + + totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs) + if err != nil { + return nil, err + } + if totalPopulation <= 0 { + return nil, nil + } + + targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId}) + if err != nil { + return nil, err + } + if targetPopulation <= 0 { + return nil, nil + } + + ratio := targetPopulation / totalPopulation + if ratio <= 0 { + return nil, nil + } + + appliedTotal := manualInput.TotalCost * ratio + part := HppV2ComponentPart{ + Code: hppV2PartManualCutover, + Title: "Manual Cut-over", + Scopes: []string{hppV2ScopePulletCost}, + Total: appliedTotal, + Proration: &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: targetPopulation, + Denominator: totalPopulation, + Ratio: ratio, + }, + Details: map[string]any{ + "cutover_date": formatDateOnly(manualInput.CutoverDate), + "farm_total_cost": manualInput.TotalCost, + "target_population": targetPopulation, + "farm_population": totalPopulation, + }, + References: []HppV2Reference{ + { + Type: "farm_depreciation_manual_input", + ID: manualInput.ID, + Date: formatDateOnly(manualInput.CutoverDate), + Qty: 1, + Total: manualInput.TotalCost, + AppliedTotal: appliedTotal, + }, + }, + } + + return &HppV2Component{ + Code: hppV2ComponentManualPulletCost, + Title: "Manual Pullet Cost", + Scopes: []string{hppV2ScopePulletCost}, + Total: appliedTotal, + Parts: []HppV2ComponentPart{part}, + }, nil +} + +func (s *hppV2Service) getDepreciationComponent( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, + endDate time.Time, + totalPulletCost float64, +) (*HppV2Component, error) { + if s.hppRepo == nil || contextRow == nil { + return nil, nil + } + + snapshotPart, err := s.buildFarmSnapshotDepreciationPart(projectFlockKandangId, contextRow, periodDate, endDate) + if err != nil { + return nil, err + } + if snapshotPart != nil { + return &HppV2Component{ + Code: hppV2ComponentDepreciation, + Title: "Depreciation", + Scopes: []string{hppV2ScopeProductionCost}, + Total: snapshotPart.Total, + Parts: []HppV2ComponentPart{*snapshotPart}, + }, nil + } + + if totalPulletCost <= 0 { + return nil, nil + } + + transferInput, err := s.hppRepo.GetLatestTransferInputByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate) + if err != nil { + return nil, err + } + + var part *HppV2ComponentPart + if transferInput != nil && transferInput.SourceProjectFlockID > 0 { + part, err = s.buildNormalTransferDepreciationPart(contextRow, transferInput, periodDate, totalPulletCost) + if err != nil { + return nil, err + } + } else { + part, err = s.buildManualCutoverDepreciationPart(projectFlockKandangId, contextRow, periodDate, totalPulletCost) + if err != nil { + return nil, err + } + } + if part == nil { + return nil, nil + } + + return &HppV2Component{ + Code: hppV2ComponentDepreciation, + Title: "Depreciation", + Scopes: []string{hppV2ScopeProductionCost}, + Total: part.Total, + Parts: []HppV2ComponentPart{*part}, + }, nil +} + +func (s *hppV2Service) buildFarmSnapshotDepreciationPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, + endDate time.Time, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + snapshot, err := s.hppRepo.GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(context.Background(), contextRow.ProjectFlockID, periodDate) + if err != nil { + return nil, err + } + if snapshot == nil || snapshot.DepreciationValue <= 0 { + return nil, nil + } + + farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if len(farmPFKIDs) == 0 { + return nil, nil + } + + end := endDate + targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, &end) + if err != nil { + return nil, err + } + farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, &end) + if err != nil { + return nil, err + } + + basis := hppV2ProrationEggWeight + numerator := targetWeight + denominator := farmWeight + if denominator <= 0 { + basis = hppV2ProrationEggPiece + numerator = targetPieces + denominator = farmPieces + } + if denominator <= 0 { + return nil, nil + } + + ratio := numerator / denominator + if ratio <= 0 { + return nil, nil + } + + appliedDepreciation := snapshot.DepreciationValue * ratio + if appliedDepreciation <= 0 { + return nil, nil + } + appliedPulletCostDayN := snapshot.PulletCostDayNTotal * ratio + depreciationPercent := snapshot.DepreciationPercentEffective + if appliedPulletCostDayN > 0 { + depreciationPercent = (appliedDepreciation / appliedPulletCostDayN) * 100 + } + + return &HppV2ComponentPart{ + Code: hppV2PartDepreciationFarmSnapshot, + Title: "Farm Snapshot", + Scopes: []string{hppV2ScopeProductionCost}, + Total: appliedDepreciation, + Proration: &HppV2Proration{ + Basis: basis, + Numerator: numerator, + Denominator: denominator, + Ratio: ratio, + }, + Details: map[string]any{ + "basis_total": snapshot.DepreciationValue, + "pullet_cost_day_n": appliedPulletCostDayN, + "depreciation_percent": depreciationPercent, + "snapshot_id": snapshot.ID, + "snapshot_period_date": formatDateOnly(snapshot.PeriodDate), + "snapshot_project_flock": snapshot.ProjectFlockID, + }, + References: []HppV2Reference{ + { + Type: "farm_depreciation_snapshot", + ID: snapshot.ID, + Date: formatDateOnly(snapshot.PeriodDate), + Qty: 1, + Total: snapshot.DepreciationValue, + AppliedTotal: appliedDepreciation, + }, + }, + }, nil +} + +func (s *hppV2Service) buildNormalTransferDepreciationPart( + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + transferInput *commonRepo.HppV2LatestTransferInputRow, + periodDate time.Time, + totalPulletCost float64, +) (*HppV2ComponentPart, error) { + if contextRow == nil || transferInput == nil || totalPulletCost <= 0 { + return nil, nil + } + + originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), transferInput.SourceProjectFlockID) + if err != nil { + return nil, err + } + if originDate == nil || originDate.IsZero() { + return nil, nil + } + + scheduleDay := DepreciationScheduleDay(*originDate, periodDate, contextRow.HouseType) + if scheduleDay <= 0 { + return nil, nil + } + + houseType := NormalizeDepreciationHouseType(contextRow.HouseType) + percentByHouseType, err := s.hppRepo.GetDepreciationPercents(context.Background(), []string{houseType}, scheduleDay) + if err != nil { + return nil, err + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN( + totalPulletCost, + scheduleDay, + contextRow.HouseType, + percentByHouseType, + ) + if depreciationValue <= 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartDepreciationNormal, + Title: "Normal Transfer", + Scopes: []string{hppV2ScopeProductionCost}, + Total: depreciationValue, + Details: map[string]any{ + "basis_total": totalPulletCost, + "pullet_cost_day_n": pulletCostDayN, + "depreciation_percent": depreciationPercent, + "schedule_day": scheduleDay, + "origin_date": formatDateOnly(*originDate), + "transfer_date": formatDateOnly(transferInput.TransferDate), + "source_project_flock_id": transferInput.SourceProjectFlockID, + }, + References: []HppV2Reference{ + { + Type: "laying_transfer", + ID: transferInput.TransferID, + Date: formatDateOnly(transferInput.TransferDate), + Qty: transferInput.TransferQty, + Total: totalPulletCost, + AppliedTotal: depreciationValue, + }, + }, + }, nil +} + +func (s *hppV2Service) buildManualCutoverDepreciationPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, + totalPulletCost float64, +) (*HppV2ComponentPart, error) { + if contextRow == nil || totalPulletCost <= 0 { + return nil, nil + } + + manualInput, err := s.hppRepo.GetManualDepreciationInputByProjectFlockID(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if manualInput == nil || manualInput.TotalCost <= 0 || manualInput.CutoverDate.IsZero() { + return nil, nil + } + if dateOnly(periodDate).Before(dateOnly(manualInput.CutoverDate)) { + return nil, nil + } + + originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if originDate == nil || originDate.IsZero() { + return nil, nil + } + + reportScheduleDay := DepreciationScheduleDay(*originDate, periodDate, contextRow.HouseType) + if reportScheduleDay <= 0 { + return nil, nil + } + + cutoverScheduleDay := DepreciationScheduleDay(*originDate, manualInput.CutoverDate, contextRow.HouseType) + startDay := 1 + if cutoverScheduleDay > 0 { + startDay = cutoverScheduleDay + } + + houseType := NormalizeDepreciationHouseType(contextRow.HouseType) + percentByHouseType, err := s.hppRepo.GetDepreciationPercents(context.Background(), []string{houseType}, reportScheduleDay) + if err != nil { + return nil, err + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange( + totalPulletCost, + startDay, + reportScheduleDay, + contextRow.HouseType, + percentByHouseType, + ) + if depreciationValue <= 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartDepreciationCutover, + Title: "Manual Cut-over", + Scopes: []string{hppV2ScopeProductionCost}, + Total: depreciationValue, + Details: map[string]any{ + "basis_total": totalPulletCost, + "pullet_cost_day_n": pulletCostDayN, + "depreciation_percent": depreciationPercent, + "schedule_day": reportScheduleDay, + "start_schedule_day": startDay, + "origin_date": formatDateOnly(*originDate), + "cutover_date": formatDateOnly(manualInput.CutoverDate), + "manual_input_id": manualInput.ID, + "project_flock_kandang": projectFlockKandangId, + }, + References: []HppV2Reference{ + { + Type: "farm_depreciation_manual_input", + ID: manualInput.ID, + Date: formatDateOnly(manualInput.CutoverDate), + Qty: 1, + Total: totalPulletCost, + AppliedTotal: depreciationValue, + }, + }, + }, nil +} + +func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { + utils.Log.Infof( + "GetHppEstimationDanRealisasi started: project_flock_kandang_id=%d total_production_cost=%.2f start_date=%s end_date=%s", + projectFlockKandangId, + totalProductionCost, + formatTimePtr(startDate), + formatTimePtr(endDate), + ) + + if s.hppRepo == nil { + utils.Log.Warnf( + "GetHppEstimationDanRealisasi skipped: hpp repository is nil (project_flock_kandang_id=%d)", + projectFlockKandangId, + ) + return &HppCostResponse{}, nil + } + + estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) + if err != nil { + utils.Log.WithError(err).Errorf( + "GetHppEstimationDanRealisasi failed to get estimation egg production: project_flock_kandang_id=%d end_date=%s", + projectFlockKandangId, + formatTimePtr(endDate), + ) + return nil, err + } + + realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) + if err != nil { + utils.Log.WithError(err).Errorf( + "GetHppEstimationDanRealisasi failed to get realization egg sales: project_flock_kandang_id=%d start_date=%s end_date=%s", + projectFlockKandangId, + formatTimePtr(startDate), + formatTimePtr(endDate), + ) + return nil, err + } + + estimation := HppCostDetail{ + Total: totalProductionCost, + Kg: estimWeightKg, + Butir: estimPieces, + } + if estimWeightKg > 0 { + estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg) + } + if estimPieces > 0 { + estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces) + } + + real := HppCostDetail{ + Total: totalProductionCost, + Kg: realWeightKg, + Butir: realPieces, + } + if realWeightKg > 0 { + real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg) + } + if realPieces > 0 { + real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) + } + + utils.Log.Infof( + "GetHppEstimationDanRealisasi success: project_flock_kandang_id=%d estimation_butir=%.2f estimation_kg=%.2f estimation_harga_butir=%.2f estimation_harga_kg=%.2f real_butir=%.2f real_kg=%.2f real_harga_butir=%.2f real_harga_kg=%.2f totalProductionCost=%.2f", + projectFlockKandangId, + estimation.Butir, + estimation.Kg, + estimation.HargaButir, + estimation.HargaKg, + real.Butir, + real.Kg, + real.HargaButir, + real.HargaKg, + totalProductionCost, + ) + + return &HppCostResponse{ + Estimation: estimation, + Real: real, + }, nil +} + +func hppV2DayWindow(date *time.Time) (time.Time, time.Time, error) { + if date == nil { + now := time.Now() + date = &now + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return time.Time{}, time.Time{}, err + } + + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) + endOfDay := startOfDay.Add(24 * time.Hour) + return startOfDay, endOfDay, nil +} + +func adjustmentRowTotalCost(row commonRepo.HppV2AdjustmentCostRow) float64 { + if row.GrandTotal > 0 { + return row.GrandTotal + } + return row.Qty * row.Price +} + +func buildExpensePartFromRows( + rows []commonRepo.HppV2ExpenseCostRow, + code string, + title string, + scopes []string, + proration *HppV2Proration, + ratio float64, +) *HppV2ComponentPart { + if len(rows) == 0 { + return nil + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost * ratio + references = append(references, HppV2Reference{ + Type: "expense_realization", + ID: row.ExpenseRealizationID, + ProductID: row.NonstockID, + ProductName: row.NonstockName, + Date: row.RealizationDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: row.TotalCost, + AppliedTotal: row.TotalCost * ratio, + }) + } + if total == 0 { + return nil + } + + return &HppV2ComponentPart{ + Code: code, + Title: title, + Scopes: append([]string{}, scopes...), + Total: total, + Proration: proration, + References: references, + } +} + +func buildChickinPartFromRows( + rows []commonRepo.HppV2ChickinCostRow, + code string, + title string, + scopes []string, + proration *HppV2Proration, + ratio float64, +) *HppV2ComponentPart { + if len(rows) == 0 { + return nil + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost * ratio + projectFlockKandangID := row.ProjectFlockKandangID + references = append(references, HppV2Reference{ + Type: "project_chickin", + ID: row.ProjectChickinID, + StockableType: row.StockableType, + ProjectFlockKandangID: &projectFlockKandangID, + ProductID: row.SourceProductID, + ProductName: row.SourceProductName, + Date: row.ChickInDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.UnitPrice, + Total: row.TotalCost, + AppliedTotal: row.TotalCost * ratio, + }) + } + if total == 0 { + return nil + } + + return &HppV2ComponentPart{ + Code: code, + Title: title, + Scopes: append([]string{}, scopes...), + Total: total, + Proration: proration, + References: references, + } +} + +func componentHasScope(component *HppV2Component, scope string) bool { + if component == nil || scope == "" { + return false + } + for _, candidate := range component.Scopes { + if candidate == scope { + return true + } + } + return false +} + +func componentScopeTotal(component *HppV2Component, scope string) float64 { + if component == nil || scope == "" { + return 0 + } + + total := 0.0 + hasPartScopes := false + for _, part := range component.Parts { + if len(part.Scopes) == 0 { + continue + } + hasPartScopes = true + if partHasScope(&part, scope) { + total += part.Total + } + } + if hasPartScopes { + return total + } + if componentHasScope(component, scope) { + return component.Total + } + return 0 +} + +func partHasScope(part *HppV2ComponentPart, scope string) bool { + if part == nil || scope == "" { + return false + } + for _, candidate := range part.Scopes { + if candidate == scope { + return true + } + } + return false +} + +func dateOnly(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) +} + +func formatDateOnly(value time.Time) string { + return dateOnly(value).Format("2006-01-02") +} diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go new file mode 100644 index 00000000..a2a8d27e --- /dev/null +++ b/internal/common/service/common.hppv2.service_test.go @@ -0,0 +1,872 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" + "testing" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type hppV2RepoStub struct { + contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext + pfkIDsByProject map[uint][]uint + latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow + manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow + snapshotByProjectKey map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow + chickInDateByProject map[uint]*time.Time + depreciationByHouse map[string]map[int]float64 + usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow + adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow + chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow + expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow + expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow + totalPopulationByKey map[string]float64 + transferSummaryByPFK map[uint]struct { + projectFlockID uint + totalQty float64 + } + eggProductionByPFK map[uint]struct { + pieces float64 + kg float64 + } + eggSalesByPFK map[uint]struct { + pieces float64 + kg float64 + } +} + +func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) { + row := s.contextByPFK[projectFlockKandangId] + if row == nil { + return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId) + } + return row, nil +} + +func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) { + return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil +} + +func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) { + return s.latestTransferByPFK[projectFlockKandangId], nil +} + +func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) { + return s.manualInputByProject[projectFlockID], nil +} + +func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) { + if s.snapshotByProjectKey == nil { + return nil, nil + } + return s.snapshotByProjectKey[fmt.Sprintf("%d|%s", projectFlockID, periodDate.Format("2006-01-02"))], nil +} + +func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) { + return s.chickInDateByProject[projectFlockID], nil +} + +func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) { + result := make(map[string]map[int]float64) + for _, houseType := range houseTypes { + source := s.depreciationByHouse[houseType] + if len(source) == 0 { + continue + } + result[houseType] = make(map[int]float64) + for day, pct := range source { + if day <= maxDay { + result[houseType][day] = pct + } + } + } + return result, nil +} + +func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) { + return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil +} + +func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) { + return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil +} + +func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { + return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil +} + +func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { + return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil +} + +func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) { + return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil +} + +func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) { + return 0, nil +} + +func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) { + return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil +} + +func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) { + totalPieces := 0.0 + totalKg := 0.0 + for _, projectFlockKandangID := range projectFlockKandangIDs { + row := s.eggProductionByPFK[projectFlockKandangID] + totalPieces += row.pieces + totalKg += row.kg + } + return totalPieces, totalKg, nil +} + +func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) { + if len(projectFlockKandangIDs) != 1 { + return 0, 0, nil + } + row := s.eggSalesByPFK[projectFlockKandangIDs[0]] + return row.pieces, row.kg, nil +} + +func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) { + row := s.transferSummaryByPFK[projectFlockKandangId] + return row.projectFlockID, row.totalQty, nil +} + +func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 10: { + ProjectFlockKandangID: 10, + ProjectFlockID: 2, + ProjectFlockCategory: "LAYING", + KandangID: 100, + KandangName: "Kandang A", + LocationID: 16, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 1: {101, 102}, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{101, 102}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000}, + }, + stubKey([]uint{10}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600}, + }, + stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300}, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{101, 102}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 10: {projectFlockID: 1, totalQty: 250}, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 10: {pieces: 100, kg: 10}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 10: {pieces: 40, kg: 4}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected breakdown result") + } + if got := result.TotalPulletCost; got != 1150 { + t.Fatalf("expected total pullet cost 1150, got %v", got) + } + if got := result.TotalProductionCost; got != 1800 { + t.Fatalf("expected total production cost 1800, got %v", got) + } + if len(result.Components) != 1 { + t.Fatalf("expected 1 component, got %d", len(result.Components)) + } + component := result.Components[0] + if component.Code != "PAKAN" { + t.Fatalf("expected PAKAN component, got %s", component.Code) + } + partTotals := map[string]float64{} + for _, part := range component.Parts { + partTotals[part.Code] = part.Total + } + if partTotals[hppV2PartGrowingNormal] != 1000 { + t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal]) + } + if partTotals[hppV2PartGrowingCutover] != 150 { + t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover]) + } + if partTotals[hppV2PartLayingNormal] != 1500 { + t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal]) + } + if partTotals[hppV2PartLayingCutover] != 300 { + t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover]) + } + if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 { + t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration) + } + if result.Hpp.Estimation.HargaKg != 180 { + t.Fatalf("expected estimation harga/kg 180, got %v", result.Hpp.Estimation.HargaKg) + } + if result.Hpp.Real.HargaKg != 450 { + t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 20: { + ProjectFlockKandangID: 20, + ProjectFlockID: 3, + ProjectFlockCategory: "LAYING", + KandangID: 200, + KandangName: "Kandang B", + LocationID: 17, + }, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{20}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 20: {pieces: 50, kg: 5}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 20: {pieces: 25, kg: 2.5}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result.TotalProductionCost != 500 { + t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost) + } + component := result.Components[0] + if len(component.Parts) != 2 { + t.Fatalf("expected 2 laying parts, got %d", len(component.Parts)) + } + for _, part := range component.Parts { + if strings.HasPrefix(part.Code, "growing_") { + t.Fatalf("expected no growing parts, got %s", part.Code) + } + } + if result.Hpp.Estimation.HargaKg != 100 { + t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 30: { + ProjectFlockKandangID: 30, + ProjectFlockID: 4, + ProjectFlockCategory: "LAYING", + KandangID: 300, + KandangName: "Kandang C", + LocationID: 18, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 5: {301, 302}, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{30}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500}, + }, + stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): { + {StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400}, + }, + stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): { + {StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{301, 302}, []string{"OVK"}): { + {AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100}, + }, + stubKey([]uint{30}, []string{"OVK"}): { + {AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50}, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{301, 302}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 30: {projectFlockID: 5, totalQty: 500}, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 30: {pieces: 120, kg: 12}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 30: {pieces: 60, kg: 6}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected breakdown result") + } + if len(result.Components) != 2 { + t.Fatalf("expected 2 components, got %d", len(result.Components)) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentPakan] != 500 { + t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan]) + } + if componentTotals[hppV2ComponentOvk] != 450 { + t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk]) + } + if result.TotalPulletCost != 250 { + t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 700 { + t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 58.33 { + t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 35: { + ProjectFlockKandangID: 35, + ProjectFlockID: 8, + ProjectFlockCategory: "LAYING", + KandangID: 350, + KandangName: "Kandang E", + LocationID: 20, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 9: {901, 902}, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{901, 902}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 35: {projectFlockID: 9, totalQty: 250}, + }, + chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{ + chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): { + {ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000}, + }, + chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): { + {ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 35: {pieces: 100, kg: 10}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 35: {pieces: 80, kg: 8}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentDocChickin] != 500 { + t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin]) + } + if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 { + t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase]) + } + if result.TotalPulletCost != 500 { + t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 1000 { + t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 100 { + t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 40: { + ProjectFlockKandangID: 40, + ProjectFlockID: 6, + ProjectFlockCategory: "LAYING", + KandangID: 400, + KandangName: "Kandang D", + LocationID: 19, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 6: {40, 41}, + 7: {701, 702}, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{701, 702}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 40: {projectFlockID: 7, totalQty: 200}, + }, + expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{ + expenseStubKey([]uint{701, 702}, false): { + {ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")}, + }, + expenseStubKey([]uint{40}, false): { + {ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")}, + }, + expenseStubKey([]uint{701, 702}, true): { + {ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")}, + }, + expenseStubKey([]uint{40}, true): { + {ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")}, + }, + }, + expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{ + expenseFarmKey(7, false): { + {ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")}, + }, + expenseFarmKey(6, false): { + {ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")}, + }, + expenseFarmKey(7, true): { + {ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")}, + }, + expenseFarmKey(6, true): { + {ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 40: {pieces: 30, kg: 3}, + 41: {pieces: 70, kg: 7}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 40: {pieces: 50, kg: 5}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentBopRegular] != 270 { + t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular]) + } + if componentTotals[hppV2ComponentBopEksp] != 88 { + t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp]) + } + if result.TotalPulletCost != 190 { + t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 168 { + t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 56 { + t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) { + sourceChickIn := mustTime(t, "2026-01-01") + reportDate := sourceChickIn.AddDate(0, 0, 154) + + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 50: { + ProjectFlockKandangID: 50, + ProjectFlockID: 10, + ProjectFlockCategory: "LAYING", + KandangID: 500, + KandangName: "Kandang F", + LocationID: 21, + HouseType: "close_house", + }, + }, + pfkIDsByProject: map[uint][]uint{ + 11: {501}, + }, + latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{ + 50: { + ProjectFlockKandangID: 50, + SourceProjectFlockID: 11, + TransferDate: mustTime(t, "2026-05-20"), + TransferQty: 100, + TransferID: 701, + }, + }, + chickInDateByProject: map[uint]*time.Time{ + 11: &sourceChickIn, + }, + depreciationByHouse: map[string]map[int]float64{ + "close_house": { + 1: 10, + }, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{501}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000}, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{501}, nil): 100, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 50: {projectFlockID: 11, totalQty: 100}, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 50: {pieces: 20, kg: 10}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(50, &reportDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if result.TotalPulletCost != 1000 { + t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 100 { + t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost) + } + + var depreciation *HppV2Component + for i := range result.Components { + if result.Components[i].Code == hppV2ComponentDepreciation { + depreciation = &result.Components[i] + break + } + } + if depreciation == nil { + t.Fatal("expected depreciation component") + } + if depreciation.Total != 100 { + t.Fatalf("expected depreciation total 100, got %v", depreciation.Total) + } + if len(depreciation.Parts) != 1 { + t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts)) + } + if depreciation.Parts[0].Details["schedule_day"] != 1 { + t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details) + } + if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" { + t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details) + } + if result.Hpp.Estimation.HargaKg != 10 { + t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) { + originDate := mustTime(t, "2026-01-01") + cutoverDate := originDate.AddDate(0, 0, 155) + + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 60: { + ProjectFlockKandangID: 60, + ProjectFlockID: 12, + ProjectFlockCategory: "LAYING", + KandangID: 600, + KandangName: "Kandang G", + LocationID: 22, + HouseType: "close_house", + }, + }, + pfkIDsByProject: map[uint][]uint{ + 12: {60}, + }, + manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{ + 12: { + ID: 801, + ProjectFlockID: 12, + TotalCost: 1000, + CutoverDate: cutoverDate, + }, + }, + chickInDateByProject: map[uint]*time.Time{ + 12: &originDate, + }, + depreciationByHouse: map[string]map[int]float64{ + "close_house": { + 1: 10, + 2: 20, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{60}, nil): 100, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 60: {pieces: 20, kg: 10}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(60, &cutoverDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if result.TotalPulletCost != 1000 { + t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 200 { + t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + if componentTotals[hppV2ComponentManualPulletCost] != 1000 { + t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost]) + } + if componentTotals[hppV2ComponentDepreciation] != 200 { + t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation]) + } + + var depreciation *HppV2Component + for i := range result.Components { + if result.Components[i].Code == hppV2ComponentDepreciation { + depreciation = &result.Components[i] + break + } + } + if depreciation == nil || len(depreciation.Parts) != 1 { + t.Fatalf("expected one depreciation part, got %+v", depreciation) + } + if depreciation.Parts[0].Details["schedule_day"] != 2 { + t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details) + } + if depreciation.Parts[0].Details["start_schedule_day"] != 2 { + t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details) + } + if result.Hpp.Estimation.HargaKg != 20 { + t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggProduction(t *testing.T) { + reportDate := mustTime(t, "2026-06-05") + + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 70: { + ProjectFlockKandangID: 70, + ProjectFlockID: 15, + ProjectFlockCategory: "LAYING", + KandangID: 700, + KandangName: "Kandang Snapshot", + LocationID: 25, + HouseType: "close_house", + }, + }, + pfkIDsByProject: map[uint][]uint{ + 15: {70, 71}, + }, + snapshotByProjectKey: map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow{ + "15|2026-06-05": { + ID: 901, + ProjectFlockID: 15, + PeriodDate: reportDate, + DepreciationPercentEffective: 10, + DepreciationValue: 1000, + PulletCostDayNTotal: 10000, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 70: {pieces: 200, kg: 20}, + 71: {pieces: 800, kg: 80}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(70, &reportDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected breakdown result") + } + + var depreciation *HppV2Component + for i := range result.Components { + if result.Components[i].Code == hppV2ComponentDepreciation { + depreciation = &result.Components[i] + break + } + } + if depreciation == nil { + t.Fatal("expected depreciation component") + } + if depreciation.Total != 200 { + t.Fatalf("expected depreciation total 200, got %v", depreciation.Total) + } + if result.TotalProductionCost != 200 { + t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost) + } + if len(depreciation.Parts) != 1 { + t.Fatalf("expected one depreciation part, got %d", len(depreciation.Parts)) + } + if depreciation.Parts[0].Code != hppV2PartDepreciationFarmSnapshot { + t.Fatalf("expected farm snapshot depreciation part, got %s", depreciation.Parts[0].Code) + } + if depreciation.Parts[0].Proration == nil || depreciation.Parts[0].Proration.Ratio != 0.2 { + t.Fatalf("expected proration ratio 0.2, got %+v", depreciation.Parts[0].Proration) + } + if depreciation.Parts[0].Details["snapshot_id"] != uint(901) { + t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details) + } +} + +func stubKey(ids []uint, flags []string) string { + idParts := make([]string, 0, len(ids)) + for _, id := range ids { + idParts = append(idParts, fmt.Sprintf("%d", id)) + } + sort.Strings(idParts) + + flagParts := append([]string{}, flags...) + sort.Strings(flagParts) + + return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",") +} + +func mustDate(t *testing.T, raw string) *time.Time { + t.Helper() + loc, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed to load timezone: %v", err) + } + value, err := time.ParseInLocation("2006-01-02", raw, loc) + if err != nil { + t.Fatalf("failed to parse date %s: %v", raw, err) + } + return &value +} + +func mustTime(t *testing.T, raw string) time.Time { + t.Helper() + value := mustDate(t, raw) + return *value +} + +func expenseStubKey(ids []uint, ekspedisi bool) string { + return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)}) +} + +func expenseFarmKey(projectFlockID uint, ekspedisi bool) string { + return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi) +} + +func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string { + return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying))) +} diff --git a/internal/common/service/depreciation_snapshot_invalidator.service.go b/internal/common/service/depreciation_snapshot_invalidator.service.go new file mode 100644 index 00000000..309cd844 --- /dev/null +++ b/internal/common/service/depreciation_snapshot_invalidator.service.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +const farmDepreciationSnapshotTable = "farm_depreciation_snapshots" + +func NormalizeDateOnlyUTC(value time.Time) time.Time { + if value.IsZero() { + return value + } + v := value.UTC() + return time.Date(v.Year(), v.Month(), v.Day(), 0, 0, 0, 0, time.UTC) +} + +func MinNonZeroDateOnlyUTC(values ...time.Time) time.Time { + var out time.Time + for _, value := range values { + if value.IsZero() { + continue + } + normalized := NormalizeDateOnlyUTC(value) + if out.IsZero() || normalized.Before(out) { + out = normalized + } + } + return out +} + +func InvalidateFarmDepreciationSnapshotsFromDate(ctx context.Context, db *gorm.DB, farmIDs []uint, fromDate time.Time) error { + if db == nil { + return nil + } + if fromDate.IsZero() { + return nil + } + + fromDate = NormalizeDateOnlyUTC(fromDate) + query := db.WithContext(ctx). + Table(farmDepreciationSnapshotTable). + Where("period_date >= ?", fromDate) + if len(farmIDs) > 0 { + query = query.Where("project_flock_id IN ?", farmIDs) + } + return query.Delete(nil).Error +} + +func ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx context.Context, db *gorm.DB, pfkIDs []uint) ([]uint, error) { + if db == nil || len(pfkIDs) == 0 { + return []uint{}, nil + } + + var projectFlockIDs []uint + if err := db.WithContext(ctx). + Table("project_flock_kandangs"). + Distinct("project_flock_id"). + Where("id IN ?", pfkIDs). + Pluck("project_flock_id", &projectFlockIDs).Error; err != nil { + return nil, err + } + + return projectFlockIDs, nil +} + +func ResolveProjectFlockIDsByExpenseID(ctx context.Context, db *gorm.DB, expenseID uint) ([]uint, error) { + if db == nil || expenseID == 0 { + return []uint{}, nil + } + + query := ` +WITH direct_farms AS ( + SELECT DISTINCT pfk.project_flock_id + FROM expense_nonstocks ens + JOIN project_flock_kandangs pfk ON pfk.id = ens.project_flock_kandang_id + WHERE ens.expense_id = @expense_id +), +json_farms AS ( + SELECT DISTINCT (jsonb_array_elements_text(e.project_flock_id::jsonb))::bigint AS project_flock_id + FROM expenses e + WHERE e.id = @expense_id + AND e.project_flock_id IS NOT NULL +) +SELECT DISTINCT project_flock_id +FROM ( + SELECT project_flock_id FROM direct_farms + UNION ALL + SELECT project_flock_id FROM json_farms +) x +` + + var ids []uint + if err := db.WithContext(ctx).Raw(query, map[string]any{ + "expense_id": expenseID, + }).Scan(&ids).Error; err != nil { + return nil, err + } + + return ids, nil +} diff --git a/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..down.sql b/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..down.sql new file mode 100644 index 00000000..b3ba5c34 --- /dev/null +++ b/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..down.sql @@ -0,0 +1,6 @@ +ALTER TABLE kandangs + DROP COLUMN IF EXISTS house_type; + +DROP TABLE IF EXISTS house_depreciation_standards; + +DROP TYPE IF EXISTS house_type_enum; diff --git a/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..up.sql b/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..up.sql new file mode 100644 index 00000000..6301b91f --- /dev/null +++ b/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..up.sql @@ -0,0 +1,18 @@ +CREATE TYPE house_type_enum AS ENUM ('open_house', 'close_house'); + +CREATE TABLE house_depreciation_standards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100), + effective_date DATE, + house_type house_type_enum NOT NULL, + day INT NOT NULL + CHECK (day >= 0), + depreciation_percent NUMERIC(15, 6) NOT NULL + CHECK (depreciation_percent >= 0 AND depreciation_percent <= 100), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT house_depreciation_standards_house_type_day_unique UNIQUE (house_type, day) +); + +ALTER TABLE kandangs + ADD COLUMN house_type house_type_enum; diff --git a/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.down.sql b/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.down.sql new file mode 100644 index 00000000..59e44914 --- /dev/null +++ b/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_project_flock_id; +DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_period_date; +DROP TABLE IF EXISTS farm_depreciation_snapshots; + diff --git a/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.up.sql b/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.up.sql new file mode 100644 index 00000000..450edc90 --- /dev/null +++ b/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS farm_depreciation_snapshots ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL + REFERENCES project_flocks(id) + ON UPDATE CASCADE + ON DELETE CASCADE, + period_date DATE NOT NULL, + depreciation_percent_effective NUMERIC(15, 6) NOT NULL DEFAULT 0, + depreciation_value NUMERIC(18, 3) NOT NULL DEFAULT 0, + pullet_cost_day_n_total NUMERIC(18, 3) NOT NULL DEFAULT 0, + components JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT farm_depreciation_snapshots_unique UNIQUE (project_flock_id, period_date) +); + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_period_date + ON farm_depreciation_snapshots (period_date); + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_project_flock_id + ON farm_depreciation_snapshots (project_flock_id); + diff --git a/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.down.sql b/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.down.sql new file mode 100644 index 00000000..62fa4007 --- /dev/null +++ b/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_project_flock_id; +DROP TABLE IF EXISTS farm_depreciation_manual_inputs; diff --git a/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.up.sql b/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.up.sql new file mode 100644 index 00000000..fd07e217 --- /dev/null +++ b/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS farm_depreciation_manual_inputs ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL + REFERENCES project_flocks(id) + ON UPDATE CASCADE + ON DELETE CASCADE, + total_cost NUMERIC(18, 3) NOT NULL DEFAULT 0 + CHECK (total_cost >= 0), + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT farm_depreciation_manual_inputs_unique UNIQUE (project_flock_id) +); + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_project_flock_id + ON farm_depreciation_manual_inputs (project_flock_id); diff --git a/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql b/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql new file mode 100644 index 00000000..0dce0ea1 --- /dev/null +++ b/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_cutover_date; + +ALTER TABLE farm_depreciation_manual_inputs + DROP COLUMN IF EXISTS cutover_date; diff --git a/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql b/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql new file mode 100644 index 00000000..20abc16e --- /dev/null +++ b/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql @@ -0,0 +1,12 @@ +ALTER TABLE farm_depreciation_manual_inputs + ADD COLUMN IF NOT EXISTS cutover_date DATE; + +UPDATE farm_depreciation_manual_inputs +SET cutover_date = COALESCE(cutover_date, DATE(created_at)) +WHERE cutover_date IS NULL; + +ALTER TABLE farm_depreciation_manual_inputs + ALTER COLUMN cutover_date SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_cutover_date + ON farm_depreciation_manual_inputs (cutover_date); diff --git a/internal/entities/farm_depreciation_manual_input.go b/internal/entities/farm_depreciation_manual_input.go new file mode 100644 index 00000000..ee4f9989 --- /dev/null +++ b/internal/entities/farm_depreciation_manual_input.go @@ -0,0 +1,18 @@ +package entities + +import "time" + +type FarmDepreciationManualInput struct { + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_manual_inputs_unique"` + TotalCost float64 `gorm:"type:numeric(18,3);not null;default:0"` + CutoverDate time.Time `gorm:"type:date;not null"` + Note *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` +} + +func (FarmDepreciationManualInput) TableName() string { + return "farm_depreciation_manual_inputs" +} diff --git a/internal/entities/farm_depreciation_snapshot.go b/internal/entities/farm_depreciation_snapshot.go new file mode 100644 index 00000000..24ce72b9 --- /dev/null +++ b/internal/entities/farm_depreciation_snapshot.go @@ -0,0 +1,21 @@ +package entities + +import ( + "time" +) + +type FarmDepreciationSnapshot struct { + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:1"` + PeriodDate time.Time `gorm:"type:date;not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:2"` + DepreciationPercentEffective float64 `gorm:"type:numeric(15,6);not null;default:0"` + DepreciationValue float64 `gorm:"type:numeric(18,3);not null;default:0"` + PulletCostDayNTotal float64 `gorm:"type:numeric(18,3);not null;default:0"` + Components []byte `gorm:"type:jsonb;default:'{}'::jsonb"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (FarmDepreciationSnapshot) TableName() string { + return "farm_depreciation_snapshots" +} diff --git a/internal/entities/house_depreciation_standard.go b/internal/entities/house_depreciation_standard.go new file mode 100644 index 00000000..9300c94b --- /dev/null +++ b/internal/entities/house_depreciation_standard.go @@ -0,0 +1,16 @@ +package entities + +import "time" + +type HouseDepreciationStandard struct { + Id uint `gorm:"primaryKey"` + HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"` + DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"` + DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (HouseDepreciationStandard) TableName() string { + return "house_depreciation_standards" +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 47daf0bf..67ab7678 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -10,6 +10,7 @@ type Kandang struct { Id uint `gorm:"primaryKey"` Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` Status string `gorm:"type:varchar(50);not null"` + HouseType *string `gorm:"type:house_type_enum"` LocationId uint `gorm:"not null"` KandangGroupId uint `gorm:"not null"` Capacity float64 `gorm:"not null"` diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index fa8374ba..f9d23d3e 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -47,13 +47,14 @@ const ( P_ApprovalGetAll = "lti.approval.list" ) const ( - P_ReportExpenseGetAll = "lti.repport.expense.list" - P_ReportDeliveryGetAll = "lti.repport.delivery.list" - P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" - P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" - P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" - P_ReportProductionResultGetAll = "lti.repport.production_result.list" - P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list" + P_ReportExpenseGetAll = "lti.repport.expense.list" + P_ReportExpenseDepreciationManage = "lti.repport.expense.depreciation.manage" + P_ReportDeliveryGetAll = "lti.repport.delivery.list" + P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" + P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" + P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" + P_ReportProductionResultGetAll = "lti.repport.production_result.list" + P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list" ) const ( diff --git a/internal/modules/dashboards/module.go b/internal/modules/dashboards/module.go index d7d0d477..622222c5 100644 --- a/internal/modules/dashboards/module.go +++ b/internal/modules/dashboards/module.go @@ -18,11 +18,11 @@ type DashboardModule struct{} func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { dashboardRepo := rDashboard.NewDashboardRepository(db) - hppCostRepo := commonRepo.NewHppCostRepository(db) + hppV2CostRepo := commonRepo.NewHppV2CostRepository(db) userRepo := rUser.NewUserRepository(db) - hppSvc := commonService.NewHppService(hppCostRepo) - dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc) + hppV2Svc := commonService.NewHppV2Service(hppV2CostRepo) + dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppV2Svc) userService := sUser.NewUserService(userRepo, validate) DashboardRoutes(router, userService, dashboardService) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 275b53f3..3811917d 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -30,10 +30,10 @@ type dashboardService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.DashboardRepository - HppSvc commonService.HppService + HppSvc commonService.HppV2Service } -func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppService) DashboardService { +func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppV2Service) DashboardService { return &dashboardService{ Log: utils.Log, Validate: validate, diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 5e6fc420..57593e59 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -358,6 +359,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen if err != nil { return nil, err } + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, uint(expense.Id), expenseDate, nil) return responseDTO, nil } @@ -385,6 +387,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } updateBody := make(map[string]any) + var requestedTransactionDate *time.Time if req.TransactionDate != nil { expenseDate, err := utils.ParseDateString(*req.TransactionDate) @@ -392,6 +395,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") } updateBody["transaction_date"] = expenseDate + requestedTransactionDate = &expenseDate } if req.Category != nil { @@ -429,6 +433,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return responseDTO, nil } + var invalidationFromDate time.Time + var invalidationFarmIDs []uint err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { expenseRepoTx := repository.NewExpenseRepository(tx) @@ -446,6 +452,16 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { return err } + oldFarmIDs, resolveOldFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id) + if resolveOldFarmErr != nil { + s.Log.Warnf("Failed to resolve old expense farm ids for invalidation (expense_id=%d): %+v", id, resolveOldFarmErr) + } + invalidationFarmIDs = append(invalidationFarmIDs, oldFarmIDs...) + + invalidationFromDate = currentExpense.TransactionDate + if requestedTransactionDate != nil { + invalidationFromDate = commonSvc.MinNonZeroDateOnlyUTC(currentExpense.TransactionDate, *requestedTransactionDate) + } categoryChanged := false var newCategory string if req.Category != nil && *req.Category != currentExpense.Category { @@ -631,6 +647,12 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } + newFarmIDs, resolveNewFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id) + if resolveNewFarmErr != nil { + s.Log.Warnf("Failed to resolve new expense farm ids for invalidation (expense_id=%d): %+v", id, resolveNewFarmErr) + } + invalidationFarmIDs = append(invalidationFarmIDs, newFarmIDs...) + return nil }) @@ -645,6 +667,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err != nil { return nil, err } + s.invalidateDepreciationSnapshots(c.Context(), nil, invalidationFarmIDs, invalidationFromDate) return responseDTO, nil } @@ -671,6 +694,10 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error { if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return err } + farmIDs, resolveFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), s.Repository.DB(), idUint) + if resolveFarmErr != nil { + s.Log.Warnf("Failed to resolve expense farm ids before delete (expense_id=%d): %+v", idUint, resolveFarmErr) + } if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Expense not found for ID %d: %+v", id, err) @@ -680,6 +707,8 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error { return err } s.Log.Infof("Successfully deleted expense with ID %d", id) + invalidationFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) + s.invalidateDepreciationSnapshots(c.Context(), nil, farmIDs, invalidationFromDate) return nil } @@ -800,6 +829,8 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va if err != nil { return nil, err } + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate) + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) return responseDTO, nil } @@ -857,6 +888,13 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( if err != nil { return nil, err } + expense, expenseErr := s.Repository.GetByID(c.Context(), id, nil) + if expenseErr != nil { + s.Log.Warnf("Failed to load expense for depreciation invalidation after complete (expense_id=%d): %+v", id, expenseErr) + } else { + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, id, invalidateFromDate, nil) + } return responseDTO, nil } @@ -884,6 +922,12 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return nil, err } + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) + if req.RealizationDate != nil { + if parsedDate, parseErr := utils.ParseDateString(*req.RealizationDate); parseErr == nil { + invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, parsedDate) + } + } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") @@ -996,6 +1040,7 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va if err != nil { return nil, err } + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) return responseDTO, nil } @@ -1057,6 +1102,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } var results []expenseDto.ExpenseDetailDTO + invalidateFromDateByExpenseID := make(map[uint]time.Time) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { @@ -1069,6 +1115,17 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, ); err != nil { return err } + expenseForInvalidation, err := expenseRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense") + } + invalidateFromDateByExpenseID[id] = commonSvc.MinNonZeroDateOnlyUTC( + expenseForInvalidation.TransactionDate, + expenseForInvalidation.RealizationDate, + ) latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -1170,10 +1227,73 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed approve expenses") } + for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID { + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) + } return results, nil } +func (s *expenseService) invalidateDepreciationSnapshotsByExpense( + ctx context.Context, + tx *gorm.DB, + expenseID uint, + fromDate time.Time, + fallbackFarmIDs []uint, +) { + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + + farmIDs := append([]uint{}, fallbackFarmIDs...) + if expenseID != 0 { + resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByExpenseID(ctx, targetDB, expenseID) + if err != nil { + s.Log.Warnf("Failed to resolve expense farm ids for invalidation (expense_id=%d): %+v", expenseID, err) + } else { + farmIDs = append(farmIDs, resolvedFarmIDs...) + } + } + s.invalidateDepreciationSnapshots(ctx, tx, farmIDs, fromDate) +} + +func (s *expenseService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + farmIDs []uint, + fromDate time.Time, +) { + if fromDate.IsZero() { + return + } + + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + farmIDs = utils.UniqueUintSlice(farmIDs) + if len(farmIDs) == 0 { + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots globally (from=%s): %+v", + fromDate.Format("2006-01-02"), + err, + ) + } + return + } + + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", + farmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } +} + func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) { expenseRepoTx := repository.NewExpenseRepository(ctx) diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 4b7c1328..20719d55 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -5,7 +5,7 @@ type DeliveryProduct struct { Qty float64 `json:"qty" validate:"omitempty,gte=0"` UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"` - WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gt=0"` + WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gte=0"` TotalWeight *float64 `json:"total_weight" validate:"omitempty,gte=0"` TotalPrice *float64 `json:"total_price" validate:"omitempty,gte=0"` DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"` diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 1bade0a9..09c617f8 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -419,6 +419,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if len(result) == 0 { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load created chickins") } + invalidateFromDate := time.Time{} + for i := range result { + invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, result[i].ChickInDate) + } + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{req.ProjectFlockKandangId}, invalidateFromDate) return result, nil } @@ -462,6 +467,8 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err != nil { return nil, err } + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(chickin.ChickInDate, updated.ChickInDate) + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{updated.ProjectFlockKandangId}, invalidateFromDate) if updated.UsageQty > 0 { if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil { @@ -566,6 +573,7 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { consumeAllocAfter, traceAllocAfter, ) + s.invalidateDepreciationSnapshots(c.Context(), tx, []uint{lockedChickin.ProjectFlockKandangId}, lockedChickin.ChickInDate) return nil }) @@ -1160,6 +1168,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if action == entity.ApprovalActionApproved { step = utils.ChickinStepDisetujui } + invalidateFromByPFK := make(map[uint]time.Time, len(approvableIDs)) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil { @@ -1204,6 +1213,12 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get chickins for approval %d", approvableID)) } + for _, chickin := range chickins { + invalidateFromByPFK[approvableID] = commonSvc.MinNonZeroDateOnlyUTC( + invalidateFromByPFK[approvableID], + chickin.ChickInDate, + ) + } kandangForApproval, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) if err != nil { @@ -1281,6 +1296,12 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID)) } + for _, chickin := range chickins { + invalidateFromByPFK[approvableID] = commonSvc.MinNonZeroDateOnlyUTC( + invalidateFromByPFK[approvableID], + chickin.ChickInDate, + ) + } if len(chickins) == 0 { continue @@ -1328,6 +1349,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") } + for projectFlockKandangID, invalidateFromDate := range invalidateFromByPFK { + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{projectFlockKandangID}, invalidateFromDate) + } updated := make([]entity.ProjectChickin, 0) for _, kandangID := range approvableIDs { @@ -1837,6 +1861,57 @@ func normalizeDateOnlyUTC(value time.Time) time.Time { return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) } +func (s chickinService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + projectFlockKandangIDs []uint, + fromDate time.Time, +) { + if fromDate.IsZero() { + return + } + + projectFlockKandangIDs = uniqueUint(projectFlockKandangIDs) + if len(projectFlockKandangIDs) == 0 { + return + } + + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + + farmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, projectFlockKandangIDs) + if err != nil { + s.Log.Warnf( + "Failed to resolve farm ids for chickin depreciation invalidation (pfk_ids=%v): %+v", + projectFlockKandangIDs, + err, + ) + farmIDs = nil + } + + if len(farmIDs) == 0 { + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots globally (from=%s): %+v", + fromDate.Format("2006-01-02"), + err, + ) + } + return + } + + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", + farmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } +} + func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error { if productWarehouseID == 0 { return nil diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index d48d9990..d17259c0 100644 --- a/internal/modules/production/project-flock-kandangs/route.go +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -13,11 +13,11 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo ctrl := controller.NewProjectFlockKandangController(s) route := v1.Group("/project-flock-kandangs") - route.Use(m.Auth(u)) - route.Get("/",m.RequirePermissions(m.P_ProjectFlockKandangsGetAll), ctrl.GetAll) - route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) + // route.Use(m.Auth(u)) + route.Get("/", ctrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) // route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing) - route.Post("/:id/closing",m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing) + route.Post("/:id/closing", m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing) route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing) } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5c4d6a9c..d3221872 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -517,7 +517,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, transactionErr } - return s.GetOne(c, createdRecording.Id) + created, err := s.GetOne(c, createdRecording.Id) + if err != nil { + return nil, err + } + if created != nil { + s.invalidateDepreciationSnapshots(c.Context(), nil, created.ProjectFlockKandangId, created.RecordDatetime) + } + return created, nil } func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { @@ -848,6 +855,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := recordingutil.AttachProductionStandards(ctx, s.Repository.DB(), false, s.Log, updatedRecording); err != nil { return nil, err } + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(recordingEntity.RecordDatetime, updatedRecording.RecordDatetime) + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + updatedRecording.ProjectFlockKandangId, + invalidateFromDate, + ) return updatedRecording, nil } @@ -965,6 +979,12 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent if err != nil { return nil, err } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + recording.ProjectFlockKandangId, + recording.RecordDatetime, + ) updated = append(updated, *recording) } @@ -985,7 +1005,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { } note := recordingutil.RecordingNote("Delete", id) - return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err = s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { recording, err := s.Repository.WithTx(tx).GetByID(ctx, id, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -1029,9 +1049,60 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) return err } + s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) return nil }) + if err != nil { + return err + } + return nil +} + +func (s recordingService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + projectFlockKandangID uint, + fromDate time.Time, +) { + if projectFlockKandangID == 0 || fromDate.IsZero() { + return + } + + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + + farmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, []uint{projectFlockKandangID}) + if err != nil { + s.Log.Warnf( + "Failed to resolve farm for recording depreciation invalidation (pfk=%d): %+v", + projectFlockKandangID, + err, + ) + farmIDs = nil + } + + if len(farmIDs) == 0 { + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots globally (from=%s): %+v", + fromDate.Format("2006-01-02"), + err, + ) + } + return + } + + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", + farmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } } func (s *recordingService) resolveRecordingCategory(ctx context.Context, recording *entity.Recording) (string, error) { diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index ce267544..c1748cc8 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -377,6 +377,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) if err != nil { return nil, err } + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{req.TargetProjectFlockId}, transferDate) return laying_transfer, nil } @@ -588,6 +589,13 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, } layingTransfer, _, err := s.GetOne(c, id) + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(existingTransfer.TransferDate, transferDate) + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{existingTransfer.ToProjectFlockId, req.TargetProjectFlockId}, + invalidateFromDate, + ) return layingTransfer, err } @@ -661,6 +669,7 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to delete transferLaying: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") } + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{transfer.ToProjectFlockId}, transfer.TransferDate) return nil } @@ -798,6 +807,14 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( if err != nil { return nil, err } + if transfer != nil { + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{transfer.ToProjectFlockId}, + resolveDepreciationEffectiveDateForTransfer(transfer), + ) + } updated = append(updated, *transfer) } @@ -837,6 +854,14 @@ func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTra if err != nil { return nil, err } + if transfer != nil { + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{transfer.ToProjectFlockId}, + resolveDepreciationEffectiveDateForTransfer(transfer), + ) + } return transfer, nil } @@ -873,6 +898,14 @@ func (s transferLayingService) ExecuteWithBusinessDate(c *fiber.Ctx, id uint, bu if err != nil { return nil, err } + if transfer != nil { + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{transfer.ToProjectFlockId}, + resolveDepreciationEffectiveDateForTransfer(transfer), + ) + } return transfer, nil } @@ -1226,6 +1259,14 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT if err != nil { return nil, err } + if transfer != nil { + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{transfer.ToProjectFlockId}, + resolveDepreciationEffectiveDateForTransfer(transfer), + ) + } return transfer, nil } @@ -1678,6 +1719,43 @@ func normalizeDateOnlyUTC(value time.Time) time.Time { return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) } +func resolveDepreciationEffectiveDateForTransfer(transfer *entity.LayingTransfer) time.Time { + if transfer == nil { + return time.Time{} + } + if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { + return *transfer.EffectiveMoveDate + } + if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() { + return *transfer.EconomicCutoffDate + } + return transfer.TransferDate +} + +func (s transferLayingService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + farmIDs []uint, + fromDate time.Time, +) { + if fromDate.IsZero() { + return + } + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + uniqueFarmIDs := utils.UniqueUintSlice(farmIDs) + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, uniqueFarmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate farm depreciation snapshots (farms=%v, from=%s): %+v", + uniqueFarmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } +} + func isLegacyTransfer(transfer *entity.LayingTransfer) bool { if transfer == nil { return false diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 5324d60f..ec8617b8 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -675,6 +675,12 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase if err := s.attachLatestApproval(c.Context(), created); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err) } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(created), + resolvePurchaseDepreciationInvalidateDate(created, created.Items, now), + ) return created, nil } @@ -826,6 +832,12 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(updated), + resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()), + ) return updated, nil } @@ -934,6 +946,12 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(updated), + resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, now), + ) return updated, nil } @@ -1421,6 +1439,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } + invalidateFromDate := resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()) + if earliestReceived != nil { + invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, *earliestReceived) + } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(updated), + invalidateFromDate, + ) receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared)) for _, prep := range prepared { @@ -1628,6 +1656,12 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } + s.invalidateDepreciationSnapshots( + ctx, + nil, + collectPFKIDsFromPurchaseItems(itemsToDelete), + resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()), + ) return updated, nil } @@ -1721,6 +1755,12 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { return utils.Internal("Failed to sync expense") } } + s.invalidateDepreciationSnapshots( + ctx, + nil, + collectPFKIDsFromPurchaseItems(itemsToDelete), + resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()), + ) return nil } @@ -2391,7 +2431,17 @@ func (s *purchaseService) rejectAndReload( if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil { return nil, err } - return s.loadPurchase(c.Context(), purchaseID) + updated, err := s.loadPurchase(c.Context(), purchaseID) + if err != nil { + return nil, err + } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(updated), + resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()), + ) + return updated, nil } func (s *purchaseService) loadPurchase( ctx context.Context, @@ -2522,10 +2572,17 @@ func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Contex } func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { + if p == nil { + return nil + } + return collectPFKIDsFromPurchaseItems(p.Items) +} + +func collectPFKIDsFromPurchaseItems(items []entity.PurchaseItem) []uint { seen := make(map[uint]struct{}) ids := make([]uint, 0) - for _, item := range p.Items { + for _, item := range items { if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 { continue } @@ -2538,6 +2595,82 @@ func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { } return ids } + +func resolvePurchaseDepreciationInvalidateDate( + purchase *entity.Purchase, + items []entity.PurchaseItem, + fallback time.Time, +) time.Time { + fromDate := time.Time{} + if purchase != nil { + fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, purchase.CreatedAt) + if purchase.PoDate != nil { + fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, *purchase.PoDate) + } + } + for _, item := range items { + if item.ReceivedDate == nil { + continue + } + fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, *item.ReceivedDate) + } + if fromDate.IsZero() { + fromDate = fallback + } + return fromDate +} + +func (s *purchaseService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + projectFlockKandangIDs []uint, + fromDate time.Time, +) { + if fromDate.IsZero() { + return + } + projectFlockKandangIDs = utils.UniqueUintSlice(projectFlockKandangIDs) + + targetDB := s.PurchaseRepo.DB() + if tx != nil { + targetDB = tx + } + + var farmIDs []uint + if len(projectFlockKandangIDs) > 0 { + resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, projectFlockKandangIDs) + if err != nil { + s.Log.Warnf( + "Failed to resolve farm ids for purchase depreciation invalidation (pfk_ids=%v): %+v", + projectFlockKandangIDs, + err, + ) + } else { + farmIDs = resolvedFarmIDs + } + } + + if len(farmIDs) == 0 { + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots globally (from=%s): %+v", + fromDate.Format("2006-01-02"), + err, + ) + } + return + } + + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", + farmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } +} + func (s *purchaseService) ensureProjectFlockNotClosedForPurchase( ctx context.Context, purchase *entity.Purchase, diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 5d85a53e..691cafc0 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -90,6 +90,75 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { }) } +func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error { + rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx) + if err != nil { + return err + } + + resp := struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta dto.ExpenseDepreciationMetaDTO `json:"meta"` + Data []dto.ExpenseDepreciationRowDTO `json:"data"` + }{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expense depreciation report successfully", + Meta: *meta, + Data: rows, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} + +func (c *RepportController) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) error { + rows, meta, err := c.RepportService.GetExpenseDepreciationManualInputs(ctx) + if err != nil { + return err + } + + resp := struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta dto.ExpenseDepreciationMetaDTO `json:"meta"` + Data []dto.ExpenseDepreciationManualInputRowDTO `json:"data"` + }{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expense depreciation manual inputs successfully", + Meta: *meta, + Data: rows, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} + +func (c *RepportController) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx) error { + req := new(validation.ExpenseDepreciationManualInputUpsert) + if err := ctx.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if err := m.EnsureProjectFlockAccess(ctx, c.RepportService.DB(), req.ProjectFlockID); err != nil { + return err + } + + result, err := c.RepportService.UpsertExpenseDepreciationManualInput(ctx, req) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Upsert expense depreciation manual input successfully", + Data: result, + }) +} + func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { query := &validation.MarketingQuery{ Page: ctx.QueryInt("page", 1), @@ -429,6 +498,29 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { }) } +func (c *RepportController) GetHppV2Breakdown(ctx *fiber.Ctx) error { + query := &validation.HppV2BreakdownQuery{ + ProjectFlockKandangID: uint(ctx.QueryInt("project_flock_kandang_id", 0)), + Period: ctx.Query("period", ""), + } + + if err := m.EnsureProjectFlockKandangAccess(ctx, c.RepportService.DB(), 0, query.ProjectFlockKandangID); err != nil { + return err + } + + data, err := c.RepportService.GetHppV2Breakdown(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get HPP v2 breakdown successfully", + Data: data, + }) +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return parseCommaSeparatedInt64sWithField(raw, "supplier_ids") } diff --git a/internal/modules/repports/dto/repportExpenseDepreciation.dto.go b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go new file mode 100644 index 00000000..e7e3f4fd --- /dev/null +++ b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go @@ -0,0 +1,44 @@ +package dto + +type ExpenseDepreciationFiltersDTO struct { + AreaID string `json:"area_id"` + LocationID string `json:"location_id"` + ProjectFlockID string `json:"project_flock_id"` + Period string `json:"period"` +} + +type ExpenseDepreciationMetaDTO struct { + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int64 `json:"total_pages"` + TotalResults int64 `json:"total_results"` + Filters ExpenseDepreciationFiltersDTO `json:"filters"` +} + +type ExpenseDepreciationRowDTO struct { + ProjectFlockID int64 `json:"project_flock_id"` + FarmName string `json:"farm_name"` + Period string `json:"period"` + DepreciationPercentEffective float64 `json:"depreciation_percent_effective"` + DepreciationValue float64 `json:"depreciation_value"` + PulletCostDayNTotal float64 `json:"pullet_cost_day_n_total"` + Components any `json:"components"` +} + +type ExpenseDepreciationManualInputRowDTO struct { + ID int64 `json:"id"` + ProjectFlockID int64 `json:"project_flock_id"` + FarmName string `json:"farm_name"` + TotalCost float64 `json:"total_cost"` + CutoverDate string `json:"cutover_date"` + Note *string `json:"note"` +} + +func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO { + return ExpenseDepreciationFiltersDTO{ + AreaID: area, + LocationID: location, + ProjectFlockID: projectFlockID, + Period: period, + } +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 9a64b806..110bbc93 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -33,9 +33,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * recordingRepository := recordingRepo.NewRecordingRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db) hppCostRepository := commonRepo.NewHppCostRepository(db) + hppV2CostRepository := commonRepo.NewHppV2CostRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) + expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) customerRepository := customerRepo.NewCustomerRepository(db) @@ -45,7 +47,29 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalSvc := approvalService.NewApprovalService(approvalRepository) hppSvc := approvalService.NewHppService(hppCostRepository) - repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, hppSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository) + hppV2Svc := approvalService.NewHppV2Service(hppV2CostRepository) + repportService := sRepport.NewRepportService( + db, + validate, + expenseRealizationRepository, + expenseDepreciationRepository, + marketingDeliveryProductRepository, + purchaseRepository, + chickinRepository, + recordingRepository, + approvalSvc, + hppSvc, + hppV2Svc, + hppCostRepository, + purchaseSupplierRepository, + debtSupplierRepository, + hppPerKandangRepository, + productionResultRepository, + customerPaymentRepository, + customerRepository, + standardGrowthDetailRepository, + productionStandardDetailRepository, + ) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/expense_depreciation.repository.go b/internal/modules/repports/repositories/expense_depreciation.repository.go new file mode 100644 index 00000000..7e058a0b --- /dev/null +++ b/internal/modules/repports/repositories/expense_depreciation.repository.go @@ -0,0 +1,329 @@ +package repositories + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type FarmDepreciationCandidateRow struct { + ProjectFlockID uint + FarmName string +} + +type FarmDepreciationLatestTransferRow struct { + ProjectFlockID uint + FarmName string + ProjectFlockKandangID uint + KandangID uint + KandangName string + HouseType *string + SourceProjectFlockID uint + TransferDate time.Time + TransferQty float64 + TransferID uint +} + +type FarmDepreciationManualInputRow struct { + Id uint + ProjectFlockID uint + FarmName string + TotalCost float64 + CutoverDate time.Time + Note *string +} + +type houseDepreciationPercentRow struct { + HouseType string + Day int + DepreciationPercent float64 +} + +type ExpenseDepreciationRepository interface { + GetCandidateFarms(ctx context.Context, period time.Time, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationCandidateRow, error) + GetSnapshotsByPeriodAndFarmIDs(ctx context.Context, period time.Time, farmIDs []uint) ([]entity.FarmDepreciationSnapshot, error) + UpsertSnapshots(ctx context.Context, rows []entity.FarmDepreciationSnapshot) error + DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error + GetLatestTransferInputsByFarms(ctx context.Context, period time.Time, farmIDs []uint) ([]FarmDepreciationLatestTransferRow, error) + GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) + GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error) + UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error + DB() *gorm.DB +} + +type expenseDepreciationRepository struct { + db *gorm.DB +} + +func NewExpenseDepreciationRepository(db *gorm.DB) ExpenseDepreciationRepository { + return &expenseDepreciationRepository{db: db} +} + +func (r *expenseDepreciationRepository) DB() *gorm.DB { + return r.db +} + +func (r *expenseDepreciationRepository) GetCandidateFarms( + ctx context.Context, + period time.Time, + areaIDs, locationIDs, projectFlockIDs []int64, +) ([]FarmDepreciationCandidateRow, error) { + rows := make([]FarmDepreciationCandidateRow, 0) + + query := r.db.WithContext(ctx). + Table("project_flocks AS pf"). + Select("DISTINCT pf.id AS project_flock_id, pf.flock_name AS farm_name"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Where("pf.deleted_at IS NULL"). + Where("pf.category = ?", utils.ProjectFlockCategoryLaying). + Where("(pfk.closed_at IS NULL OR DATE(pfk.closed_at) >= DATE(?))", period) + + if len(areaIDs) > 0 { + query = query.Where("pf.area_id IN ?", areaIDs) + } + if len(locationIDs) > 0 { + query = query.Where("pf.location_id IN ?", locationIDs) + } + if len(projectFlockIDs) > 0 { + query = query.Where("pf.id IN ?", projectFlockIDs) + } + + if err := query.Order("pf.id ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *expenseDepreciationRepository) GetSnapshotsByPeriodAndFarmIDs( + ctx context.Context, + period time.Time, + farmIDs []uint, +) ([]entity.FarmDepreciationSnapshot, error) { + if len(farmIDs) == 0 { + return []entity.FarmDepreciationSnapshot{}, nil + } + + rows := make([]entity.FarmDepreciationSnapshot, 0) + if err := r.db.WithContext(ctx). + Where("project_flock_id IN ?", farmIDs). + Where("period_date = DATE(?)", period). + Find(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *expenseDepreciationRepository) UpsertSnapshots(ctx context.Context, rows []entity.FarmDepreciationSnapshot) error { + if len(rows) == 0 { + return nil + } + + return r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "project_flock_id"}, + {Name: "period_date"}, + }, + DoUpdates: clause.AssignmentColumns([]string{ + "depreciation_percent_effective", + "depreciation_value", + "pullet_cost_day_n_total", + "components", + "updated_at", + }), + }). + Create(&rows).Error +} + +func (r *expenseDepreciationRepository) DeleteSnapshotsFromDate( + ctx context.Context, + fromDate time.Time, + farmIDs []uint, +) error { + if fromDate.IsZero() { + return nil + } + + query := r.db.WithContext(ctx). + Table("farm_depreciation_snapshots"). + Where("period_date >= DATE(?)", fromDate) + if len(farmIDs) > 0 { + query = query.Where("project_flock_id IN ?", farmIDs) + } + return query.Delete(nil).Error +} + +func (r *expenseDepreciationRepository) GetLatestTransferInputsByFarms( + ctx context.Context, + period time.Time, + farmIDs []uint, +) ([]FarmDepreciationLatestTransferRow, error) { + if len(farmIDs) == 0 { + return []FarmDepreciationLatestTransferRow{}, nil + } + + rows := make([]FarmDepreciationLatestTransferRow, 0) + query := ` +WITH latest_transfer_approval AS ( + SELECT a.approvable_id, a.action + FROM approvals a + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = @approval_type + GROUP BY approvable_id + ) la + ON la.approvable_id = a.approvable_id + AND la.latest_action_at = a.action_at + WHERE a.approvable_type = @approval_type +), +approved_transfers AS ( + SELECT + lt.id, + lt.from_project_flock_id, + lt.to_project_flock_id, + COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) AS effective_date + FROM laying_transfers lt + JOIN latest_transfer_approval lta ON lta.approvable_id = lt.id + WHERE lt.deleted_at IS NULL + AND lt.executed_at IS NOT NULL + AND lta.action = 'APPROVED' +) +SELECT DISTINCT ON (ltt.target_project_flock_kandang_id) + pf.id AS project_flock_id, + pf.flock_name AS farm_name, + pfk.id AS project_flock_kandang_id, + k.id AS kandang_id, + k.name AS kandang_name, + k.house_type::text AS house_type, + at.from_project_flock_id AS source_project_flock_id, + at.effective_date AS transfer_date, + ltt.total_qty AS transfer_qty, + at.id AS transfer_id +FROM laying_transfer_targets ltt +JOIN approved_transfers at ON at.id = ltt.laying_transfer_id +JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id +JOIN project_flocks pf ON pf.id = pfk.project_flock_id +JOIN kandangs k ON k.id = pfk.kandang_id +WHERE ltt.deleted_at IS NULL + AND pf.id IN @farm_ids + AND at.effective_date <= DATE(@period_date) +ORDER BY ltt.target_project_flock_kandang_id, at.effective_date DESC, at.id DESC +` + + if err := r.db.WithContext(ctx).Raw(query, map[string]any{ + "approval_type": utils.ApprovalWorkflowTransferToLaying.String(), + "farm_ids": farmIDs, + "period_date": period, + }).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *expenseDepreciationRepository) GetDepreciationPercents( + ctx context.Context, + houseTypes []string, + maxDay int, +) (map[string]map[int]float64, error) { + result := make(map[string]map[int]float64) + if len(houseTypes) == 0 || maxDay <= 0 { + return result, nil + } + + rows := make([]houseDepreciationPercentRow, 0) + if err := r.db.WithContext(ctx). + Table("house_depreciation_standards"). + Select("house_type::text AS house_type, day, depreciation_percent"). + Where("house_type::text IN ?", houseTypes). + Where("day <= ?", maxDay). + Order("house_type ASC, day ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if _, exists := result[row.HouseType]; !exists { + result[row.HouseType] = make(map[int]float64) + } + result[row.HouseType][row.Day] = row.DepreciationPercent + } + + return result, nil +} + +func (r *expenseDepreciationRepository) GetLatestManualInputsByFarms( + ctx context.Context, + areaIDs, locationIDs, projectFlockIDs []int64, +) ([]FarmDepreciationManualInputRow, error) { + rows := make([]FarmDepreciationManualInputRow, 0) + + query := r.db.WithContext(ctx). + Table("farm_depreciation_manual_inputs AS fdmi"). + Select(` + fdmi.id AS id, + fdmi.project_flock_id AS project_flock_id, + pf.flock_name AS farm_name, + fdmi.total_cost AS total_cost, + fdmi.cutover_date AS cutover_date, + fdmi.note AS note + `). + Joins("JOIN project_flocks AS pf ON pf.id = fdmi.project_flock_id"). + Where("pf.deleted_at IS NULL"). + Where("pf.category = ?", utils.ProjectFlockCategoryLaying) + + if len(areaIDs) > 0 { + query = query.Where("pf.area_id IN ?", areaIDs) + } + if len(locationIDs) > 0 { + query = query.Where("pf.location_id IN ?", locationIDs) + } + if len(projectFlockIDs) > 0 { + query = query.Where("pf.id IN ?", projectFlockIDs) + } + + if err := query. + Order("pf.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *expenseDepreciationRepository) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error { + if row == nil { + return nil + } + + now := time.Now().UTC() + err := r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "project_flock_id"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "total_cost": row.TotalCost, + "cutover_date": row.CutoverDate, + "note": row.Note, + "updated_at": now, + }), + }). + Create(row).Error + if err != nil { + return err + } + + return r.db.WithContext(ctx). + Table("farm_depreciation_manual_inputs"). + Select("id, project_flock_id, total_cost, cutover_date, note"). + Where("project_flock_id = ?", row.ProjectFlockId). + Take(row).Error +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 2f5eceec..16c14de5 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -16,10 +16,14 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Use(m.Auth(u)) route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) + route.Get("/expense/depreciation", ctrl.GetExpenseDepreciation) + route.Get("/expense/depreciation/manual-inputs", ctrl.GetExpenseDepreciationManualInputs) + route.Put("/expense/depreciation/manual-inputs", ctrl.UpsertExpenseDepreciationManualInput) route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) + route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) } diff --git a/internal/modules/repports/services/repport.expense_depreciation_test.go b/internal/modules/repports/services/repport.expense_depreciation_test.go new file mode 100644 index 00000000..820fbaa6 --- /dev/null +++ b/internal/modules/repports/services/repport.expense_depreciation_test.go @@ -0,0 +1,445 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + dto "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" + repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gorm.io/gorm" +) + +type expenseDepreciationRepoMock struct { + repportRepo.ExpenseDepreciationRepository + manualInputs []repportRepo.FarmDepreciationManualInputRow + + upsertedRow *entity.FarmDepreciationManualInput + deleteCalled bool + deleteDate time.Time + deleteFarmIDs []uint +} + +func (m *expenseDepreciationRepoMock) DB() *gorm.DB { + return nil +} + +func (m *expenseDepreciationRepoMock) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error { + if row == nil { + return nil + } + cloned := *row + if cloned.Id == 0 { + cloned.Id = 123 + } + m.upsertedRow = &cloned + row.Id = cloned.Id + return nil +} + +func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { + m.deleteCalled = true + m.deleteDate = fromDate + m.deleteFarmIDs = append([]uint{}, farmIDs...) + return nil +} + +func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Context, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationManualInputRow, error) { + return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil +} + +type hppCostRepoMock struct { + commonRepo.HppCostRepository + kandangIDsByFarm map[uint][]uint +} + +func (m *hppCostRepoMock) GetProjectFlockKandangIDs(_ context.Context, projectFlockID uint) ([]uint, error) { + return append([]uint{}, m.kandangIDsByFarm[projectFlockID]...), nil +} + +type hppV2ServiceMock struct { + approvalService.HppV2Service + breakdownByPFK map[uint]*approvalService.HppV2Breakdown +} + +func (m *hppV2ServiceMock) CalculateHppBreakdown(projectFlockKandangId uint, _ *time.Time) (*approvalService.HppV2Breakdown, error) { + return m.breakdownByPFK[projectFlockKandangId], nil +} + +func TestComputeExpenseDepreciationSnapshots_FromHppV2NormalTransfer(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + svc := &repportService{ + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 1: {10}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 10: { + ProjectFlockKandangID: 10, + KandangID: 100, + KandangName: "Kandang A", + HouseType: "close_house", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Title: "Depreciation", + Total: 100, + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "normal_transfer", + Total: 100, + Details: map[string]any{ + "schedule_day": 2, + "depreciation_percent": 10.0, + "pullet_cost_day_n": 1000.0, + "source_project_flock_id": 77, + "origin_date": "2026-01-01", + }, + References: []approvalService.HppV2Reference{ + { + Type: "laying_transfer", + ID: 701, + Date: "2026-05-20", + Qty: 150, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{1}, map[uint]string{1: "Farm A"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].DepreciationValue != 100 { + t.Fatalf("expected depreciation value 100, got %v", rows[0].DepreciationValue) + } + if rows[0].PulletCostDayNTotal != 1000 { + t.Fatalf("expected pullet cost day n 1000, got %v", rows[0].PulletCostDayNTotal) + } + assertFloatEqual(t, rows[0].DepreciationPercentEffective, 10) + + components := decodeDepreciationComponents(t, rows[0].Components) + if components.KandangCount != 1 { + t.Fatalf("expected kandang_count 1, got %d", components.KandangCount) + } + entry := components.Kandang[0] + if entry.ProjectFlockKandangID != 10 || entry.KandangID != 100 || entry.KandangName != "Kandang A" { + t.Fatalf("unexpected kandang identity: %+v", entry) + } + if entry.TransferID != 701 || entry.TransferDate != "2026-05-20" || entry.TransferQty != 150 { + t.Fatalf("unexpected transfer metadata: %+v", entry) + } + if entry.DepreciationSource != "normal_transfer" { + t.Fatalf("expected depreciation_source normal_transfer, got %q", entry.DepreciationSource) + } + if entry.ManualInputID != nil || entry.CutoverDate != "" || entry.StartScheduleDay != nil { + t.Fatalf("expected manual fields empty for normal transfer, got %+v", entry) + } +} + +func TestComputeExpenseDepreciationSnapshots_FromHppV2ManualCutover(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + svc := &repportService{ + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 2: {20}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 20: { + ProjectFlockKandangID: 20, + KandangID: 200, + KandangName: "Kandang B", + HouseType: "open_house", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Title: "Depreciation", + Total: 200, + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "manual_cutover", + Total: 200, + Details: map[string]any{ + "schedule_day": 2, + "start_schedule_day": 2, + "depreciation_percent": 25.0, + "pullet_cost_day_n": 800.0, + "manual_input_id": 901, + "cutover_date": "2026-06-01", + "origin_date": "2026-01-01", + }, + References: []approvalService.HppV2Reference{ + { + Type: "farm_depreciation_manual_input", + ID: 901, + Date: "2026-06-01", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{2}, map[uint]string{2: "Farm B"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + assertFloatEqual(t, rows[0].DepreciationPercentEffective, 25) + + components := decodeDepreciationComponents(t, rows[0].Components) + if components.KandangCount != 1 { + t.Fatalf("expected kandang_count 1, got %d", components.KandangCount) + } + entry := components.Kandang[0] + if entry.DepreciationSource != "manual_cutover" { + t.Fatalf("expected depreciation_source manual_cutover, got %q", entry.DepreciationSource) + } + if entry.TransferID != 0 || entry.TransferDate != "" || entry.TransferQty != 0 { + t.Fatalf("expected transfer fields empty for manual path, got %+v", entry) + } + if entry.ManualInputID == nil || *entry.ManualInputID != 901 { + t.Fatalf("expected manual_input_id 901, got %+v", entry.ManualInputID) + } + if entry.CutoverDate != "2026-06-01" || entry.OriginDate != "2026-01-01" { + t.Fatalf("unexpected manual date fields: %+v", entry) + } + if entry.StartScheduleDay == nil || *entry.StartScheduleDay != 2 { + t.Fatalf("expected start_schedule_day 2, got %+v", entry.StartScheduleDay) + } +} + +func TestComputeExpenseDepreciationSnapshots_AggregatesMultipleKandang(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + svc := &repportService{ + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 3: {30, 31}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 30: { + ProjectFlockKandangID: 30, + KandangID: 300, + KandangName: "Kandang C1", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "normal_transfer", + Total: 50, + Details: map[string]any{ + "schedule_day": 1, + "depreciation_percent": 10.0, + "pullet_cost_day_n": 500.0, + }, + }, + }, + }, + }, + }, + 31: { + ProjectFlockKandangID: 31, + KandangID: 301, + KandangName: "Kandang C2", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "normal_transfer", + Total: 100, + Details: map[string]any{ + "schedule_day": 2, + "depreciation_percent": 10.0, + "pullet_cost_day_n": 1000.0, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{3}, map[uint]string{3: "Farm C"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].DepreciationValue != 150 { + t.Fatalf("expected depreciation value 150, got %v", rows[0].DepreciationValue) + } + if rows[0].PulletCostDayNTotal != 1500 { + t.Fatalf("expected pullet cost day n 1500, got %v", rows[0].PulletCostDayNTotal) + } + assertFloatEqual(t, rows[0].DepreciationPercentEffective, 10) + + components := decodeDepreciationComponents(t, rows[0].Components) + if components.KandangCount != 2 { + t.Fatalf("expected kandang_count 2, got %d", components.KandangCount) + } +} + +func TestComputeExpenseDepreciationSnapshots_ZeroWhenDepreciationMissing(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + svc := &repportService{ + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 4: {40}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 40: { + ProjectFlockKandangID: 40, + KandangID: 400, + KandangName: "Kandang D", + Components: []approvalService.HppV2Component{ + {Code: "PAKAN", Total: 123}, + }, + }, + }, + }, + } + + rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{4}, map[uint]string{4: "Farm D"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].DepreciationValue != 0 || rows[0].PulletCostDayNTotal != 0 || rows[0].DepreciationPercentEffective != 0 { + t.Fatalf("expected zero snapshot values, got %+v", rows[0]) + } + components := decodeDepreciationComponents(t, rows[0].Components) + if components.KandangCount != 0 || len(components.Kandang) != 0 { + t.Fatalf("expected empty components, got %+v", components) + } +} + +func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDate(t *testing.T) { + repo := &expenseDepreciationRepoMock{ + manualInputs: []repportRepo.FarmDepreciationManualInputRow{ + { + Id: 123, + ProjectFlockID: 99, + FarmName: "Farm Z", + TotalCost: 1000, + CutoverDate: mustJakartaDate(t, "2026-06-01"), + }, + }, + } + + svc := &repportService{ + Validate: validator.New(), + ExpenseDepreciationRepo: repo, + } + + reqPayload := &validation.ExpenseDepreciationManualInputUpsert{ + ProjectFlockID: 99, + TotalCost: 1000, + CutoverDate: "2026-06-01", + } + + app := fiber.New() + var response *dto.ExpenseDepreciationManualInputRowDTO + app.Put("/", func(c *fiber.Ctx) error { + result, err := svc.UpsertExpenseDepreciationManualInput(c, reqPayload) + if err != nil { + return err + } + response = result + return c.SendStatus(fiber.StatusOK) + }) + + httpResp, err := app.Test(httptest.NewRequest(http.MethodPut, "/", nil)) + if err != nil { + t.Fatalf("expected no app error, got %v", err) + } + if httpResp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status %d, got %d", fiber.StatusOK, httpResp.StatusCode) + } + if !repo.deleteCalled { + t.Fatal("expected DeleteSnapshotsFromDate to be called") + } + if len(repo.deleteFarmIDs) != 1 || repo.deleteFarmIDs[0] != 99 { + t.Fatalf("expected delete farm ids [99], got %v", repo.deleteFarmIDs) + } + if repo.deleteDate.Format("2006-01-02") != "2026-06-01" { + t.Fatalf("expected delete date 2026-06-01, got %s", repo.deleteDate.Format("2006-01-02")) + } + if response == nil { + t.Fatal("expected response") + } + if response.FarmName != "Farm Z" { + t.Fatalf("expected farm name Farm Z, got %s", response.FarmName) + } +} + +func decodeDepreciationComponents(t *testing.T, raw []byte) depreciationFarmComponents { + t.Helper() + var out depreciationFarmComponents + if len(raw) == 0 { + return out + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("failed to decode components: %v", err) + } + return out +} + +func mustJakartaDate(t *testing.T, raw string) time.Time { + t.Helper() + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading timezone: %v", err) + } + value, err := time.ParseInLocation("2006-01-02", raw, location) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + return value +} + +func assertFloatEqual(t *testing.T, got float64, want float64) { + t.Helper() + const epsilon = 0.000001 + if got > want+epsilon || got < want-epsilon { + t.Fatalf("expected %.6f, got %.6f", want, got) + } +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index b866cf96..572a2317 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -42,10 +42,14 @@ import ( type RepportService interface { GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) + GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) + GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) + UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error) GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) + GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) DB() *gorm.DB @@ -56,12 +60,15 @@ type repportService struct { Validate *validator.Validate db *gorm.DB ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository + ExpenseDepreciationRepo repportRepo.ExpenseDepreciationRepository MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository PurchaseRepo purchaseRepo.PurchaseRepository ChickinRepo chickinRepo.ProjectChickinRepository RecordingRepo recordingRepo.RecordingRepository ApprovalSvc approvalService.ApprovalService HppSvc approvalService.HppService + HppV2Svc approvalService.HppV2Service + HppCostRepo commonRepo.HppCostRepository PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository @@ -85,12 +92,15 @@ func NewRepportService( db *gorm.DB, validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, + expenseDepreciationRepo repportRepo.ExpenseDepreciationRepository, marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, purchaseRepo purchaseRepo.PurchaseRepository, chickinRepo chickinRepo.ProjectChickinRepository, recordingRepo recordingRepo.RecordingRepository, approvalSvc approvalService.ApprovalService, hppSvc approvalService.HppService, + hppV2Svc approvalService.HppV2Service, + hppCostRepo commonRepo.HppCostRepository, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, @@ -105,12 +115,15 @@ func NewRepportService( Validate: validate, db: db, ExpenseRealizationRepo: expenseRealizationRepo, + ExpenseDepreciationRepo: expenseDepreciationRepo, MarketingDeliveryRepo: marketingDeliveryRepo, PurchaseRepo: purchaseRepo, ChickinRepo: chickinRepo, RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, HppSvc: hppSvc, + HppV2Svc: hppV2Svc, + HppCostRepo: hppCostRepo, PurchaseSupplierRepo: purchaseSupplierRepo, DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, @@ -164,6 +177,540 @@ func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuer return result, total, nil } +func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) { + params, filters, err := s.parseExpenseDepreciationQuery(ctx) + if err != nil { + return nil, nil, err + } + if err := s.Validate.Struct(params); err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if s.ExpenseDepreciationRepo == nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD") + } + + candidateRows, err := s.ExpenseDepreciationRepo.GetCandidateFarms( + ctx.Context(), + periodDate, + params.AreaIDs, + params.LocationIDs, + params.ProjectFlockIDs, + ) + if err != nil { + return nil, nil, err + } + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + if len(candidateRows) == 0 { + meta := &dto.ExpenseDepreciationMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: 1, + TotalResults: 0, + Filters: filters, + } + return []dto.ExpenseDepreciationRowDTO{}, meta, nil + } + + farmIDs := make([]uint, 0, len(candidateRows)) + farmNameByID := make(map[uint]string, len(candidateRows)) + for _, row := range candidateRows { + farmIDs = append(farmIDs, row.ProjectFlockID) + farmNameByID[row.ProjectFlockID] = row.FarmName + } + + snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs) + if err != nil { + return nil, nil, err + } + snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots)) + for _, row := range snapshots { + snapshotByFarmID[row.ProjectFlockId] = row + } + + missingFarmIDs := make([]uint, 0) + for _, farmID := range farmIDs { + if _, exists := snapshotByFarmID[farmID]; exists { + continue + } + missingFarmIDs = append(missingFarmIDs, farmID) + } + + if len(missingFarmIDs) > 0 { + computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID) + if computeErr != nil { + return nil, nil, computeErr + } + if len(computedSnapshots) > 0 { + if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil { + return nil, nil, err + } + for _, row := range computedSnapshots { + snapshotByFarmID[row.ProjectFlockId] = row + } + } + } + + rows := make([]dto.ExpenseDepreciationRowDTO, 0, len(candidateRows)) + for _, candidate := range candidateRows { + snapshot, exists := snapshotByFarmID[candidate.ProjectFlockID] + if !exists { + rows = append(rows, dto.ExpenseDepreciationRowDTO{ + ProjectFlockID: int64(candidate.ProjectFlockID), + FarmName: candidate.FarmName, + Period: params.Period, + DepreciationPercentEffective: 0, + DepreciationValue: 0, + PulletCostDayNTotal: 0, + Components: map[string]any{}, + }) + continue + } + rows = append(rows, dto.ExpenseDepreciationRowDTO{ + ProjectFlockID: int64(snapshot.ProjectFlockId), + FarmName: candidate.FarmName, + Period: params.Period, + DepreciationPercentEffective: snapshot.DepreciationPercentEffective, + DepreciationValue: snapshot.DepreciationValue, + PulletCostDayNTotal: snapshot.PulletCostDayNTotal, + Components: parseSnapshotComponents(snapshot.Components), + }) + } + + totalResults := int64(len(rows)) + totalPages := int64(0) + if totalResults > 0 { + totalPages = int64(math.Ceil(float64(totalResults) / float64(limit))) + } + if totalPages == 0 { + totalPages = 1 + } + + offset := (params.Page - 1) * limit + if offset < 0 { + offset = 0 + } + if offset > len(rows) { + offset = len(rows) + } + end := offset + limit + if end > len(rows) { + end = len(rows) + } + + meta := &dto.ExpenseDepreciationMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: totalPages, + TotalResults: totalResults, + Filters: filters, + } + + return rows[offset:end], meta, nil +} + +func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) { + params, filters, err := s.parseExpenseDepreciationQuery(ctx) + if err != nil { + return nil, nil, err + } + if s.ExpenseDepreciationRepo == nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") + } + + repoRows, err := s.ExpenseDepreciationRepo.GetLatestManualInputsByFarms( + ctx.Context(), + params.AreaIDs, + params.LocationIDs, + params.ProjectFlockIDs, + ) + if err != nil { + return nil, nil, err + } + + rows := make([]dto.ExpenseDepreciationManualInputRowDTO, 0, len(repoRows)) + for _, row := range repoRows { + rows = append(rows, dto.ExpenseDepreciationManualInputRowDTO{ + ID: int64(row.Id), + ProjectFlockID: int64(row.ProjectFlockID), + FarmName: row.FarmName, + TotalCost: row.TotalCost, + CutoverDate: row.CutoverDate.Format("2006-01-02"), + Note: row.Note, + }) + } + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + totalResults := int64(len(rows)) + totalPages := int64(0) + if totalResults > 0 { + totalPages = int64(math.Ceil(float64(totalResults) / float64(limit))) + } + if totalPages == 0 { + totalPages = 1 + } + + offset := (params.Page - 1) * limit + if offset < 0 { + offset = 0 + } + if offset > len(rows) { + offset = len(rows) + } + end := offset + limit + if end > len(rows) { + end = len(rows) + } + + meta := &dto.ExpenseDepreciationMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: totalPages, + TotalResults: totalResults, + Filters: filters, + } + + return rows[offset:end], meta, nil +} + +func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error) { + if req == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "request is required") + } + if err := s.Validate.Struct(req); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if s.ExpenseDepreciationRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") + } + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + cutoverDate, err := time.ParseInLocation("2006-01-02", req.CutoverDate, location) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "cutover_date must follow format YYYY-MM-DD") + } + + row := entity.FarmDepreciationManualInput{ + ProjectFlockId: req.ProjectFlockID, + TotalCost: req.TotalCost, + CutoverDate: cutoverDate, + Note: req.Note, + } + if err := s.ExpenseDepreciationRepo.UpsertManualInput(ctx.Context(), &row); err != nil { + return nil, err + } + if err := s.ExpenseDepreciationRepo.DeleteSnapshotsFromDate( + ctx.Context(), + cutoverDate, + []uint{row.ProjectFlockId}, + ); err != nil { + return nil, err + } + + response := &dto.ExpenseDepreciationManualInputRowDTO{ + ID: int64(row.Id), + ProjectFlockID: int64(row.ProjectFlockId), + TotalCost: row.TotalCost, + CutoverDate: row.CutoverDate.Format("2006-01-02"), + Note: row.Note, + } + + listRows, listErr := s.ExpenseDepreciationRepo.GetLatestManualInputsByFarms( + ctx.Context(), + nil, + nil, + []int64{int64(row.ProjectFlockId)}, + ) + if listErr == nil { + for _, listRow := range listRows { + if listRow.ProjectFlockID == row.ProjectFlockId { + response.FarmName = listRow.FarmName + break + } + } + } + + return response, nil +} + +type depreciationKandangComponent struct { + ProjectFlockKandangID uint `json:"project_flock_kandang_id"` + KandangID uint `json:"kandang_id"` + KandangName string `json:"kandang_name"` + TransferID uint `json:"transfer_id"` + TransferDate string `json:"transfer_date"` + SourceProjectFlockID uint `json:"source_project_flock_id"` + HouseType string `json:"house_type"` + DayN int `json:"day_n"` + DepreciationPercent float64 `json:"depreciation_percent"` + TransferQty float64 `json:"transfer_qty"` + PulletCostDayN float64 `json:"pullet_cost_day_n"` + DepreciationValue float64 `json:"depreciation_value"` + DepreciationSource string `json:"depreciation_source,omitempty"` + ManualInputID *uint `json:"manual_input_id,omitempty"` + CutoverDate string `json:"cutover_date,omitempty"` + OriginDate string `json:"origin_date,omitempty"` + StartScheduleDay *int `json:"start_schedule_day,omitempty"` +} + +type depreciationFarmComponents struct { + KandangCount int `json:"kandang_count"` + Kandang []depreciationKandangComponent `json:"kandang"` +} + +func (s *repportService) computeExpenseDepreciationSnapshots( + ctx context.Context, + periodDate time.Time, + farmIDs []uint, + farmNameByID map[uint]string, +) ([]entity.FarmDepreciationSnapshot, error) { + _ = farmNameByID + + if len(farmIDs) == 0 { + return []entity.FarmDepreciationSnapshot{}, nil + } + if s.HppCostRepo == nil { + return nil, errors.New("hpp cost repository is not configured") + } + if s.HppV2Svc == nil { + return nil, errors.New("hpp v2 service is not configured") + } + + result := make([]entity.FarmDepreciationSnapshot, 0, len(farmIDs)) + for _, farmID := range farmIDs { + kandangIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, farmID) + if err != nil { + return nil, err + } + + components := depreciationFarmComponents{ + Kandang: make([]depreciationKandangComponent, 0, len(kandangIDs)), + } + + totalDepreciationValue := 0.0 + totalPulletCostDayN := 0.0 + for _, kandangID := range kandangIDs { + breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &periodDate) + if err != nil { + return nil, err + } + if breakdown == nil { + continue + } + + depreciationComponent := hppV2FindDepreciationComponent(breakdown) + if depreciationComponent == nil { + continue + } + + for _, part := range depreciationComponent.Parts { + if part.Total <= 0 { + continue + } + + houseType := approvalService.NormalizeDepreciationHouseType(breakdown.HouseType) + component := depreciationKandangComponent{ + ProjectFlockKandangID: breakdown.ProjectFlockKandangID, + KandangID: breakdown.KandangID, + KandangName: breakdown.KandangName, + SourceProjectFlockID: hppV2DetailUint(part.Details, "source_project_flock_id"), + HouseType: houseType, + DayN: hppV2DetailInt(part.Details, "schedule_day"), + DepreciationPercent: hppV2DetailFloat(part.Details, "depreciation_percent"), + PulletCostDayN: hppV2DetailFloat(part.Details, "pullet_cost_day_n"), + DepreciationValue: part.Total, + DepreciationSource: part.Code, + OriginDate: hppV2DetailString(part.Details, "origin_date"), + } + + if component.HouseType == "" { + component.HouseType = approvalService.NormalizeDepreciationHouseType(hppV2DetailString(part.Details, "house_type")) + } + + if ref := hppV2FindReference(part.References, "laying_transfer"); ref != nil { + component.TransferID = ref.ID + component.TransferDate = ref.Date + component.TransferQty = ref.Qty + } + + if part.Code == "manual_cutover" { + if startDay := hppV2DetailInt(part.Details, "start_schedule_day"); startDay > 0 { + component.StartScheduleDay = &startDay + } + component.CutoverDate = hppV2DetailString(part.Details, "cutover_date") + if manualID := hppV2DetailUint(part.Details, "manual_input_id"); manualID > 0 { + component.ManualInputID = &manualID + } + if component.ManualInputID == nil { + if ref := hppV2FindReference(part.References, "farm_depreciation_manual_input"); ref != nil && ref.ID > 0 { + manualID := ref.ID + component.ManualInputID = &manualID + } + } + } + + totalPulletCostDayN += component.PulletCostDayN + totalDepreciationValue += component.DepreciationValue + components.Kandang = append(components.Kandang, component) + } + } + + components.KandangCount = len(components.Kandang) + effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN) + + componentsJSON, marshalErr := json.Marshal(components) + if marshalErr != nil { + return nil, marshalErr + } + + result = append(result, entity.FarmDepreciationSnapshot{ + ProjectFlockId: farmID, + PeriodDate: periodDate, + DepreciationPercentEffective: effectivePercent, + DepreciationValue: totalDepreciationValue, + PulletCostDayNTotal: totalPulletCostDayN, + Components: componentsJSON, + }) + } + + return result, nil +} + +func hppV2FindDepreciationComponent(breakdown *approvalService.HppV2Breakdown) *approvalService.HppV2Component { + if breakdown == nil { + return nil + } + for idx := range breakdown.Components { + if breakdown.Components[idx].Code == "DEPRECIATION" { + return &breakdown.Components[idx] + } + } + return nil +} + +func hppV2FindReference(references []approvalService.HppV2Reference, refType string) *approvalService.HppV2Reference { + if refType == "" { + return nil + } + for idx := range references { + if references[idx].Type == refType { + return &references[idx] + } + } + return nil +} + +func hppV2DetailFloat(details map[string]any, key string) float64 { + if details == nil || key == "" { + return 0 + } + + raw, exists := details[key] + if !exists || raw == nil { + return 0 + } + + switch value := raw.(type) { + case float64: + return value + case float32: + return float64(value) + case int: + return float64(value) + case int8: + return float64(value) + case int16: + return float64(value) + case int32: + return float64(value) + case int64: + return float64(value) + case uint: + return float64(value) + case uint8: + return float64(value) + case uint16: + return float64(value) + case uint32: + return float64(value) + case uint64: + return float64(value) + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return 0 + } + return parsed + default: + return 0 + } +} + +func hppV2DetailInt(details map[string]any, key string) int { + return int(math.Round(hppV2DetailFloat(details, key))) +} + +func hppV2DetailUint(details map[string]any, key string) uint { + value := hppV2DetailInt(details, key) + if value < 0 { + return 0 + } + return uint(value) +} + +func hppV2DetailString(details map[string]any, key string) string { + if details == nil || key == "" { + return "" + } + raw, exists := details[key] + if !exists || raw == nil { + return "" + } + switch value := raw.(type) { + case string: + return value + case time.Time: + return value.Format("2006-01-02") + default: + return fmt.Sprintf("%v", value) + } +} + +func parseSnapshotComponents(raw []byte) any { + if len(raw) == 0 { + return map[string]any{} + } + var out any + if err := json.Unmarshal(raw, &out); err != nil { + return map[string]any{} + } + return out +} + +func valueOrEmptyString(v *string) string { + if v == nil { + return "" + } + return *v +} + func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -226,7 +773,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing return nil, 0, err } - hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppSvc, attributionRows) + hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppV2Svc, attributionRows) categoryByDelivery := buildMarketingCategoryByDelivery(deliveryProducts, attributionRows) items := dto.ToMarketingReportItems(deliveryProducts, hppByDelivery, categoryByDelivery, agingMap) @@ -235,7 +782,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing func buildMarketingHppByDelivery( ctx context.Context, - hppSvc approvalService.HppService, + hppSvc approvalService.HppV2Service, attributionRows []commonRepo.MarketingDeliveryAttributionRow, ) map[uint]float64 { if len(attributionRows) == 0 { @@ -1645,6 +2192,27 @@ func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Locatio return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc) } +func (s *repportService) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if s.HppV2Svc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "hpp v2 service is not configured") + } + + periodDate, err := time.ParseInLocation("2006-01-02", params.Period, time.FixedZone("Asia/Jakarta", 7*60*60)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD") + } + + result, err := s.HppV2Svc.CalculateHppBreakdown(params.ProjectFlockKandangID, &periodDate) + if err != nil { + return nil, err + } + + return result, nil +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { @@ -1791,20 +2359,22 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes var eggWeightFloat float64 var avgWeight float64 eggHpp := 0.0 - if s.HppSvc != nil { - hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate) + if s.HppV2Svc != nil { + hppCost, err := s.HppV2Svc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate) if err != nil { return nil, nil, err } if hppCost != nil { eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir - eggHpp = hppCost.Estimation.HargaKg + // eggHpp = hppCost.Estimation.HargaKg + eggHpp = hppCost.Real.HargaKg eggTotalPiecesFloat = hppCost.Estimation.Butir eggWeightFloat = hppCost.Estimation.Kg if eggTotalPiecesFloat > 0 { avgWeight = eggWeightFloat / eggTotalPiecesFloat } - eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining + // eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining + eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg } } if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { @@ -2133,6 +2703,84 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return params, filters, nil } +func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validation.ExpenseDepreciationQuery, dto.ExpenseDepreciationFiltersDTO, error) { + page := ctx.QueryInt("page", 1) + if page < 1 { + page = 1 + } + limit := ctx.QueryInt("limit", 10) + if limit < 1 { + limit = 10 + } + + rawArea := ctx.Query("area_id", "") + rawLocation := ctx.Query("location_id", "") + rawProjectFlock := ctx.Query("project_flock_id", "") + period := strings.TrimSpace(ctx.Query("period", "")) + + areaIDs, err := parseCommaSeparatedInt64s(rawArea) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + locationIDs, err := parseCommaSeparatedInt64s(rawLocation) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + projectFlockIDs, err := parseCommaSeparatedInt64s(rawProjectFlock) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, err + } + + if locationScope.Restrict { + allowed := toInt64Slice(locationScope.IDs) + if len(allowed) == 0 { + locationIDs = []int64{-1} + } else if len(locationIDs) > 0 { + locationIDs = intersectInt64(locationIDs, allowed) + } else { + locationIDs = allowed + } + } + + if areaScope.Restrict { + allowed := toInt64Slice(areaScope.IDs) + if len(allowed) == 0 { + areaIDs = []int64{-1} + } else if len(areaIDs) > 0 { + areaIDs = intersectInt64(areaIDs, allowed) + } else { + areaIDs = allowed + } + } + + params := &validation.ExpenseDepreciationQuery{ + Page: page, + Limit: limit, + Period: period, + ProjectFlockIDs: projectFlockIDs, + AreaIDs: areaIDs, + LocationIDs: locationIDs, + } + + filters := dto.NewExpenseDepreciationFiltersDTO( + rawArea, + rawLocation, + rawProjectFlock, + period, + ) + + return params, filters, nil +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index d248c779..f34e2702 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -75,6 +75,27 @@ type HppPerKandangQuery struct { WeightMax *float64 `query:"-"` } +type HppV2BreakdownQuery struct { + ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"required,gt=0"` + Period string `query:"period" validate:"required,datetime=2006-01-02"` +} + +type ExpenseDepreciationQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` + Period string `query:"period" validate:"required,datetime=2006-01-02"` + ProjectFlockIDs []int64 `query:"-"` + AreaIDs []int64 `query:"-"` + LocationIDs []int64 `query:"-"` +} + +type ExpenseDepreciationManualInputUpsert struct { + ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"` + TotalCost float64 `json:"total_cost" validate:"required,gte=0"` + CutoverDate string `json:"cutover_date" validate:"required,datetime=2006-01-02"` + Note *string `json:"note" validate:"omitempty,max=1000"` +} + type ProductionResultQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` diff --git a/internal/readapi/readapi.go b/internal/readapi/readapi.go index 2ae62472..59fe1329 100644 --- a/internal/readapi/readapi.go +++ b/internal/readapi/readapi.go @@ -983,6 +983,23 @@ func describeRoute(route normalizedRoute) routeMeta { {Name: "area_id", In: "query", Description: "Area id filter.", Example: 1}, {Name: "realization_date", In: "query", Description: "Realization date filter (YYYY-MM-DD).", Example: "2026-01-15"}, } + case "/api/reports/expense/depreciation": + meta.QueryParams = []parameterMeta{ + {Name: "page", In: "query", Description: "Page number.", Example: 1}, + {Name: "limit", In: "query", Description: "Page size.", Example: 10}, + {Name: "period", In: "query", Description: "Daily period filter (YYYY-MM-DD).", Required: true, Example: "2026-01-01"}, + {Name: "project_flock_id", In: "query", Description: "Comma separated project flock ids.", Example: "1,2"}, + {Name: "area_id", In: "query", Description: "Comma separated area ids.", Example: "1,2"}, + {Name: "location_id", In: "query", Description: "Comma separated location ids.", Example: "1,2"}, + } + case "/api/reports/expense/depreciation/manual-inputs": + meta.QueryParams = []parameterMeta{ + {Name: "page", In: "query", Description: "Page number.", Example: 1}, + {Name: "limit", In: "query", Description: "Page size.", Example: 10}, + {Name: "project_flock_id", In: "query", Description: "Comma separated project flock ids.", Example: "1,2"}, + {Name: "area_id", In: "query", Description: "Comma separated area ids.", Example: "1,2"}, + {Name: "location_id", In: "query", Description: "Comma separated location ids.", Example: "1,2"}, + } case "/api/reports/marketing": meta.QueryParams = []parameterMeta{ {Name: "page", In: "query", Description: "Page number.", Example: 1}, diff --git a/internal/utils/constant.go b/internal/utils/constant.go index dfe4ef6e..eaed4a97 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -581,6 +581,17 @@ const ( KandangStatusActive KandangStatus = "ACTIVE" ) +// ------------------------------------------------------------------- +// House Type +// ------------------------------------------------------------------- + +type HouseType string + +const ( + HouseTypeOpenHouse HouseType = "open_house" + HouseTypeCloseHouse HouseType = "close_house" +) + // ------------------------------------------------------------------- // Marketing Type // ------------------------------------------------------------------- diff --git a/scripts/sql/seed_house_depreciation_standards.sql b/scripts/sql/seed_house_depreciation_standards.sql new file mode 100644 index 00000000..7e14ae6e --- /dev/null +++ b/scripts/sql/seed_house_depreciation_standards.sql @@ -0,0 +1,1095 @@ +-- Generated from /Users/macbookairm1/Downloads/Req IT.xlsx (sheet: % Depresiasi) +-- close_house: days 1-553, open_house: days 1-532 +-- depreciation_percent is stored as percent number (e.g. 0.154943 means 0.154943%) +INSERT INTO house_depreciation_standards (name, effective_date, house_type, day, depreciation_percent) +VALUES + ('depresiasi 2026', NOW()::date, 'close_house', 1, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 2, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 3, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 4, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 5, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 6, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 7, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 8, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 9, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 10, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 11, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 12, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 13, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 14, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 15, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 16, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 17, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 18, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 19, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 20, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 21, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 22, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 23, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 24, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 25, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 26, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 27, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 28, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 29, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 30, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 31, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 32, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 33, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 34, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 35, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 36, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 37, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 38, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 39, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 40, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 41, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 42, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 43, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 44, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 45, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 46, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 47, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 48, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 49, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 50, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 51, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 52, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 53, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 54, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 55, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 56, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 57, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 58, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 59, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 60, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 61, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 62, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 63, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 64, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 65, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 66, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 67, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 68, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 69, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 70, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 71, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 72, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 73, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 74, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 75, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 76, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 77, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 78, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 79, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 80, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 81, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 82, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 83, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 84, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 85, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 86, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 87, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 88, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 89, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 90, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 91, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 92, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 93, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 94, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 95, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 96, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 97, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 98, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 99, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 100, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 101, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 102, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 103, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 104, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 105, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 106, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 107, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 108, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 109, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 110, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 111, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 112, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 113, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 114, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 115, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 116, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 117, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 118, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 119, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 120, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 121, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 122, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 123, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 124, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 125, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 126, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 127, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 128, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 129, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 130, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 131, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 132, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 133, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 134, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 135, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 136, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 137, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 138, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 139, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 140, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 141, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 142, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 143, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 144, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 145, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 146, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 147, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 148, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 149, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 150, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 151, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 152, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 153, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 154, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 155, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 156, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 157, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 158, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 159, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 160, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 161, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 162, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 163, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 164, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 165, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 166, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 167, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 168, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 169, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 170, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 171, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 172, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 173, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 174, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 175, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 176, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 177, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 178, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 179, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 180, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 181, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 182, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 183, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 184, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 185, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 186, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 187, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 188, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 189, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 190, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 191, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 192, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 193, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 194, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 195, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 196, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 197, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 198, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 199, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 200, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 201, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 202, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 203, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 204, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 205, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 206, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 207, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 208, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 209, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 210, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 211, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 212, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 213, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 214, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 215, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 216, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 217, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 218, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 219, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 220, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 221, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 222, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 223, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 224, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 225, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 226, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 227, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 228, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 229, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 230, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 231, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 232, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 233, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 234, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 235, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 236, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 237, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 238, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 239, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 240, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 241, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 242, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 243, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 244, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 245, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 246, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 247, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 248, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 249, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 250, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 251, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 252, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 253, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 254, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 255, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 256, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 257, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 258, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 259, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 260, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 261, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 262, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 263, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 264, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 265, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 266, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 267, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 268, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 269, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 270, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 271, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 272, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 273, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 274, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 275, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 276, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 277, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 278, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 279, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 280, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 281, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 282, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 283, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 284, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 285, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 286, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 287, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 288, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 289, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 290, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 291, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 292, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 293, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 294, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 295, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 296, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 297, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 298, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 299, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 300, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 301, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 302, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 303, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 304, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 305, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 306, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 307, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 308, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 309, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 310, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 311, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 312, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 313, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 314, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 315, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 316, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 317, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 318, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 319, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 320, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 321, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 322, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 323, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 324, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 325, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 326, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 327, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 328, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 329, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 330, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 331, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 332, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 333, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 334, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 335, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 336, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 337, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 338, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 339, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 340, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 341, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 342, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 343, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 344, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 345, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 346, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 347, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 348, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 349, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 350, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 351, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 352, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 353, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 354, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 355, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 356, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 357, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 358, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 359, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 360, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 361, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 362, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 363, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 364, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 365, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 366, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 367, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 368, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 369, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 370, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 371, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 372, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 373, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 374, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 375, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 376, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 377, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 378, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 379, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 380, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 381, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 382, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 383, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 384, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 385, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 386, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 387, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 388, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 389, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 390, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 391, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 392, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 393, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 394, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 395, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 396, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 397, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 398, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 399, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 400, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 401, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 402, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 403, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 404, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 405, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 406, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 407, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 408, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 409, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 410, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 411, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 412, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 413, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 414, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 415, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 416, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 417, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 418, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 419, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 420, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 421, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 422, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 423, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 424, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 425, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 426, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 427, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 428, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 429, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 430, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 431, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 432, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 433, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 434, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 435, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 436, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 437, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 438, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 439, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 440, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 441, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 442, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 443, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 444, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 445, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 446, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 447, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 448, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 449, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 450, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 451, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 452, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 453, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 454, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 455, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 456, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 457, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 458, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 459, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 460, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 461, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 462, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 463, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 464, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 465, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 466, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 467, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 468, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 469, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 470, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 471, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 472, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 473, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 474, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 475, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 476, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 477, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 478, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 479, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 480, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 481, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 482, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 483, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 484, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 485, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 486, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 487, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 488, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 489, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 490, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 491, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 492, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 493, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 494, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 495, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 496, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 497, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 498, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 499, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 500, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 501, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 502, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 503, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 504, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 505, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 506, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 507, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 508, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 509, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 510, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 511, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 512, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 513, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 514, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 515, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 516, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 517, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 518, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 519, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 520, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 521, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 522, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 523, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 524, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 525, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 526, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 527, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 528, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 529, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 530, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 531, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 532, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 533, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 534, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 535, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 536, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 537, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 538, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 539, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 540, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 541, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 542, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 543, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 544, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 545, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 546, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 547, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 548, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 549, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 550, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 551, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 552, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 553, 0.123954), + ('depresiasi 2026', NOW()::date, 'open_house', 1, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 2, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 3, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 4, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 5, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 6, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 7, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 8, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 9, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 10, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 11, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 12, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 13, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 14, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 15, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 16, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 17, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 18, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 19, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 20, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 21, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 22, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 23, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 24, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 25, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 26, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 27, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 28, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 29, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 30, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 31, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 32, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 33, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 34, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 35, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 36, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 37, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 38, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 39, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 40, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 41, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 42, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 43, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 44, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 45, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 46, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 47, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 48, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 49, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 50, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 51, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 52, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 53, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 54, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 55, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 56, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 57, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 58, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 59, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 60, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 61, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 62, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 63, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 64, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 65, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 66, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 67, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 68, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 69, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 70, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 71, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 72, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 73, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 74, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 75, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 76, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 77, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 78, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 79, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 80, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 81, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 82, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 83, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 84, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 85, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 86, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 87, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 88, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 89, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 90, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 91, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 92, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 93, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 94, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 95, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 96, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 97, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 98, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 99, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 100, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 101, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 102, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 103, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 104, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 105, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 106, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 107, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 108, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 109, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 110, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 111, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 112, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 113, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 114, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 115, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 116, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 117, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 118, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 119, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 120, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 121, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 122, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 123, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 124, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 125, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 126, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 127, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 128, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 129, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 130, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 131, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 132, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 133, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 134, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 135, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 136, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 137, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 138, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 139, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 140, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 141, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 142, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 143, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 144, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 145, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 146, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 147, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 148, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 149, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 150, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 151, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 152, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 153, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 154, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 155, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 156, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 157, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 158, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 159, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 160, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 161, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 162, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 163, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 164, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 165, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 166, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 167, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 168, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 169, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 170, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 171, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 172, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 173, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 174, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 175, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 176, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 177, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 178, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 179, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 180, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 181, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 182, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 183, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 184, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 185, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 186, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 187, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 188, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 189, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 190, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 191, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 192, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 193, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 194, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 195, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 196, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 197, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 198, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 199, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 200, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 201, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 202, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 203, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 204, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 205, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 206, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 207, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 208, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 209, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 210, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 211, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 212, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 213, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 214, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 215, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 216, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 217, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 218, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 219, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 220, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 221, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 222, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 223, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 224, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 225, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 226, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 227, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 228, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 229, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 230, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 231, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 232, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 233, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 234, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 235, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 236, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 237, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 238, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 239, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 240, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 241, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 242, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 243, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 244, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 245, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 246, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 247, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 248, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 249, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 250, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 251, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 252, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 253, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 254, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 255, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 256, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 257, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 258, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 259, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 260, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 261, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 262, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 263, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 264, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 265, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 266, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 267, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 268, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 269, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 270, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 271, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 272, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 273, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 274, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 275, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 276, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 277, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 278, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 279, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 280, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 281, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 282, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 283, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 284, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 285, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 286, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 287, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 288, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 289, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 290, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 291, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 292, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 293, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 294, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 295, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 296, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 297, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 298, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 299, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 300, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 301, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 302, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 303, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 304, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 305, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 306, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 307, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 308, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 309, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 310, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 311, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 312, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 313, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 314, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 315, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 316, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 317, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 318, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 319, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 320, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 321, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 322, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 323, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 324, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 325, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 326, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 327, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 328, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 329, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 330, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 331, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 332, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 333, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 334, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 335, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 336, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 337, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 338, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 339, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 340, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 341, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 342, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 343, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 344, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 345, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 346, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 347, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 348, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 349, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 350, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 351, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 352, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 353, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 354, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 355, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 356, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 357, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 358, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 359, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 360, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 361, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 362, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 363, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 364, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 365, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 366, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 367, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 368, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 369, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 370, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 371, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 372, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 373, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 374, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 375, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 376, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 377, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 378, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 379, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 380, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 381, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 382, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 383, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 384, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 385, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 386, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 387, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 388, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 389, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 390, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 391, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 392, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 393, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 394, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 395, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 396, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 397, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 398, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 399, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 400, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 401, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 402, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 403, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 404, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 405, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 406, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 407, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 408, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 409, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 410, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 411, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 412, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 413, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 414, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 415, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 416, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 417, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 418, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 419, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 420, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 421, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 422, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 423, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 424, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 425, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 426, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 427, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 428, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 429, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 430, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 431, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 432, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 433, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 434, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 435, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 436, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 437, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 438, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 439, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 440, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 441, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 442, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 443, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 444, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 445, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 446, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 447, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 448, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 449, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 450, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 451, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 452, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 453, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 454, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 455, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 456, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 457, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 458, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 459, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 460, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 461, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 462, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 463, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 464, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 465, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 466, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 467, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 468, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 469, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 470, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 471, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 472, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 473, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 474, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 475, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 476, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 477, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 478, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 479, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 480, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 481, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 482, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 483, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 484, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 485, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 486, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 487, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 488, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 489, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 490, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 491, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 492, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 493, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 494, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 495, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 496, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 497, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 498, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 499, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 500, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 501, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 502, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 503, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 504, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 505, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 506, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 507, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 508, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 509, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 510, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 511, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 512, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 513, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 514, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 515, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 516, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 517, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 518, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 519, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 520, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 521, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 522, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 523, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 524, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 525, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 526, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 527, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 528, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 529, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 530, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 531, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 532, 0.128991) +ON CONFLICT (house_type, day) DO UPDATE +SET name = EXCLUDED.name, + effective_date = EXCLUDED.effective_date, + depreciation_percent = EXCLUDED.depreciation_percent, + updated_at = NOW();