Merge branch 'development' into 'production'

Development

See merge request mbugroup/lti-api!425
This commit is contained in:
Adnan Zahir
2026-04-20 08:24:44 +07:00
61 changed files with 11383 additions and 57 deletions
+587
View File
@@ -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
}
@@ -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)
@@ -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 = &noteRaw
}
}
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
}
@@ -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
}
+602
View File
@@ -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
}
+280
View File
@@ -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
}
+75
View File
@@ -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
})
}
@@ -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 <path.xlsx> [--sheet <name>] [--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.
+212
View File
@@ -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": { "/api/reports/hpp-per-kandang": {
"get": { "get": {
"description": "Read access to `/api/reports/hpp-per-kandang`.", "description": "Read access to `/api/reports/hpp-per-kandang`.",
+135
View File
@@ -5318,6 +5318,141 @@ paths:
summary: GET api / reports / expense summary: GET api / reports / expense
tags: tags:
- Reports - 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: /api/reports/hpp-per-kandang:
get: get:
description: Read access to `/api/reports/hpp-per-kandang`. description: Read access to `/api/reports/hpp-per-kandang`.
+26
View File
@@ -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" "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", "name": "GET api / reports / hpp per kandang",
"request": { "request": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -82,6 +82,7 @@ func DefaultDashboardPermissions() []string {
"lti.repport.debtsupplier.list", "lti.repport.debtsupplier.list",
"lti.repport.delivery.list", "lti.repport.delivery.list",
"lti.repport.expense.list", "lti.repport.expense.list",
"lti.repport.expense.depreciation.manage",
"lti.repport.gethppperkandang.list", "lti.repport.gethppperkandang.list",
"lti.repport.production_result.list", "lti.repport.production_result.list",
"lti.repport.purchasesupplier.list", "lti.repport.purchasesupplier.list",
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"errors"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" 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) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error)
} }
type HppRepositoryImpl struct { 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) { 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 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("project_chickins AS pc"). Table("project_chickins AS pc").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Select(`
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). COALESCE(SUM(sa.qty * CASE
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). 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). Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error Scan(&total).Error
if err != nil { 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 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). 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("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("f.name = ?", utils.FlagEkspedisi). // Where("f.name = ?", utils.FlagEkspedisi).
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -100,15 +122,35 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
date = &now date = &now
} }
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("recordings AS r"). 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 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 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 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(
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). "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.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan). Where("f.name = ?", utils.FlagPakan).
@@ -132,15 +174,34 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
utils.FlagVitamin, utils.FlagVitamin,
utils.FlagKimia, utils.FlagKimia,
} }
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("recordings AS r"). 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 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 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(
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). "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.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). 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). 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) { func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String() stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableTransferIn := fifo.StockableKeyStockTransferIn.String() stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String() usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("project_chickins AS pc"). Table("project_chickins AS pc").
Select(` Select(`
COALESCE(SUM(sa.qty * CASE COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0 WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
END), 0)`, ELSE 0
stockablePurchase, stockableTransferIn). 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("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 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 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 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). Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
@@ -215,6 +282,33 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang
return 0, 0, err 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 return totals.TotalPieces, totals.TotalWeightKg, nil
} }
@@ -311,3 +405,25 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
return summary.ProjectFlockID, summary.TotalQty, nil 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
}
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
@@ -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
}
@@ -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
}
+121 -13
View File
@@ -46,6 +46,7 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -54,16 +55,21 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay) depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer) totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
if err != nil { if err != nil {
return nil, err 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) { 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 { if s.hppRepo == nil {
return 0, nil return 0, nil
} }
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs) docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
if err != nil { if err != nil {
return 0, err return 0, err
} }
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID) budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs) expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
if err != nil { if err != nil {
return 0, err return 0, err
} }
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date) feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
if err != nil { if err != nil {
return 0, err return 0, err
} }
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date) ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
if err != nil { if err != nil {
return 0, err 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) { 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) costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
if err != nil { if err != nil {
return 0, err return 0, err
} }
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId}) costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
if err != nil { if err != nil {
return 0, err return 0, err
} }
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate) costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
if err != nil { if err != nil {
return 0, err 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) { 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 { if s.hppRepo == nil {
return 0, nil return 0, nil
} }
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil { if err != nil {
return 0, err return 0, err
} }
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId) projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
if err != nil { if err != nil {
return 0, err return 0, err
} }
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate) eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId) totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if eggProduksiPiecesFlock == 0 { if eggProduksiPiecesFlock == 0 {
return 0, nil 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) { func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if endDate == nil { if endDate == nil {
// now := time.Now() now := time.Now()
// endDate = &now endDate = &now
// } }
if s.hppRepo == nil { if s.hppRepo == nil {
return 0, nil return 0, nil
} }
@@ -199,6 +232,13 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
if err != nil { if err != nil {
return 0, err 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) kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil { if err != nil {
@@ -218,22 +258,81 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
return 0, err 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) { func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
if s.hppRepo == nil { if s.hppRepo == nil {
return &HppCostResponse{}, nil return &HppCostResponse{}, nil
} }
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -261,12 +360,21 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
} }
return &HppCostResponse{ result := &HppCostResponse{
Estimation: estimation, Estimation: estimation,
Real: real, Real: real,
}, nil }
return result, nil
} }
func roundToTwoDecimals(value float64) float64 { 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 "<nil>"
}
return value.Format(time.RFC3339)
} }
@@ -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"`
}
File diff suppressed because it is too large Load Diff
@@ -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)))
}
@@ -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
}
@@ -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;
@@ -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;
@@ -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;
@@ -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);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_project_flock_id;
DROP TABLE IF EXISTS farm_depreciation_manual_inputs;
@@ -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);
@@ -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;
@@ -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);
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1
View File
@@ -10,6 +10,7 @@ type Kandang struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` 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"` Status string `gorm:"type:varchar(50);not null"`
HouseType *string `gorm:"type:house_type_enum"`
LocationId uint `gorm:"not null"` LocationId uint `gorm:"not null"`
KandangGroupId uint `gorm:"not null"` KandangGroupId uint `gorm:"not null"`
Capacity float64 `gorm:"not null"` Capacity float64 `gorm:"not null"`
+8 -7
View File
@@ -47,13 +47,14 @@ const (
P_ApprovalGetAll = "lti.approval.list" P_ApprovalGetAll = "lti.approval.list"
) )
const ( const (
P_ReportExpenseGetAll = "lti.repport.expense.list" P_ReportExpenseGetAll = "lti.repport.expense.list"
P_ReportDeliveryGetAll = "lti.repport.delivery.list" P_ReportExpenseDepreciationManage = "lti.repport.expense.depreciation.manage"
P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" P_ReportDeliveryGetAll = "lti.repport.delivery.list"
P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list"
P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list"
P_ReportProductionResultGetAll = "lti.repport.production_result.list" P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list"
P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list" P_ReportProductionResultGetAll = "lti.repport.production_result.list"
P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list"
) )
const ( const (
+3 -3
View File
@@ -18,11 +18,11 @@ type DashboardModule struct{}
func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dashboardRepo := rDashboard.NewDashboardRepository(db) dashboardRepo := rDashboard.NewDashboardRepository(db)
hppCostRepo := commonRepo.NewHppCostRepository(db) hppV2CostRepo := commonRepo.NewHppV2CostRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
hppSvc := commonService.NewHppService(hppCostRepo) hppV2Svc := commonService.NewHppV2Service(hppV2CostRepo)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc) dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppV2Svc)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
DashboardRoutes(router, userService, dashboardService) DashboardRoutes(router, userService, dashboardService)
@@ -30,10 +30,10 @@ type dashboardService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
Repository repository.DashboardRepository 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{ return &dashboardService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" 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 { if err != nil {
return nil, err return nil, err
} }
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, uint(expense.Id), expenseDate, nil)
return responseDTO, 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) updateBody := make(map[string]any)
var requestedTransactionDate *time.Time
if req.TransactionDate != nil { if req.TransactionDate != nil {
expenseDate, err := utils.ParseDateString(*req.TransactionDate) 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") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
} }
updateBody["transaction_date"] = expenseDate updateBody["transaction_date"] = expenseDate
requestedTransactionDate = &expenseDate
} }
if req.Category != nil { if req.Category != nil {
@@ -429,6 +433,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return responseDTO, nil return responseDTO, nil
} }
var invalidationFromDate time.Time
var invalidationFarmIDs []uint
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
expenseRepoTx := repository.NewExpenseRepository(tx) 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 { if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
return err 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 categoryChanged := false
var newCategory string var newCategory string
if req.Category != nil && *req.Category != currentExpense.Category { 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 return nil
}) })
@@ -645,6 +667,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.invalidateDepreciationSnapshots(c.Context(), nil, invalidationFarmIDs, invalidationFromDate)
return responseDTO, nil 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 { if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return err 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 err := s.Repository.DeleteOne(c.Context(), idUint); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Expense not found for ID %d: %+v", id, err) 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 return err
} }
s.Log.Infof("Successfully deleted expense with ID %d", id) 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 return nil
} }
@@ -800,6 +829,8 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
if err != nil { if err != nil {
return nil, err return nil, err
} }
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate)
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
return responseDTO, nil return responseDTO, nil
} }
@@ -857,6 +888,13 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (
if err != nil { if err != nil {
return nil, err 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 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 { if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return nil, err 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) latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") 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 { if err != nil {
return nil, err return nil, err
} }
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
return responseDTO, nil return responseDTO, nil
} }
@@ -1057,6 +1102,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
} }
var results []expenseDto.ExpenseDetailDTO var results []expenseDto.ExpenseDetailDTO
invalidateFromDateByExpenseID := make(map[uint]time.Time)
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { 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 { ); err != nil {
return err 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) latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 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") 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 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) { func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {
expenseRepoTx := repository.NewExpenseRepository(ctx) expenseRepoTx := repository.NewExpenseRepository(ctx)
@@ -5,7 +5,7 @@ type DeliveryProduct struct {
Qty float64 `json:"qty" validate:"omitempty,gte=0"` Qty float64 `json:"qty" validate:"omitempty,gte=0"`
UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"`
AvgWeight float64 `json:"avg_weight" 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"` TotalWeight *float64 `json:"total_weight" validate:"omitempty,gte=0"`
TotalPrice *float64 `json:"total_price" validate:"omitempty,gte=0"` TotalPrice *float64 `json:"total_price" validate:"omitempty,gte=0"`
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"` DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
@@ -419,6 +419,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
if len(result) == 0 { if len(result) == 0 {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load created chickins") 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 return result, nil
} }
@@ -462,6 +467,8 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(chickin.ChickInDate, updated.ChickInDate)
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{updated.ProjectFlockKandangId}, invalidateFromDate)
if updated.UsageQty > 0 { if updated.UsageQty > 0 {
if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil { 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, consumeAllocAfter,
traceAllocAfter, traceAllocAfter,
) )
s.invalidateDepreciationSnapshots(c.Context(), tx, []uint{lockedChickin.ProjectFlockKandangId}, lockedChickin.ChickInDate)
return nil return nil
}) })
@@ -1160,6 +1168,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
if action == entity.ApprovalActionApproved { if action == entity.ApprovalActionApproved {
step = utils.ChickinStepDisetujui step = utils.ChickinStepDisetujui
} }
invalidateFromByPFK := make(map[uint]time.Time, len(approvableIDs))
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil { 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get chickins for approval %d", approvableID)) 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) kandangForApproval, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID)
if err != nil { if err != nil {
@@ -1281,6 +1296,12 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID)) 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 { if len(chickins) == 0 {
continue 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") 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) updated := make([]entity.ProjectChickin, 0)
for _, kandangID := range approvableIDs { 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) 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 { func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error {
if productWarehouseID == 0 { if productWarehouseID == 0 {
return nil return nil
@@ -13,11 +13,11 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo
ctrl := controller.NewProjectFlockKandangController(s) ctrl := controller.NewProjectFlockKandangController(s)
route := v1.Group("/project-flock-kandangs") route := v1.Group("/project-flock-kandangs")
route.Use(m.Auth(u)) // route.Use(m.Auth(u))
route.Get("/",m.RequirePermissions(m.P_ProjectFlockKandangsGetAll), ctrl.GetAll) route.Get("/", ctrl.GetAll)
route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne)
// route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing)
// route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing) // 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) route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing)
} }
@@ -517,7 +517,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return nil, transactionErr 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) { 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 { if err := recordingutil.AttachProductionStandards(ctx, s.Repository.DB(), false, s.Log, updatedRecording); err != nil {
return nil, err return nil, err
} }
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(recordingEntity.RecordDatetime, updatedRecording.RecordDatetime)
s.invalidateDepreciationSnapshots(
c.Context(),
nil,
updatedRecording.ProjectFlockKandangId,
invalidateFromDate,
)
return updatedRecording, nil return updatedRecording, nil
} }
@@ -965,6 +979,12 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.invalidateDepreciationSnapshots(
c.Context(),
nil,
recording.ProjectFlockKandangId,
recording.RecordDatetime,
)
updated = append(updated, *recording) updated = append(updated, *recording)
} }
@@ -985,7 +1005,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
} }
note := recordingutil.RecordingNote("Delete", id) 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) recording, err := s.Repository.WithTx(tx).GetByID(ctx, id, nil)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { 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) s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
return err return err
} }
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
return nil 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) { func (s *recordingService) resolveRecordingCategory(ctx context.Context, recording *entity.Recording) (string, error) {
@@ -377,6 +377,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{req.TargetProjectFlockId}, transferDate)
return laying_transfer, nil return laying_transfer, nil
} }
@@ -588,6 +589,13 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
} }
layingTransfer, _, err := s.GetOne(c, id) 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 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) s.Log.Errorf("Failed to delete transferLaying: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying")
} }
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{transfer.ToProjectFlockId}, transfer.TransferDate)
return nil return nil
} }
@@ -798,6 +807,14 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
if err != nil { if err != nil {
return nil, err return nil, err
} }
if transfer != nil {
s.invalidateDepreciationSnapshots(
c.Context(),
nil,
[]uint{transfer.ToProjectFlockId},
resolveDepreciationEffectiveDateForTransfer(transfer),
)
}
updated = append(updated, *transfer) updated = append(updated, *transfer)
} }
@@ -837,6 +854,14 @@ func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTra
if err != nil { if err != nil {
return nil, err return nil, err
} }
if transfer != nil {
s.invalidateDepreciationSnapshots(
c.Context(),
nil,
[]uint{transfer.ToProjectFlockId},
resolveDepreciationEffectiveDateForTransfer(transfer),
)
}
return transfer, nil return transfer, nil
} }
@@ -873,6 +898,14 @@ func (s transferLayingService) ExecuteWithBusinessDate(c *fiber.Ctx, id uint, bu
if err != nil { if err != nil {
return nil, err return nil, err
} }
if transfer != nil {
s.invalidateDepreciationSnapshots(
c.Context(),
nil,
[]uint{transfer.ToProjectFlockId},
resolveDepreciationEffectiveDateForTransfer(transfer),
)
}
return transfer, nil return transfer, nil
} }
@@ -1226,6 +1259,14 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
if err != nil { if err != nil {
return nil, err return nil, err
} }
if transfer != nil {
s.invalidateDepreciationSnapshots(
c.Context(),
nil,
[]uint{transfer.ToProjectFlockId},
resolveDepreciationEffectiveDateForTransfer(transfer),
)
}
return transfer, nil 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) 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 { func isLegacyTransfer(transfer *entity.LayingTransfer) bool {
if transfer == nil { if transfer == nil {
return false return false
@@ -675,6 +675,12 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
if err := s.attachLatestApproval(c.Context(), created); err != nil { 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.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 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 { 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.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 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 { 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.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 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 { 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.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)) receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared))
for _, prep := range 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 { if err := s.attachLatestApproval(ctx, updated); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) 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 return updated, nil
} }
@@ -1721,6 +1755,12 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
return utils.Internal("Failed to sync expense") return utils.Internal("Failed to sync expense")
} }
} }
s.invalidateDepreciationSnapshots(
ctx,
nil,
collectPFKIDsFromPurchaseItems(itemsToDelete),
resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()),
)
return nil 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 { if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil {
return nil, err 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( func (s *purchaseService) loadPurchase(
ctx context.Context, ctx context.Context,
@@ -2522,10 +2572,17 @@ func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Contex
} }
func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { 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{}) seen := make(map[uint]struct{})
ids := make([]uint, 0) ids := make([]uint, 0)
for _, item := range p.Items { for _, item := range items {
if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 { if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 {
continue continue
} }
@@ -2538,6 +2595,82 @@ func collectPFKIDsFromPurchase(p *entity.Purchase) []uint {
} }
return ids 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( func (s *purchaseService) ensureProjectFlockNotClosedForPurchase(
ctx context.Context, ctx context.Context,
purchase *entity.Purchase, purchase *entity.Purchase,
@@ -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 { func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error {
query := &validation.MarketingQuery{ query := &validation.MarketingQuery{
Page: ctx.QueryInt("page", 1), 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) { func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
return parseCommaSeparatedInt64sWithField(raw, "supplier_ids") return parseCommaSeparatedInt64sWithField(raw, "supplier_ids")
} }
@@ -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,
}
}
+25 -1
View File
@@ -33,9 +33,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
recordingRepository := recordingRepo.NewRecordingRepository(db) recordingRepository := recordingRepo.NewRecordingRepository(db)
approvalRepository := commonRepo.NewApprovalRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db)
hppCostRepository := commonRepo.NewHppCostRepository(db) hppCostRepository := commonRepo.NewHppCostRepository(db)
hppV2CostRepository := commonRepo.NewHppV2CostRepository(db)
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db)
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db)
productionResultRepository := repportRepo.NewProductionResultRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db)
customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db)
customerRepository := customerRepo.NewCustomerRepository(db) customerRepository := customerRepo.NewCustomerRepository(db)
@@ -45,7 +47,29 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
approvalSvc := approvalService.NewApprovalService(approvalRepository) approvalSvc := approvalService.NewApprovalService(approvalRepository)
hppSvc := approvalService.NewHppService(hppCostRepository) 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) userService := sUser.NewUserService(userRepository, validate)
RepportRoutes(router, userService, repportService) RepportRoutes(router, userService, repportService)
@@ -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
}
+4
View File
@@ -16,10 +16,14 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) 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("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing)
route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier)
route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) 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-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("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult)
route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment)
} }
@@ -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)
}
}
@@ -42,10 +42,14 @@ import (
type RepportService interface { type RepportService interface {
GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) 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) GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error)
GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error)
GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error)
GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, 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) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error)
GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error)
DB() *gorm.DB DB() *gorm.DB
@@ -56,12 +60,15 @@ type repportService struct {
Validate *validator.Validate Validate *validator.Validate
db *gorm.DB db *gorm.DB
ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository
ExpenseDepreciationRepo repportRepo.ExpenseDepreciationRepository
MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository
PurchaseRepo purchaseRepo.PurchaseRepository PurchaseRepo purchaseRepo.PurchaseRepository
ChickinRepo chickinRepo.ProjectChickinRepository ChickinRepo chickinRepo.ProjectChickinRepository
RecordingRepo recordingRepo.RecordingRepository RecordingRepo recordingRepo.RecordingRepository
ApprovalSvc approvalService.ApprovalService ApprovalSvc approvalService.ApprovalService
HppSvc approvalService.HppService HppSvc approvalService.HppService
HppV2Svc approvalService.HppV2Service
HppCostRepo commonRepo.HppCostRepository
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
DebtSupplierRepo repportRepo.DebtSupplierRepository DebtSupplierRepo repportRepo.DebtSupplierRepository
HppPerKandangRepo repportRepo.HppPerKandangRepository HppPerKandangRepo repportRepo.HppPerKandangRepository
@@ -85,12 +92,15 @@ func NewRepportService(
db *gorm.DB, db *gorm.DB,
validate *validator.Validate, validate *validator.Validate,
expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository,
expenseDepreciationRepo repportRepo.ExpenseDepreciationRepository,
marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository,
purchaseRepo purchaseRepo.PurchaseRepository, purchaseRepo purchaseRepo.PurchaseRepository,
chickinRepo chickinRepo.ProjectChickinRepository, chickinRepo chickinRepo.ProjectChickinRepository,
recordingRepo recordingRepo.RecordingRepository, recordingRepo recordingRepo.RecordingRepository,
approvalSvc approvalService.ApprovalService, approvalSvc approvalService.ApprovalService,
hppSvc approvalService.HppService, hppSvc approvalService.HppService,
hppV2Svc approvalService.HppV2Service,
hppCostRepo commonRepo.HppCostRepository,
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
debtSupplierRepo repportRepo.DebtSupplierRepository, debtSupplierRepo repportRepo.DebtSupplierRepository,
hppPerKandangRepo repportRepo.HppPerKandangRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository,
@@ -105,12 +115,15 @@ func NewRepportService(
Validate: validate, Validate: validate,
db: db, db: db,
ExpenseRealizationRepo: expenseRealizationRepo, ExpenseRealizationRepo: expenseRealizationRepo,
ExpenseDepreciationRepo: expenseDepreciationRepo,
MarketingDeliveryRepo: marketingDeliveryRepo, MarketingDeliveryRepo: marketingDeliveryRepo,
PurchaseRepo: purchaseRepo, PurchaseRepo: purchaseRepo,
ChickinRepo: chickinRepo, ChickinRepo: chickinRepo,
RecordingRepo: recordingRepo, RecordingRepo: recordingRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
HppSvc: hppSvc, HppSvc: hppSvc,
HppV2Svc: hppV2Svc,
HppCostRepo: hppCostRepo,
PurchaseSupplierRepo: purchaseSupplierRepo, PurchaseSupplierRepo: purchaseSupplierRepo,
DebtSupplierRepo: debtSupplierRepo, DebtSupplierRepo: debtSupplierRepo,
HppPerKandangRepo: hppPerKandangRepo, HppPerKandangRepo: hppPerKandangRepo,
@@ -164,6 +177,540 @@ func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuer
return result, total, nil 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) { func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
@@ -226,7 +773,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing
return nil, 0, err return nil, 0, err
} }
hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppSvc, attributionRows) hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppV2Svc, attributionRows)
categoryByDelivery := buildMarketingCategoryByDelivery(deliveryProducts, attributionRows) categoryByDelivery := buildMarketingCategoryByDelivery(deliveryProducts, attributionRows)
items := dto.ToMarketingReportItems(deliveryProducts, hppByDelivery, categoryByDelivery, agingMap) items := dto.ToMarketingReportItems(deliveryProducts, hppByDelivery, categoryByDelivery, agingMap)
@@ -235,7 +782,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing
func buildMarketingHppByDelivery( func buildMarketingHppByDelivery(
ctx context.Context, ctx context.Context,
hppSvc approvalService.HppService, hppSvc approvalService.HppV2Service,
attributionRows []commonRepo.MarketingDeliveryAttributionRow, attributionRows []commonRepo.MarketingDeliveryAttributionRow,
) map[uint]float64 { ) map[uint]float64 {
if len(attributionRows) == 0 { 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) 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) { func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
params, filters, err := s.parseHppPerKandangQuery(ctx) params, filters, err := s.parseHppPerKandangQuery(ctx)
if err != nil { if err != nil {
@@ -1791,20 +2359,22 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
var eggWeightFloat float64 var eggWeightFloat float64
var avgWeight float64 var avgWeight float64
eggHpp := 0.0 eggHpp := 0.0
if s.HppSvc != nil { if s.HppV2Svc != nil {
hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate) hppCost, err := s.HppV2Svc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if hppCost != nil { if hppCost != nil {
eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir
eggHpp = hppCost.Estimation.HargaKg // eggHpp = hppCost.Estimation.HargaKg
eggHpp = hppCost.Real.HargaKg
eggTotalPiecesFloat = hppCost.Estimation.Butir eggTotalPiecesFloat = hppCost.Estimation.Butir
eggWeightFloat = hppCost.Estimation.Kg eggWeightFloat = hppCost.Estimation.Kg
if eggTotalPiecesFloat > 0 { if eggTotalPiecesFloat > 0 {
avgWeight = eggWeightFloat / eggTotalPiecesFloat avgWeight = eggWeightFloat / eggTotalPiecesFloat
} }
eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining // eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining
eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg
} }
} }
if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { 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 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) { func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -75,6 +75,27 @@ type HppPerKandangQuery struct {
WeightMax *float64 `query:"-"` 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 { type ProductionResultQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"` Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
+17
View File
@@ -983,6 +983,23 @@ func describeRoute(route normalizedRoute) routeMeta {
{Name: "area_id", In: "query", Description: "Area id filter.", Example: 1}, {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"}, {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": case "/api/reports/marketing":
meta.QueryParams = []parameterMeta{ meta.QueryParams = []parameterMeta{
{Name: "page", In: "query", Description: "Page number.", Example: 1}, {Name: "page", In: "query", Description: "Page number.", Example: 1},
+11
View File
@@ -581,6 +581,17 @@ const (
KandangStatusActive KandangStatus = "ACTIVE" KandangStatusActive KandangStatus = "ACTIVE"
) )
// -------------------------------------------------------------------
// House Type
// -------------------------------------------------------------------
type HouseType string
const (
HouseTypeOpenHouse HouseType = "open_house"
HouseTypeCloseHouse HouseType = "close_house"
)
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Marketing Type // Marketing Type
// ------------------------------------------------------------------- // -------------------------------------------------------------------
File diff suppressed because it is too large Load Diff