mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ab4e1a6ef | |||
| 217f35b250 | |||
| 61d375a59a | |||
| 09242a6998 | |||
| 7639e30326 | |||
| 2216f572c2 | |||
| edfd6ac95c | |||
| aa3e655a67 | |||
| 98bfdac3c5 | |||
| a98d026ccb | |||
| c3eab60f49 | |||
| e455889dae | |||
| be8b99e7e8 | |||
| 1e8651b8f2 | |||
| 33bae94d43 | |||
| efe9f0ce3c | |||
| 1cd72e5598 | |||
| 7f701511d3 | |||
| 9405c9d64b | |||
| b179ed2bc9 | |||
| 255e6a16d3 | |||
| 93ed89b4ef | |||
| b9201c2a4f | |||
| f443686505 | |||
| 9d8d54bd3c | |||
| 791c5880fd | |||
| 0581bf4a17 | |||
| 1a5dfbb162 | |||
| b28ffdf9c6 | |||
| 90a921ff46 | |||
| 2c9ae1d5ab | |||
| bf93770798 | |||
| 98d031cc18 |
@@ -0,0 +1,266 @@
|
||||
// Command normalize-recording-cutover-depletion
|
||||
//
|
||||
// Data-only normalization of recording population metrics for a cut-over flock
|
||||
// where pre-cutover mortality (culling + dead) was booked via stock adjustments
|
||||
// (which do NOT feed the recording population). It applies an "opening depletion"
|
||||
// offset to the CUMULATIVE depletion of every recording in a project_flock_kandang,
|
||||
// recomputing the population-dependent metric columns DIRECTLY on the `recordings`
|
||||
// table.
|
||||
//
|
||||
// It does NOT touch recording_depletions, stock_allocations, product_warehouses,
|
||||
// project_flock_populations, or adjustment_stocks — so inventory/FIFO stay intact
|
||||
// (the existing adjustments keep owning the stock movement).
|
||||
//
|
||||
// Recomputed columns (per recording, ordered by record_datetime,id):
|
||||
//
|
||||
// cumDepByDate = running SUM(recording_depletions.qty) up to that recording (INVARIANT)
|
||||
// new_tcq = initialChickin - cumDepByDate - opening
|
||||
// cum_depletion_rate = (cumDepByDate + opening) / initialChickin * 100
|
||||
// feed_intake = feed_intake_old * (old_total_chick_qty / new_tcq) [null->null]
|
||||
// fcr_value = fcr_value_old * (old_total_chick_qty / new_tcq) [null->null]
|
||||
//
|
||||
// cum_intake and egg-based metrics are left untouched (see plan).
|
||||
//
|
||||
// Idempotent: only rows where total_chick_qty IS DISTINCT FROM new_tcq are updated.
|
||||
// Self-check: run with -opening=0; consistent rows are no-ops, any row that changes
|
||||
// was already inconsistent (stale) and gets reconciled to follow the depletion data.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=0 # self-check dry-run
|
||||
// DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=3126 # dry-run
|
||||
// DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=3126 -apply # apply
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type recRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
Day *int `gorm:"column:day"`
|
||||
RecordDate string `gorm:"column:record_date"`
|
||||
TotalChickQty *float64 `gorm:"column:total_chick_qty"`
|
||||
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
|
||||
FeedIntake *float64 `gorm:"column:feed_intake"`
|
||||
FcrValue *float64 `gorm:"column:fcr_value"`
|
||||
CumDepByDate float64 `gorm:"column:cum_dep_by_date"`
|
||||
}
|
||||
|
||||
const eps = 1e-6
|
||||
|
||||
func main() {
|
||||
var (
|
||||
pfk uint
|
||||
opening float64
|
||||
apply bool
|
||||
chickinOverride float64
|
||||
)
|
||||
flag.UintVar(&pfk, "pfk", 0, "project_flock_kandangs_id (required)")
|
||||
flag.Float64Var(&opening, "opening", 0, "opening depletion qty added to cumulative depletion of every recording")
|
||||
flag.BoolVar(&apply, "apply", false, "apply changes (default: dry-run)")
|
||||
flag.Float64Var(&chickinOverride, "chickin", 0, "override initial chickin base (0 = auto SUM project_chickins.usage_qty)")
|
||||
flag.Parse()
|
||||
|
||||
if pfk == 0 {
|
||||
log.Fatal("-pfk is required")
|
||||
}
|
||||
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
|
||||
// 1) initial chickin base
|
||||
var initialChickin float64
|
||||
if chickinOverride > 0 {
|
||||
initialChickin = chickinOverride
|
||||
} else {
|
||||
if err := db.Raw(
|
||||
`SELECT COALESCE(SUM(usage_qty),0) FROM project_chickins WHERE project_flock_kandang_id = ?`, pfk,
|
||||
).Scan(&initialChickin).Error; err != nil {
|
||||
log.Fatalf("query initial chickin: %v", err)
|
||||
}
|
||||
}
|
||||
if initialChickin <= 0 {
|
||||
log.Fatalf("initial chickin <= 0 for pfk %d (got %.3f)", pfk, initialChickin)
|
||||
}
|
||||
|
||||
// 2) sanity: duplicate record_datetime would make cumulative-by-date ambiguous
|
||||
var dupDatetimes int64
|
||||
if err := db.Raw(
|
||||
`SELECT COUNT(*) FROM (
|
||||
SELECT record_datetime FROM recordings
|
||||
WHERE project_flock_kandangs_id = ? AND deleted_at IS NULL
|
||||
GROUP BY record_datetime HAVING COUNT(*) > 1
|
||||
) t`, pfk,
|
||||
).Scan(&dupDatetimes).Error; err != nil {
|
||||
log.Fatalf("check duplicate datetimes: %v", err)
|
||||
}
|
||||
if dupDatetimes > 0 {
|
||||
fmt.Printf("WARNING: %d duplicate record_datetime group(s) for pfk %d — cumulative-by-date ordering may be ambiguous; review carefully.\n\n", dupDatetimes, pfk)
|
||||
}
|
||||
|
||||
// 3) load recordings + running cumulative depletion (by record_datetime, id)
|
||||
var rows []recRow
|
||||
q := `
|
||||
WITH dep AS (
|
||||
SELECT r.id, r.day, r.record_datetime,
|
||||
r.total_chick_qty, r.cum_depletion_rate, r.feed_intake, r.fcr_value,
|
||||
COALESCE((SELECT SUM(rd.qty) FROM recording_depletions rd WHERE rd.recording_id = r.id), 0) AS daily_dep
|
||||
FROM recordings r
|
||||
WHERE r.project_flock_kandangs_id = ? AND r.deleted_at IS NULL
|
||||
)
|
||||
SELECT id, day,
|
||||
to_char(record_datetime, 'YYYY-MM-DD') AS record_date,
|
||||
total_chick_qty, cum_depletion_rate, feed_intake, fcr_value,
|
||||
SUM(daily_dep) OVER (ORDER BY record_datetime, id) AS cum_dep_by_date
|
||||
FROM dep
|
||||
ORDER BY record_datetime, id`
|
||||
if err := db.Raw(q, pfk).Scan(&rows).Error; err != nil {
|
||||
log.Fatalf("query recordings: %v", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
log.Fatalf("no recordings found for pfk %d", pfk)
|
||||
}
|
||||
|
||||
mode := "DRY-RUN"
|
||||
if apply {
|
||||
mode = "APPLY"
|
||||
}
|
||||
fmt.Printf("=== normalize-recording-cutover-depletion ===\n")
|
||||
fmt.Printf("Mode: %s | pfk=%d | initialChickin=%.3f | opening=%.3f | recordings=%d\n\n", mode, pfk, initialChickin, opening, len(rows))
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "id\tday\tdate\ttcq_old->new\tcumRate_old->new\tfeed_old->new\tfcr_old->new\tstatus")
|
||||
|
||||
var willChange, anomalies, skipped int
|
||||
var negTcq int
|
||||
for _, r := range rows {
|
||||
newTcq := initialChickin - r.CumDepByDate - opening
|
||||
newRate := (r.CumDepByDate + opening) / initialChickin * 100
|
||||
|
||||
status := ""
|
||||
// detect pre-existing inconsistency (stale row): old tcq != invariant base (opening=0 expectation)
|
||||
expectedBase := initialChickin - r.CumDepByDate
|
||||
if r.TotalChickQty == nil || math.Abs(*r.TotalChickQty-expectedBase) > 1e-3 {
|
||||
status = "ANOMALY"
|
||||
anomalies++
|
||||
}
|
||||
|
||||
if newTcq < -eps {
|
||||
status = "NEG_TCQ!"
|
||||
negTcq++
|
||||
}
|
||||
|
||||
// idempotent guard
|
||||
if r.TotalChickQty != nil && math.Abs(*r.TotalChickQty-newTcq) < 1e-6 {
|
||||
if status == "" {
|
||||
status = "noop"
|
||||
}
|
||||
skipped++
|
||||
} else {
|
||||
willChange++
|
||||
}
|
||||
|
||||
var newFeed, newFcr *float64
|
||||
if r.FeedIntake != nil && r.TotalChickQty != nil && math.Abs(newTcq) > eps {
|
||||
v := *r.FeedIntake * (*r.TotalChickQty / newTcq)
|
||||
newFeed = &v
|
||||
} else {
|
||||
newFeed = r.FeedIntake
|
||||
}
|
||||
if r.FcrValue != nil && r.TotalChickQty != nil && math.Abs(newTcq) > eps {
|
||||
v := *r.FcrValue * (*r.TotalChickQty / newTcq)
|
||||
newFcr = &v
|
||||
} else {
|
||||
newFcr = r.FcrValue
|
||||
}
|
||||
|
||||
fmt.Fprintf(tw, "%d\t%s\t%s\t%s -> %.3f\t%s -> %.3f\t%s -> %s\t%s -> %s\t%s\n",
|
||||
r.ID, iptr(r.Day), r.RecordDate,
|
||||
fptr(r.TotalChickQty), newTcq,
|
||||
fptr(r.CumDepletionRate), newRate,
|
||||
fptr(r.FeedIntake), fptrV(newFeed),
|
||||
fptr(r.FcrValue), fptrV(newFcr),
|
||||
status,
|
||||
)
|
||||
}
|
||||
tw.Flush()
|
||||
|
||||
fmt.Printf("\nSummary: will_change=%d skipped(noop)=%d anomalies=%d neg_tcq=%d\n", willChange, skipped, anomalies, negTcq)
|
||||
if negTcq > 0 {
|
||||
log.Fatalf("ABORT: %d recording(s) would get negative total_chick_qty — opening too large or data issue", negTcq)
|
||||
}
|
||||
|
||||
if !apply {
|
||||
fmt.Println("\nDry-run only. Re-run with -apply to persist.")
|
||||
return
|
||||
}
|
||||
|
||||
// 4) APPLY — single set-based UPDATE in a transaction (RHS uses pre-update column values)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Exec(`
|
||||
WITH dep AS (
|
||||
SELECT r.id, r.record_datetime,
|
||||
COALESCE((SELECT SUM(rd.qty) FROM recording_depletions rd WHERE rd.recording_id = r.id), 0) AS daily_dep
|
||||
FROM recordings r
|
||||
WHERE r.project_flock_kandangs_id = ? AND r.deleted_at IS NULL
|
||||
),
|
||||
calc AS (
|
||||
SELECT id,
|
||||
(? - cum_dep - ?) AS new_tcq,
|
||||
((cum_dep + ?) / ? * 100) AS new_rate
|
||||
FROM (
|
||||
SELECT id, SUM(daily_dep) OVER (ORDER BY record_datetime, id) AS cum_dep
|
||||
FROM dep
|
||||
) s
|
||||
)
|
||||
UPDATE recordings r SET
|
||||
total_chick_qty = c.new_tcq,
|
||||
cum_depletion_rate = c.new_rate,
|
||||
feed_intake = CASE WHEN r.feed_intake IS NULL OR r.total_chick_qty IS NULL OR c.new_tcq = 0
|
||||
THEN r.feed_intake ELSE r.feed_intake * (r.total_chick_qty / c.new_tcq) END,
|
||||
fcr_value = CASE WHEN r.fcr_value IS NULL OR r.total_chick_qty IS NULL OR c.new_tcq = 0
|
||||
THEN r.fcr_value ELSE r.fcr_value * (r.total_chick_qty / c.new_tcq) END,
|
||||
updated_at = NOW()
|
||||
FROM calc c
|
||||
WHERE r.id = c.id
|
||||
AND r.total_chick_qty IS DISTINCT FROM c.new_tcq`,
|
||||
pfk,
|
||||
initialChickin, opening,
|
||||
opening, initialChickin,
|
||||
)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
fmt.Printf("\nAPPLIED: %d recording row(s) updated.\n", res.RowsAffected)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
fmt.Println("Done. Verify with the queries in tmp/pfk91-cutover-fix.md.")
|
||||
}
|
||||
|
||||
func fptr(p *float64) string {
|
||||
if p == nil {
|
||||
return "null"
|
||||
}
|
||||
return fmt.Sprintf("%.3f", *p)
|
||||
}
|
||||
|
||||
func fptrV(p *float64) string { return fptr(p) }
|
||||
|
||||
func iptr(p *int) string {
|
||||
if p == nil {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%d", *p)
|
||||
}
|
||||
@@ -96,6 +96,7 @@ type HppV2FarmDepreciationSnapshotRow struct {
|
||||
DepreciationPercentEffective float64
|
||||
DepreciationValue float64
|
||||
PulletCostDayNTotal float64
|
||||
Components []byte
|
||||
}
|
||||
|
||||
type HppV2CostRepository interface {
|
||||
@@ -114,6 +115,12 @@ type HppV2CostRepository interface {
|
||||
GetChickinPopulationByPFKForFarm(ctx context.Context, projectFlockID uint) (map[uint]float64, error)
|
||||
GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, projectFlockID uint) (map[string]map[int]float64, map[string]*time.Time, error)
|
||||
ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error)
|
||||
// ListLayingUsageCostRowsByProductFlags meng-anchor atribusi ke kandang recording
|
||||
// (recordings.project_flock_kandangs_id), bukan ke recording_stocks.project_flock_kandang_id.
|
||||
// Diperlukan karena pakan/OVK kandang LAYING yang dikonsumsi dari gudang tipe LOKASI
|
||||
// punya recording_stocks.project_flock_kandang_id = NULL — kasus ini harus tetap diatribusikan
|
||||
// ke kandang laying sebagai production_cost (bukan jatuh ke RECORDING_STOCK_ROUTE / pullet_cost).
|
||||
ListLayingUsageCostRowsByProductFlags(ctx context.Context, layingProjectFlockKandangID uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error)
|
||||
ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error)
|
||||
ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error)
|
||||
ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error)
|
||||
@@ -367,18 +374,19 @@ func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlo
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("pfk_rec.project_flock_id = ?", projectFlockID).
|
||||
Where("DATE(r.record_datetime) <= DATE(?)", periodDate).
|
||||
// Hanya routing cross-kandang ASLI: stok yang dicatat di recording kandang X tetapi
|
||||
// recording_stocks.project_flock_kandang_id menunjuk kandang lain (Y) saat ada transfer.
|
||||
// Cabang lama "NOT(transferExists) AND rs.pfk IS NULL" DIHAPUS — kasus pakan/OVK laying
|
||||
// dari gudang LOKASI (pfk NULL) kini diatribusikan sebagai production_cost via
|
||||
// ListLayingUsageCostRowsByProductFlags, sehingga kedua jalur jadi disjoint (tanpa dobel).
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"((%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id) OR (NOT (%s) AND rs.project_flock_kandang_id IS NULL)",
|
||||
transferExistsCondition,
|
||||
"(%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id",
|
||||
transferExistsCondition,
|
||||
),
|
||||
periodDate,
|
||||
string(utils.ApprovalWorkflowTransferToLaying),
|
||||
entity.ApprovalActionApproved,
|
||||
periodDate,
|
||||
string(utils.ApprovalWorkflowTransferToLaying),
|
||||
entity.ApprovalActionApproved,
|
||||
).
|
||||
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).
|
||||
Scan(&total).Error
|
||||
@@ -397,7 +405,7 @@ func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeri
|
||||
var row HppV2FarmDepreciationSnapshotRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("farm_depreciation_snapshots").
|
||||
Select("id, project_flock_id, period_date, depreciation_percent_effective, depreciation_value, pullet_cost_day_n_total").
|
||||
Select("id, project_flock_id, period_date, depreciation_percent_effective, depreciation_value, pullet_cost_day_n_total, components").
|
||||
Where("project_flock_id = ?", projectFlockID).
|
||||
Where("period_date = DATE(?)", periodDate).
|
||||
Limit(1).
|
||||
@@ -585,6 +593,91 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListLayingUsageCostRowsByProductFlags identik dengan ListUsageCostRowsByProductFlags,
|
||||
// tetapi atribusi baris ditentukan oleh kandang RECORDING (r.project_flock_kandangs_id),
|
||||
// dengan recording_stocks.project_flock_kandang_id boleh NULL (gudang LOKASI) atau sama
|
||||
// dengan kandang laying. Baris yang routed ke kandang lain (rs.pfk <> kandang recording)
|
||||
// SENGAJA TIDAK diikutkan di sini — itu ranah RECORDING_STOCK_ROUTE.
|
||||
func (r *HppV2RepositoryImpl) ListLayingUsageCostRowsByProductFlags(
|
||||
ctx context.Context,
|
||||
layingProjectFlockKandangID uint,
|
||||
flagNames []string,
|
||||
date *time.Time,
|
||||
) ([]HppV2UsageCostRow, error) {
|
||||
if layingProjectFlockKandangID == 0 || len(flagNames) == 0 {
|
||||
return []HppV2UsageCostRow{}, nil
|
||||
}
|
||||
if date == nil {
|
||||
now := time.Now()
|
||||
date = &now
|
||||
}
|
||||
|
||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
|
||||
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
|
||||
|
||||
rows := make([]HppV2UsageCostRow, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select(`
|
||||
sa.stockable_type AS stockable_type,
|
||||
sa.stockable_id AS stockable_id,
|
||||
COALESCE(pi.product_id, ast_pw.product_id, 0) AS source_product_id,
|
||||
COALESCE(pi_prod.name, ast_prod.name, '') AS source_product_name,
|
||||
COALESCE(SUM(sa.qty), 0) AS qty,
|
||||
COALESCE(MAX(CASE
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
||||
ELSE 0
|
||||
END), 0) AS unit_price,
|
||||
COALESCE(SUM(sa.qty * CASE
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
||||
ELSE 0
|
||||
END), 0) AS total_cost,
|
||||
MIN(r.record_datetime) AS first_used_at,
|
||||
MAX(r.record_datetime) AS last_used_at
|
||||
`,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
).
|
||||
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
||||
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||
Joins(
|
||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
usableRecordingStock,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id").
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id").
|
||||
Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id").
|
||||
Where("r.project_flock_kandangs_id = ?", layingProjectFlockKandangID).
|
||||
Where("(rs.project_flock_kandang_id IS NULL OR rs.project_flock_kandang_id = ?)", layingProjectFlockKandangID).
|
||||
Where("r.deleted_at IS NULL").
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames).
|
||||
Group(`
|
||||
sa.stockable_type,
|
||||
sa.stockable_id,
|
||||
COALESCE(pi.product_id, ast_pw.product_id, 0),
|
||||
COALESCE(pi_prod.name, ast_prod.name, '')
|
||||
`).
|
||||
Order("MIN(r.record_datetime) ASC, sa.stockable_type ASC, sa.stockable_id ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags(
|
||||
ctx context.Context,
|
||||
projectFlockKandangIDs []uint,
|
||||
|
||||
@@ -192,19 +192,27 @@ func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t
|
||||
|
||||
repo := &HppV2RepositoryImpl{db: db}
|
||||
|
||||
// Route sekarang HANYA menangkap routing cross-kandang asli
|
||||
// (transferExists AND rs.pfk IS NOT NULL AND rs.pfk <> r.project_flock_kandangs_id).
|
||||
// Baris pfk NULL (gudang LOKASI) tidak lagi masuk route — kini jadi production_cost
|
||||
// laying-usage via ListLayingUsageCostRowsByProductFlags.
|
||||
// Pada 2026-04-30 hanya rs 102 yang lolos: recording pfk 101 (transfer 1001 approved &
|
||||
// executed, effective 04-05 <= 04-30), rs.pfk 201 <> 101 → 1 × 110 = 110.
|
||||
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
|
||||
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
assertFloatEquals(t, total, 750)
|
||||
assertFloatEquals(t, total, 110)
|
||||
|
||||
// Pada 2026-04-10 hanya recording pfk 101 & 102 yang masuk rentang tanggal; tetap hanya
|
||||
// rs 102 (cross-kandang) yang lolos → 110.
|
||||
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
|
||||
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
assertFloatEquals(t, earlyTotal, 240)
|
||||
assertFloatEquals(t, earlyTotal, 110)
|
||||
}
|
||||
|
||||
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
@@ -55,6 +56,10 @@ type HppV2Service interface {
|
||||
GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
|
||||
GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
|
||||
GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
|
||||
// GetBopRegularProductionScopeRange / GetBopEkspedisiProductionScopeRange mengembalikan BOP
|
||||
// production_cost untuk rentang [startDate, endDate] secara range-correct (tidak pernah negatif).
|
||||
GetBopRegularProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error)
|
||||
GetBopEkspedisiProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error)
|
||||
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
|
||||
}
|
||||
|
||||
@@ -453,7 +458,7 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat
|
||||
total += growingCutoverPart.Total
|
||||
}
|
||||
|
||||
layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, false)
|
||||
layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, endDate, config, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -462,7 +467,7 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat
|
||||
total += layingNormalPart.Total
|
||||
}
|
||||
|
||||
layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, true)
|
||||
layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, endDate, config, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -737,6 +742,7 @@ func (s *hppV2Service) buildGrowingUsagePart(
|
||||
|
||||
func (s *hppV2Service) buildLayingUsagePart(
|
||||
projectFlockKandangId uint,
|
||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||
endDate *time.Time,
|
||||
config hppV2StockComponentConfig,
|
||||
cutover bool,
|
||||
@@ -778,7 +784,16 @@ func (s *hppV2Service) buildLayingUsagePart(
|
||||
}, nil
|
||||
}
|
||||
|
||||
rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate)
|
||||
// Untuk kandang LAYING, atribusi pakan/OVK berbasis kandang recording (termasuk konsumsi
|
||||
// dari gudang LOKASI yang punya recording_stocks.project_flock_kandang_id = NULL). Untuk
|
||||
// kandang non-laying, pertahankan semantik lama (strict rs.project_flock_kandang_id IN [pfk]).
|
||||
var rows []commonRepo.HppV2UsageCostRow
|
||||
var err error
|
||||
if contextRow != nil && contextRow.ProjectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
|
||||
rows, err = s.hppRepo.ListLayingUsageCostRowsByProductFlags(context.Background(), projectFlockKandangId, config.NormalFlags, endDate)
|
||||
} else {
|
||||
rows, err = s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -931,17 +946,48 @@ func (s *hppV2Service) buildLayingExpenseFarmPart(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
|
||||
ratio, proration, err := s.layingFarmExpenseRatio(projectFlockKandangId, contextRow, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ratio <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return buildExpensePartFromRows(
|
||||
rows,
|
||||
hppV2PartLayingFarm,
|
||||
"Laying Farm",
|
||||
[]string{hppV2ScopeProductionCost},
|
||||
proration,
|
||||
ratio,
|
||||
), nil
|
||||
}
|
||||
|
||||
// layingFarmExpenseRatio menghitung porsi (share) kandang laying terhadap seluruh farm pada
|
||||
// endDate berdasarkan bobot telur KUMULATIF (fallback ke jumlah butir bila bobot 0). Return
|
||||
// ratio 0 bila tak terhitung. Diekstrak agar dipakai bersama oleh buildLayingExpenseFarmPart
|
||||
// dan GetExpenseProductionScopeRange (perhitungan BOP range-correct).
|
||||
func (s *hppV2Service) layingFarmExpenseRatio(
|
||||
projectFlockKandangId uint,
|
||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||
endDate *time.Time,
|
||||
) (float64, *HppV2Proration, error) {
|
||||
if contextRow == nil {
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, nil, err
|
||||
}
|
||||
farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
basis := hppV2ProrationEggWeight
|
||||
@@ -953,27 +999,120 @@ func (s *hppV2Service) buildLayingExpenseFarmPart(
|
||||
denominator = farmPieces
|
||||
}
|
||||
if denominator <= 0 {
|
||||
return nil, nil
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
ratio := numerator / denominator
|
||||
if ratio <= 0 {
|
||||
return nil, nil
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
return buildExpensePartFromRows(
|
||||
rows,
|
||||
hppV2PartLayingFarm,
|
||||
"Laying Farm",
|
||||
[]string{hppV2ScopeProductionCost},
|
||||
&HppV2Proration{
|
||||
return ratio, &HppV2Proration{
|
||||
Basis: basis,
|
||||
Numerator: numerator,
|
||||
Denominator: denominator,
|
||||
Ratio: ratio,
|
||||
},
|
||||
ratio,
|
||||
), nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetExpenseProductionScopeRange menghitung BOP production_cost satu komponen expense untuk rentang
|
||||
// [startDate, endDate] secara range-correct (tidak pernah negatif untuk expense non-negatif).
|
||||
// - laying-direct (ratio 1, monoton): selisih kumulatif end - start.
|
||||
// - laying-farm (prorated): (expenseCum(end) - expenseCum(start)) × ratio(end).
|
||||
//
|
||||
// Ini mengganti pola lama di report yang men-differensiasi dua angka yang sudah diprorata dengan
|
||||
// ratio berbeda (ratio(end) vs ratio(start)) — sumber bug BOP negatif saat share antar kandang bergeser.
|
||||
func (s *hppV2Service) GetExpenseProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time, config hppV2ExpenseComponentConfig) (float64, error) {
|
||||
if s.hppRepo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Samakan semantik tanggal dengan CalculateHppBreakdown: kumulatif dihitung sampai AKHIR hari
|
||||
// (endOfDay). Penting karena ratio egg-weight memakai r.record_datetime (granular jam).
|
||||
_, endOfEndDay, err := hppV2DayWindow(endDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_, endOfStartDay, err := hppV2DayWindow(startDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// laying-direct: delta kumulatif (monoton, >= 0).
|
||||
directEnd, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, &endOfEndDay, config)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
directStart, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, &endOfStartDay, config)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
directDelta := hppV2PartTotal(directEnd) - hppV2PartTotal(directStart)
|
||||
if directDelta < 0 {
|
||||
directDelta = 0
|
||||
}
|
||||
|
||||
// laying-farm: delta expense kumulatif × ratio(end).
|
||||
farmRowsEnd, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, &endOfEndDay, config.Ekspedisi)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
farmRowsStart, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, &endOfStartDay, config.Ekspedisi)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
farmExpenseDelta := hppV2SumExpenseRows(farmRowsEnd) - hppV2SumExpenseRows(farmRowsStart)
|
||||
if farmExpenseDelta < 0 {
|
||||
farmExpenseDelta = 0
|
||||
}
|
||||
farmDelta := 0.0
|
||||
if farmExpenseDelta > 0 {
|
||||
ratio, _, err := s.layingFarmExpenseRatio(projectFlockKandangId, contextRow, &endOfEndDay)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
farmDelta = farmExpenseDelta * ratio
|
||||
}
|
||||
|
||||
return directDelta + farmDelta, nil
|
||||
}
|
||||
|
||||
// GetBopRegularProductionScopeRange / GetBopEkspedisiProductionScopeRange — wrapper range-correct
|
||||
// untuk dua komponen BOP, memakai config yang sama dengan GetBopRegularBreakdown/GetBopEkspedisiBreakdown.
|
||||
func (s *hppV2Service) GetBopRegularProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error) {
|
||||
return s.GetExpenseProductionScopeRange(projectFlockKandangId, startDate, endDate, hppV2ExpenseComponentConfig{
|
||||
Code: hppV2ComponentBopRegular,
|
||||
Title: "BOP Regular",
|
||||
Ekspedisi: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *hppV2Service) GetBopEkspedisiProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error) {
|
||||
return s.GetExpenseProductionScopeRange(projectFlockKandangId, startDate, endDate, hppV2ExpenseComponentConfig{
|
||||
Code: hppV2ComponentBopEksp,
|
||||
Title: "BOP Ekspedisi",
|
||||
Ekspedisi: true,
|
||||
})
|
||||
}
|
||||
|
||||
func hppV2PartTotal(part *HppV2ComponentPart) float64 {
|
||||
if part == nil {
|
||||
return 0
|
||||
}
|
||||
return part.Total
|
||||
}
|
||||
|
||||
func hppV2SumExpenseRows(rows []commonRepo.HppV2ExpenseCostRow) float64 {
|
||||
total := 0.0
|
||||
for _, row := range rows {
|
||||
total += row.TotalCost
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *hppV2Service) getManualPulletCostComponent(
|
||||
@@ -1334,6 +1473,18 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
|
||||
depreciationPercent = (appliedDepreciation / appliedPulletCostDayN) * 100
|
||||
}
|
||||
|
||||
details := map[string]any{
|
||||
"basis_total": snapshot.DepreciationValue,
|
||||
"pullet_cost_day_n": appliedPulletCostDayN,
|
||||
"depreciation_percent": depreciationPercent,
|
||||
"snapshot_id": snapshot.ID,
|
||||
"snapshot_period_date": formatDateOnly(snapshot.PeriodDate),
|
||||
"snapshot_project_flock": snapshot.ProjectFlockID,
|
||||
}
|
||||
for key, value := range farmDepreciationSnapshotMetadata(snapshot.Components, projectFlockKandangId) {
|
||||
details[key] = value
|
||||
}
|
||||
|
||||
return &HppV2ComponentPart{
|
||||
Code: hppV2PartDepreciationFarmSnapshot,
|
||||
Title: "Farm Snapshot",
|
||||
@@ -1345,14 +1496,7 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
|
||||
Denominator: denominator,
|
||||
Ratio: ratio,
|
||||
},
|
||||
Details: map[string]any{
|
||||
"basis_total": snapshot.DepreciationValue,
|
||||
"pullet_cost_day_n": appliedPulletCostDayN,
|
||||
"depreciation_percent": depreciationPercent,
|
||||
"snapshot_id": snapshot.ID,
|
||||
"snapshot_period_date": formatDateOnly(snapshot.PeriodDate),
|
||||
"snapshot_project_flock": snapshot.ProjectFlockID,
|
||||
},
|
||||
Details: details,
|
||||
References: []HppV2Reference{
|
||||
{
|
||||
Type: "farm_depreciation_snapshot",
|
||||
@@ -1366,6 +1510,84 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
|
||||
}, nil
|
||||
}
|
||||
|
||||
type farmDepreciationSnapshotComponents struct {
|
||||
Kandang []farmDepreciationSnapshotKandangComponent `json:"kandang"`
|
||||
}
|
||||
|
||||
type farmDepreciationSnapshotKandangComponent struct {
|
||||
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
|
||||
DayN int `json:"day_n"`
|
||||
MultiplicationPercent float64 `json:"multiplication_percentage"`
|
||||
ChickinDate string `json:"chickin_date"`
|
||||
OriginDate string `json:"origin_date"`
|
||||
StandardEffectiveDate string `json:"standard_effective_date"`
|
||||
Population float64 `json:"population"`
|
||||
}
|
||||
|
||||
func farmDepreciationSnapshotMetadata(raw []byte, projectFlockKandangID uint) map[string]any {
|
||||
result := make(map[string]any)
|
||||
if len(raw) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
var components farmDepreciationSnapshotComponents
|
||||
if err := json.Unmarshal(raw, &components); err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
var fallback *farmDepreciationSnapshotKandangComponent
|
||||
for i := range components.Kandang {
|
||||
component := &components.Kandang[i]
|
||||
if !component.hasDepreciationMetadata() {
|
||||
continue
|
||||
}
|
||||
if component.ProjectFlockKandangID == projectFlockKandangID {
|
||||
return component.snapshotDetails()
|
||||
}
|
||||
if fallback == nil {
|
||||
fallback = component
|
||||
}
|
||||
}
|
||||
if fallback != nil {
|
||||
return fallback.snapshotDetails()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (c farmDepreciationSnapshotKandangComponent) hasDepreciationMetadata() bool {
|
||||
return c.DayN > 0 ||
|
||||
c.MultiplicationPercent > 0 ||
|
||||
c.ChickinDate != "" ||
|
||||
c.OriginDate != "" ||
|
||||
c.StandardEffectiveDate != "" ||
|
||||
c.Population > 0
|
||||
}
|
||||
|
||||
func (c farmDepreciationSnapshotKandangComponent) snapshotDetails() map[string]any {
|
||||
chickinDate := c.ChickinDate
|
||||
if chickinDate == "" {
|
||||
chickinDate = c.OriginDate
|
||||
}
|
||||
|
||||
details := map[string]any{
|
||||
"schedule_day": c.DayN,
|
||||
"multiplication_percentage": c.MultiplicationPercent,
|
||||
}
|
||||
if chickinDate != "" {
|
||||
details["origin_date"] = chickinDate
|
||||
details["chickin_date"] = chickinDate
|
||||
}
|
||||
if c.StandardEffectiveDate != "" {
|
||||
details["standard_effective_date"] = c.StandardEffectiveDate
|
||||
}
|
||||
if c.Population > 0 {
|
||||
details["kandang_population"] = c.Population
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func (s *hppV2Service) buildNormalTransferDepreciationPart(
|
||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||
transferInput *commonRepo.HppV2LatestTransferInputRow,
|
||||
|
||||
@@ -25,6 +25,10 @@ type hppV2RepoStub struct {
|
||||
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
|
||||
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
// expenseRowsByFarmDateKey (opsional) membuat ListExpenseRealizationRowsByProjectFlockID
|
||||
// date-aware untuk menguji perhitungan range BOP. Bila non-nil, dipakai menggantikan
|
||||
// expenseRowsByFarmKey; key = "<flock>|<ekspedisi>|<YYYY-MM-DD>".
|
||||
expenseRowsByFarmDateKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
routeCostByProject map[uint]float64
|
||||
totalPopulationByKey map[string]float64
|
||||
transferSummaryByPFK map[uint]struct {
|
||||
@@ -118,6 +122,10 @@ func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, proje
|
||||
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) ListLayingUsageCostRowsByProductFlags(_ context.Context, layingProjectFlockKandangID uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
|
||||
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey([]uint{layingProjectFlockKandangID}, 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
|
||||
}
|
||||
@@ -126,7 +134,10 @@ func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ con
|
||||
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) {
|
||||
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
|
||||
if s.expenseRowsByFarmDateKey != nil && date != nil {
|
||||
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmDateKey[expenseFarmDateKey(projectFlockID, ekspedisi, *date)]...), nil
|
||||
}
|
||||
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil
|
||||
}
|
||||
|
||||
@@ -814,6 +825,28 @@ func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggPro
|
||||
DepreciationPercentEffective: 10,
|
||||
DepreciationValue: 1000,
|
||||
PulletCostDayNTotal: 10000,
|
||||
Components: []byte(`{
|
||||
"kandang_count": 2,
|
||||
"total_population": 1000,
|
||||
"kandang": [
|
||||
{
|
||||
"project_flock_kandang_id": 71,
|
||||
"day_n": 5,
|
||||
"multiplication_percentage": 0.95,
|
||||
"chickin_date": "2026-01-02",
|
||||
"standard_effective_date": "2026-06-01",
|
||||
"population": 800
|
||||
},
|
||||
{
|
||||
"project_flock_kandang_id": 70,
|
||||
"day_n": 7,
|
||||
"multiplication_percentage": 0.93,
|
||||
"chickin_date": "2026-01-01",
|
||||
"standard_effective_date": "2026-06-02",
|
||||
"population": 200
|
||||
}
|
||||
]
|
||||
}`),
|
||||
},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
@@ -862,6 +895,21 @@ func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggPro
|
||||
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
|
||||
t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if depreciation.Parts[0].Details["schedule_day"] != 7 {
|
||||
t.Fatalf("expected snapshot schedule_day 7, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if depreciation.Parts[0].Details["multiplication_percentage"] != 0.93 {
|
||||
t.Fatalf("expected snapshot multiplication_percentage 0.93, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if depreciation.Parts[0].Details["chickin_date"] != "2026-01-01" {
|
||||
t.Fatalf("expected snapshot chickin_date 2026-01-01, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if depreciation.Parts[0].Details["standard_effective_date"] != "2026-06-02" {
|
||||
t.Fatalf("expected snapshot standard_effective_date 2026-06-02, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if depreciation.Parts[0].Details["kandang_population"] != float64(200) {
|
||||
t.Fatalf("expected snapshot kandang_population 200, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
func stubKey(ids []uint, flags []string) string {
|
||||
@@ -904,6 +952,108 @@ func expenseFarmKey(projectFlockID uint, ekspedisi bool) string {
|
||||
return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi)
|
||||
}
|
||||
|
||||
func expenseFarmDateKey(projectFlockID uint, ekspedisi bool, date time.Time) string {
|
||||
return fmt.Sprintf("%d|%t|%s", projectFlockID, ekspedisi, date.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string {
|
||||
return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying)))
|
||||
}
|
||||
|
||||
// TestHppV2PakanBreakdown_LayingAttributesLokasiFeedAsProductionCost membuktikan Fix 1:
|
||||
// untuk kandang LAYING, pemakaian pakan (termasuk dari gudang LOKASI dengan pfk NULL) diatribusikan
|
||||
// sebagai production_cost via ListLayingUsageCostRowsByProductFlags — BUKAN pullet_cost.
|
||||
// Stub memetakan ListLayingUsageCostRowsByProductFlags(50,...) ke usageRowsByKey[[50]+PAKAN].
|
||||
func TestHppV2PakanBreakdown_LayingAttributesLokasiFeedAsProductionCost(t *testing.T) {
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
50: {
|
||||
ProjectFlockKandangID: 50,
|
||||
ProjectFlockID: 20,
|
||||
ProjectFlockCategory: string(utils.ProjectFlockCategoryLaying),
|
||||
KandangID: 1,
|
||||
LocationID: 14,
|
||||
HouseType: "close_house",
|
||||
},
|
||||
},
|
||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
||||
stubKey([]uint{50}, []string{"PAKAN"}): {
|
||||
{StockableType: "purchase_items", StockableID: 9001, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 310, UnitPrice: 1, TotalCost: 310},
|
||||
},
|
||||
},
|
||||
// Tanpa transferSummaryByPFK[50] -> growing part nil; tanpa adjustRowsByKey -> laying cutover nil.
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
component, err := svc.GetPakanBreakdown(50, mustDate(t, "2026-05-31"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if component == nil {
|
||||
t.Fatal("expected PAKAN component")
|
||||
}
|
||||
if component.Total != 310 {
|
||||
t.Fatalf("expected component total 310, got %v", component.Total)
|
||||
}
|
||||
if len(component.Parts) != 1 || component.Parts[0].Code != hppV2PartLayingNormal {
|
||||
t.Fatalf("expected single laying_normal part, got %+v", component.Parts)
|
||||
}
|
||||
if got := componentScopeTotal(component, hppV2ScopeProductionCost); got != 310 {
|
||||
t.Fatalf("expected production_cost 310, got %v", got)
|
||||
}
|
||||
if got := componentScopeTotal(component, hppV2ScopePulletCost); got != 0 {
|
||||
t.Fatalf("expected pullet_cost 0 (feed laying bukan pullet), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHppV2BopProductionScopeRange_NonNegativeAndProrated membuktikan Fix 2: BOP farm-level dihitung
|
||||
// sebagai (expenseCum(end) - expenseCum(start)) × ratio(end) — range-correct & tidak pernah negatif.
|
||||
// Range [2026-04-30, 2026-05-31] -> engine memakai endOfDay: start=2026-05-01, end=2026-06-01.
|
||||
// Share kandang 50 = 30/(30+70) = 0.3.
|
||||
// - REGULAR: expense farm tumbuh 1000 -> 1300 (delta 300) => 300 × 0.3 = 90.
|
||||
// - EKSPEDISI: expense farm "turun" 500 -> 200 (delta -300, kasus uji clamp) => di-clamp ke 0.
|
||||
func TestHppV2BopProductionScopeRange_NonNegativeAndProrated(t *testing.T) {
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
50: {ProjectFlockKandangID: 50, ProjectFlockID: 20, ProjectFlockCategory: string(utils.ProjectFlockCategoryLaying)},
|
||||
},
|
||||
pfkIDsByProject: map[uint][]uint{
|
||||
20: {50, 51},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
50: {pieces: 300, kg: 30},
|
||||
51: {pieces: 700, kg: 70},
|
||||
},
|
||||
expenseRowsByFarmDateKey: map[string][]commonRepo.HppV2ExpenseCostRow{
|
||||
// REGULAR (ekspedisi=false): kumulatif 1000 (start) -> 1300 (end)
|
||||
expenseFarmDateKey(20, false, mustTime(t, "2026-05-01")): {{TotalCost: 1000}},
|
||||
expenseFarmDateKey(20, false, mustTime(t, "2026-06-01")): {{TotalCost: 800}, {TotalCost: 500}},
|
||||
// EKSPEDISI (ekspedisi=true): kumulatif 500 (start) -> 200 (end) => delta negatif, harus di-clamp
|
||||
expenseFarmDateKey(20, true, mustTime(t, "2026-05-01")): {{TotalCost: 500}},
|
||||
expenseFarmDateKey(20, true, mustTime(t, "2026-06-01")): {{TotalCost: 200}},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
start := mustDate(t, "2026-04-30")
|
||||
end := mustDate(t, "2026-05-31")
|
||||
|
||||
reg, err := svc.GetBopRegularProductionScopeRange(50, start, end)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if reg != 90 {
|
||||
t.Fatalf("expected BOP regular range 90 (300 × 0.3), got %v", reg)
|
||||
}
|
||||
|
||||
eksp, err := svc.GetBopEkspedisiProductionScopeRange(50, start, end)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if eksp != 0 {
|
||||
t.Fatalf("expected BOP ekspedisi range clamped to 0 (delta negatif), got %v", eksp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,10 +195,13 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
|
||||
|
||||
if remaining > 0 {
|
||||
if !allowOverConsume {
|
||||
return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty)
|
||||
}
|
||||
s.logger.Warnf("FIFO v2: clearing historical pending (%.3f) for %s/%d at PW=%d — over-consume is blocked by rule",
|
||||
remaining, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID)
|
||||
result.PendingQty = 0
|
||||
} else {
|
||||
result.PendingQty = remaining
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil {
|
||||
return nil, err
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
BEGIN;
|
||||
|
||||
-- Rollback: re-insert TELUR/TELUR_GRADE block rules yang dihapus oleh migration ini.
|
||||
|
||||
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
|
||||
SELECT 'TELUR', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur', TRUE
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE lane = 'USABLE'
|
||||
AND function_code = 'MARKETING_OUT'
|
||||
AND flag_group_code = 'TELUR'
|
||||
AND reason = 'fifo_v2_exception_marketing_block_telur'
|
||||
);
|
||||
|
||||
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
|
||||
SELECT 'TELUR_GRADE', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur_grade', TRUE
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE lane = 'USABLE'
|
||||
AND function_code = 'MARKETING_OUT'
|
||||
AND flag_group_code = 'TELUR_GRADE'
|
||||
AND reason = 'fifo_v2_exception_marketing_block_telur_grade'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
BEGIN;
|
||||
|
||||
-- Revert rules yang ditambahkan oleh migration 20260603031237_block_marketing_overconsume_telur.
|
||||
-- TELUR/TELUR_GRADE kembali fallback ke default allow rule (allow_overconsume=TRUE)
|
||||
-- karena validasi stok sekarang ditangani di service layer (code validation) bukan lewat
|
||||
-- config overconsume FIFO v2.
|
||||
|
||||
DELETE FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE lane = 'USABLE'
|
||||
AND function_code = 'MARKETING_OUT'
|
||||
AND flag_group_code IN ('TELUR', 'TELUR_GRADE')
|
||||
AND reason IN (
|
||||
'fifo_v2_exception_marketing_block_telur',
|
||||
'fifo_v2_exception_marketing_block_telur_grade'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
-- Reverse UPSERT: hapus baris PFK 47 & 48 yang kemungkinan baru diinsert oleh up migration ini.
|
||||
-- Jika sebelumnya sudah ada (ON CONFLICT DO UPDATE), baris ini akan terhapus —
|
||||
-- restore manual dari backup jika diperlukan.
|
||||
DELETE FROM farm_depreciation_manual_inputs
|
||||
WHERE project_flock_id IN (47, 48);
|
||||
|
||||
-- UPDATE rows untuk PFK 4–27 tidak bisa di-reverse secara presisi:
|
||||
-- nilai total_cost sebelum migration ini tidak tersimpan di migration history
|
||||
-- (data awal di-load via cmd/import-farm-depreciation-manual-inputs dari Excel).
|
||||
-- PFK 10 dan 11 tidak berubah (nilai sama dengan state dari migration 20260529144559).
|
||||
-- Jika perlu rollback penuh: restore dari database backup atau re-import Excel lama.
|
||||
|
||||
-- Recompute snapshots setelah rollback
|
||||
TRUNCATE TABLE farm_depreciation_snapshots;
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 1900157533.55,
|
||||
cutover_date = DATE '2026-02-28',
|
||||
updated_at = NOW()
|
||||
WHERE project_flock_id = 10;
|
||||
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 146658321.066,
|
||||
cutover_date = DATE '2026-02-28',
|
||||
updated_at = NOW()
|
||||
WHERE project_flock_id = 13;
|
||||
|
||||
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 51824694.138,
|
||||
cutover_date = DATE '2026-02-28',
|
||||
updated_at = NOW()
|
||||
WHERE project_flock_id = 17;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 15491774.796,
|
||||
cutover_date = DATE '2026-02-28',
|
||||
updated_at = NOW()
|
||||
WHERE project_flock_id = 8;
|
||||
|
||||
|
||||
|
||||
|
||||
-- Cutover 2026-02-28 (lanjutan)
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 575074391.36, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 4;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 578360642.51, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 5;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 880983605.92, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 6;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 391669576.153, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 9;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 2521797832.14, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 11;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 139227054.164, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 12;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 380083106.836, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 14;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 705136853.847, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 15;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 209816474.000, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 18;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 557606867.000, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 19;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 239330456.11, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 20;
|
||||
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 4724203916.72, cutover_date = DATE '2026-02-28', updated_at = NOW()
|
||||
WHERE project_flock_id = 26;
|
||||
|
||||
-- Cutover 2026-05-15
|
||||
UPDATE farm_depreciation_manual_inputs
|
||||
SET total_cost = 5449963647.43, cutover_date = DATE '2026-05-15', updated_at = NOW()
|
||||
WHERE project_flock_id = 27;
|
||||
|
||||
-- Cutover 2026-06-08 (upsert — row mungkin belum ada)
|
||||
INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at)
|
||||
VALUES (47, 5395429899.42, DATE '2026-06-08', NOW(), NOW())
|
||||
ON CONFLICT (project_flock_id) DO UPDATE
|
||||
SET total_cost = EXCLUDED.total_cost,
|
||||
cutover_date = EXCLUDED.cutover_date,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Cutover 2026-06-16 (upsert — row mungkin belum ada)
|
||||
INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at)
|
||||
VALUES (48, 5514616442.08, DATE '2026-06-16', NOW(), NOW())
|
||||
ON CONFLICT (project_flock_id) DO UPDATE
|
||||
SET total_cost = EXCLUDED.total_cost,
|
||||
cutover_date = EXCLUDED.cutover_date,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Pengaman: pastikan snapshot di-recompute dengan total_cost baru
|
||||
-- saat user request /api/reports/expense/depreciation
|
||||
TRUNCATE TABLE farm_depreciation_snapshots;
|
||||
@@ -789,11 +789,56 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
|
||||
|
||||
totalActualPopulation := totalChickinQty - totalDepletion
|
||||
|
||||
// Prefer recording-based population (recordings.total_chick_qty) so closing stays
|
||||
// consistent with normalized cut-over flocks. For normal flocks this equals
|
||||
// chickin - depletion (no-op); it only differs when the recording population was
|
||||
// normalized separately from recording_depletions. Falls back if any kandang in
|
||||
// scope lacks a recording.
|
||||
scopeKandangs := projectFlockKandangs
|
||||
if projectFlockKandangID != nil {
|
||||
scopeKandangs = nil
|
||||
for _, k := range projectFlockKandangs {
|
||||
if k.Id == *projectFlockKandangID {
|
||||
scopeKandangs = append(scopeKandangs, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if recPop, ok := s.actualPopulationFromRecordings(c.Context(), scopeKandangs); ok {
|
||||
totalActualPopulation = recPop
|
||||
}
|
||||
|
||||
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount)
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// actualPopulationFromRecordings sums the latest recordings.total_chick_qty across the
|
||||
// given kandangs (the production population source of truth). Returns ok=false if any
|
||||
// kandang lacks a recording, so the caller falls back to chickin-minus-depletion.
|
||||
// For normal flocks this equals chickin - depletion; it only differs for cut-over flocks
|
||||
// whose recording population was normalized separately from recording_depletions.
|
||||
func (s closingService) actualPopulationFromRecordings(ctx context.Context, kandangs []entity.ProjectFlockKandang) (float64, bool) {
|
||||
if s.RecordingRepo == nil || len(kandangs) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
total := 0.0
|
||||
for _, k := range kandangs {
|
||||
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx, k.Id)
|
||||
if err != nil {
|
||||
s.Log.Warnf("actualPopulationFromRecordings: latest recording pfk=%d: %v", k.Id, err)
|
||||
return 0, false
|
||||
}
|
||||
if latest == nil || latest.TotalChickQty == nil {
|
||||
return 0, false
|
||||
}
|
||||
if *latest.TotalChickQty > 0 {
|
||||
total += *latest.TotalChickQty
|
||||
}
|
||||
}
|
||||
return total, true
|
||||
}
|
||||
|
||||
type activeKandangMetricRow struct {
|
||||
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
|
||||
ProjectFlockID uint `gorm:"column:project_flock_id"`
|
||||
|
||||
@@ -156,7 +156,7 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl
|
||||
|
||||
hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData)
|
||||
|
||||
profitLossSection := s.buildProfitLossSection(projectFlock, costs, productionData)
|
||||
profitLossSection := s.buildProfitLossSection(c, projectFlock, projectFlockKandangs, costs, productionData)
|
||||
|
||||
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
|
||||
return &data, nil
|
||||
@@ -386,7 +386,7 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti
|
||||
return dto.ToHPPSection(hppItems, hppSummary)
|
||||
}
|
||||
|
||||
func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection {
|
||||
func (s closingKeuanganService) buildProfitLossSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.ProfitLossSection {
|
||||
|
||||
totalWeightProduced := production.TotalWeightProduced
|
||||
totalEggWeightKg := production.TotalEggWeightKg
|
||||
@@ -394,6 +394,11 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
|
||||
totalWeightSold := production.TotalWeightSold
|
||||
totalBirdSold := production.TotalBirdSold
|
||||
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
|
||||
// Prefer recording-based population (consistent with buildHPPSection) so per-ekor
|
||||
// P&L matches the normalized recording population for cut-over flocks.
|
||||
if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok {
|
||||
actualPopulation = lastPopulation
|
||||
}
|
||||
|
||||
isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)
|
||||
|
||||
|
||||
@@ -200,9 +200,12 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
|
||||
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
|
||||
}
|
||||
}
|
||||
var grandTotalSO float64
|
||||
var grandTotalSO, grandTotalDO float64
|
||||
for _, p := range marketing.Products {
|
||||
grandTotalSO += p.TotalPrice
|
||||
if p.DeliveryProduct != nil && p.DeliveryProduct.DeliveryDate != nil {
|
||||
grandTotalDO += p.DeliveryProduct.TotalPrice
|
||||
}
|
||||
}
|
||||
|
||||
return MarketingListDTO{
|
||||
@@ -211,7 +214,7 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
|
||||
SalesPerson: salesPerson,
|
||||
SoDocs: marketing.SoDocs,
|
||||
GrandTotalSO: grandTotalSO,
|
||||
GrandTotalDO: marketing.GrandTotal,
|
||||
GrandTotalDO: grandTotalDO,
|
||||
SalesOrder: salesOrderProducts,
|
||||
DeliveryOrder: extractDeliveryGroupsFromProducts(marketing),
|
||||
CreatedUser: createdUser,
|
||||
|
||||
@@ -972,6 +972,19 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
|
||||
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
|
||||
}
|
||||
if requestedQty > 0 {
|
||||
available, err := s.checkAvailableStockQty(ctx, tx, marketingProduct.ProductWarehouseId)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa ketersediaan stok")
|
||||
}
|
||||
if requestedQty > available {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf(
|
||||
"Stok tidak mencukupi: dibutuhkan %g, tersedia %g",
|
||||
requestedQty, available,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if err := reflowMarketingScope(
|
||||
ctx,
|
||||
s.FifoStockV2Svc,
|
||||
@@ -1505,3 +1518,28 @@ func uniqueUintIDs(ids []uint) []uint {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// checkAvailableStockQty returns the net available qty for a product warehouse:
|
||||
// gross qty (product_warehouses.qty) minus the sum of active CONSUME allocations
|
||||
// in stock_allocations. This gives the true available stock accounting for all
|
||||
// other delivery orders that have already consumed from the same warehouse.
|
||||
func (s deliveryOrdersService) checkAvailableStockQty(ctx context.Context, tx *gorm.DB, productWarehouseId uint) (float64, error) {
|
||||
var pw entity.ProductWarehouse
|
||||
if err := tx.WithContext(ctx).Select("qty").First(&pw, productWarehouseId).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var usedQty float64
|
||||
if err := tx.WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(qty), 0)
|
||||
FROM stock_allocations
|
||||
WHERE stockable_type = 'product_warehouses'
|
||||
AND stockable_id = ?
|
||||
AND status = 'ACTIVE'
|
||||
AND allocation_purpose = 'CONSUME'
|
||||
`, productWarehouseId).Scan(&usedQty).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return pw.Quantity - usedQty, nil
|
||||
}
|
||||
|
||||
+63
-11
@@ -387,35 +387,87 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan
|
||||
return nil
|
||||
}
|
||||
|
||||
week := ((day - 1) / 7) + 1
|
||||
if week <= 0 {
|
||||
requestedWeek := ((day - 1) / 7) + 1
|
||||
if requestedWeek <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upperCategory := strings.ToUpper(category)
|
||||
if upperCategory == string(utils.ProjectFlockCategoryLaying) {
|
||||
detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
|
||||
effectiveWeek := requestedWeek
|
||||
firstCommonWeek, ok, err := s.layingFirstCommonStandardWeek(ctx, standardID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if detail == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
|
||||
if ok && requestedWeek < firstCommonWeek {
|
||||
effectiveWeek = firstCommonWeek
|
||||
}
|
||||
|
||||
detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
|
||||
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if detail != nil && growthDetail != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
|
||||
}
|
||||
|
||||
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, requestedWeek)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if growthDetail == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s productionStandardService) layingFirstCommonStandardWeek(ctx context.Context, standardID uint) (int, bool, error) {
|
||||
details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
detailWeeks := make(map[int]struct{}, len(details))
|
||||
for _, detail := range details {
|
||||
if detail.Week <= 0 {
|
||||
continue
|
||||
}
|
||||
detailWeeks[detail.Week] = struct{}{}
|
||||
}
|
||||
|
||||
growthDetails, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(ctx, standardID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
firstCommonWeek := 0
|
||||
for _, detail := range growthDetails {
|
||||
if detail.Week <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := detailWeeks[detail.Week]; !ok {
|
||||
continue
|
||||
}
|
||||
if firstCommonWeek == 0 || detail.Week < firstCommonWeek {
|
||||
firstCommonWeek = detail.Week
|
||||
}
|
||||
}
|
||||
|
||||
return firstCommonWeek, firstCommonWeek > 0, nil
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
repositories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureWeekAvailableAllowsLayingBeforeFirstCommonStandardWeek(t *testing.T) {
|
||||
svc := setupProductionStandardServiceTest(t)
|
||||
|
||||
if err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 85); err != nil {
|
||||
t.Fatalf("expected pre-standard laying week to be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureWeekAvailableRejectsLayingMissingWeekAfterStandardStarts(t *testing.T) {
|
||||
svc := setupProductionStandardServiceTest(t)
|
||||
|
||||
err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 127)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing laying standard week to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "week 19") {
|
||||
t.Fatalf("expected error to mention requested week 19, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureWeekAvailableKeepsGrowingWeekStrict(t *testing.T) {
|
||||
svc := setupProductionStandardServiceTest(t)
|
||||
|
||||
err := svc.EnsureWeekAvailable(context.Background(), 2, string(utils.ProjectFlockCategoryGrowing), 8)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing growing standard week to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "week 2") {
|
||||
t.Fatalf("expected error to mention requested week 2, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupProductionStandardServiceTest(t *testing.T) productionStandardService {
|
||||
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)
|
||||
}
|
||||
|
||||
statements := []string{
|
||||
`CREATE TABLE production_standard_details (
|
||||
id INTEGER PRIMARY KEY,
|
||||
production_standard_id INTEGER NOT NULL,
|
||||
week INTEGER NOT NULL,
|
||||
target_hen_day_production NUMERIC NULL,
|
||||
target_hen_house_production NUMERIC NULL,
|
||||
target_egg_weight NUMERIC NULL,
|
||||
target_egg_mass NUMERIC NULL,
|
||||
standard_fcr NUMERIC NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL
|
||||
)`,
|
||||
`CREATE TABLE standard_growth_details (
|
||||
id INTEGER PRIMARY KEY,
|
||||
production_standard_id INTEGER NOT NULL,
|
||||
target_mean_bw NUMERIC NULL,
|
||||
max_depletion NUMERIC NULL,
|
||||
min_uniformity NUMERIC NOT NULL,
|
||||
week INTEGER NOT NULL,
|
||||
feed_intake NUMERIC NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
created_by INTEGER NOT NULL
|
||||
)`,
|
||||
`INSERT INTO production_standard_details (id, production_standard_id, week, standard_fcr) VALUES
|
||||
(1, 1, 18, 2.1)`,
|
||||
`INSERT INTO standard_growth_details (id, production_standard_id, week, min_uniformity, created_by) VALUES
|
||||
(1, 1, 18, 80, 1),
|
||||
(2, 2, 1, 80, 1)`,
|
||||
}
|
||||
|
||||
for _, stmt := range statements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
t.Fatalf("failed preparing schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return productionStandardService{
|
||||
ProductionStandardDetailRepo: repositories.NewProductionStandardDetailRepository(db),
|
||||
StandardGrowthDetailRepo: repositories.NewStandardGrowthDetailRepository(db),
|
||||
}
|
||||
}
|
||||
@@ -480,6 +480,29 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error {
|
||||
return ctx.Status(fiber.StatusOK).JSON(resp)
|
||||
}
|
||||
|
||||
func (c *RepportController) GetHppPerFarm(ctx *fiber.Ctx) error {
|
||||
data, meta, err := c.RepportService.GetHppPerFarm(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Code int `json:"code"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Meta dto.HppPerFarmMetaDTO `json:"meta"`
|
||||
Data dto.HppPerFarmResponseData `json:"data"`
|
||||
}{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Get HPP per farm successfully",
|
||||
Meta: *meta,
|
||||
Data: *data,
|
||||
}
|
||||
|
||||
return ctx.Status(fiber.StatusOK).JSON(resp)
|
||||
}
|
||||
|
||||
func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
|
||||
var customerIDs []uint
|
||||
if customerIDsStr := ctx.Query("customer_ids"); customerIDsStr != "" {
|
||||
|
||||
@@ -60,7 +60,6 @@ type ExpenseDepreciationV2RowDTO struct {
|
||||
TotalValuePulletAfterDepreciation float64 `json:"total_value_pullet_after_depreciation"`
|
||||
StandardEffectiveDate string `json:"standard_effective_date,omitempty"`
|
||||
TotalPopulation float64 `json:"total_population"`
|
||||
Components any `json:"components"`
|
||||
}
|
||||
|
||||
func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpenseDepreciationRowDTOComponentsJSONContract(t *testing.T) {
|
||||
v1 := ExpenseDepreciationRowDTO{
|
||||
ProjectFlockID: 1,
|
||||
FarmName: "Farm A",
|
||||
Period: "2026-06-05",
|
||||
Components: map[string]any{"kandang_count": 1},
|
||||
}
|
||||
rawV1, err := json.Marshal(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal v1 dto: %v", err)
|
||||
}
|
||||
|
||||
var decodedV1 map[string]any
|
||||
if err := json.Unmarshal(rawV1, &decodedV1); err != nil {
|
||||
t.Fatalf("unmarshal v1 dto: %v", err)
|
||||
}
|
||||
if _, ok := decodedV1["components"]; !ok {
|
||||
t.Fatalf("expected v1 components to be present, got %s", string(rawV1))
|
||||
}
|
||||
|
||||
v2 := ExpenseDepreciationV2RowDTO{
|
||||
Date: "2026-06-05",
|
||||
DepreciationPercentEffective: 10,
|
||||
DepreciationValue: 100,
|
||||
PulletCostDayNTotal: 1000,
|
||||
MultiplicationPercentage: 0.9,
|
||||
DayN: 2,
|
||||
ChickinDate: "2026-01-01",
|
||||
TotalValuePulletAfterDepreciation: 900,
|
||||
TotalPopulation: 100,
|
||||
}
|
||||
rawV2, err := json.Marshal(v2)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal v2 dto: %v", err)
|
||||
}
|
||||
|
||||
var decodedV2 map[string]any
|
||||
if err := json.Unmarshal(rawV2, &decodedV2); err != nil {
|
||||
t.Fatalf("unmarshal v2 dto: %v", err)
|
||||
}
|
||||
if _, ok := decodedV2["components"]; ok {
|
||||
t.Fatalf("expected v2 components to be omitted, got %s", string(rawV2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package dto
|
||||
|
||||
type HppPerFarmFiltersDTO struct {
|
||||
AreaID string `json:"area_id"`
|
||||
LocationID string `json:"location_id"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
}
|
||||
|
||||
type HppPerFarmMetaDTO struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int64 `json:"total_pages"`
|
||||
TotalResults int64 `json:"total_results"`
|
||||
Filters HppPerFarmFiltersDTO `json:"filters"`
|
||||
}
|
||||
|
||||
type HppPerFarmResponseData struct {
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Rows []HppPerFarmRowDTO `json:"rows"`
|
||||
Summary HppPerFarmSummaryDTO `json:"summary"`
|
||||
}
|
||||
|
||||
// HppPerFarmRowDTO is one farm (location) row, aggregating all LAYING project
|
||||
// flocks within the same location over the selected date range.
|
||||
type HppPerFarmRowDTO struct {
|
||||
Location HppPerKandangLocationDTO `json:"location"`
|
||||
// total_cost_rp = depreciation + pakan + ovk + bop (+ other production cost).
|
||||
// DOC/pullet is NOT included here (it is expensed through depreciation);
|
||||
// average_doc_price_rp is provided for information only.
|
||||
TotalCostRp float64 `json:"total_cost_rp"`
|
||||
FeedCostRp float64 `json:"feed_cost_rp"`
|
||||
OvkCostRp float64 `json:"ovk_cost_rp"`
|
||||
BopCostRp float64 `json:"bop_cost_rp"`
|
||||
DepreciationRp float64 `json:"depreciation_rp"`
|
||||
OtherCostRp float64 `json:"other_cost_rp"`
|
||||
EggWeightRecordingKg float64 `json:"egg_weight_recording_kg"`
|
||||
EggWeightDoKg float64 `json:"egg_weight_do_kg"`
|
||||
HppPerKgProduction float64 `json:"hpp_per_kg_production"`
|
||||
HppPerKgSales float64 `json:"hpp_per_kg_sales"`
|
||||
AverageDocPriceRp int64 `json:"average_doc_price_rp"`
|
||||
|
||||
Flocks []HppPerFarmFlockDTO `json:"flocks"`
|
||||
}
|
||||
|
||||
// HppPerFarmFlockDTO is the per-project-flock breakdown inside a farm row.
|
||||
type HppPerFarmFlockDTO struct {
|
||||
ProjectFlockID int64 `json:"project_flock_id"`
|
||||
FlockName string `json:"flock_name"`
|
||||
TotalCostRp float64 `json:"total_cost_rp"`
|
||||
FeedCostRp float64 `json:"feed_cost_rp"`
|
||||
OvkCostRp float64 `json:"ovk_cost_rp"`
|
||||
BopCostRp float64 `json:"bop_cost_rp"`
|
||||
DepreciationRp float64 `json:"depreciation_rp"`
|
||||
OtherCostRp float64 `json:"other_cost_rp"`
|
||||
EggWeightRecordingKg float64 `json:"egg_weight_recording_kg"`
|
||||
EggWeightDoKg float64 `json:"egg_weight_do_kg"`
|
||||
HppPerKgProduction float64 `json:"hpp_per_kg_production"`
|
||||
HppPerKgSales float64 `json:"hpp_per_kg_sales"`
|
||||
AverageDocPriceRp int64 `json:"average_doc_price_rp"`
|
||||
}
|
||||
|
||||
type HppPerFarmSummaryDTO struct {
|
||||
TotalCostRp float64 `json:"total_cost_rp"`
|
||||
TotalEggWeightRecordingKg float64 `json:"total_egg_weight_recording_kg"`
|
||||
TotalEggWeightDoKg float64 `json:"total_egg_weight_do_kg"`
|
||||
AverageHppPerKgProduction float64 `json:"average_hpp_per_kg_production"`
|
||||
AverageHppPerKgSales float64 `json:"average_hpp_per_kg_sales"`
|
||||
}
|
||||
|
||||
func NewHppPerFarmFiltersDTO(area, location, startDate, endDate string) HppPerFarmFiltersDTO {
|
||||
return HppPerFarmFiltersDTO{
|
||||
AreaID: area,
|
||||
LocationID: location,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppByDeliver
|
||||
realizationDate = *mdp.DeliveryDate
|
||||
}
|
||||
|
||||
totalWeightKg := mdp.UsageQty * mdp.AvgWeight
|
||||
totalWeightKg := mdp.TotalWeight
|
||||
salesAmount := totalWeightKg * mdp.UnitPrice
|
||||
|
||||
var hpp float64
|
||||
|
||||
@@ -37,6 +37,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
|
||||
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
|
||||
debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db)
|
||||
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
|
||||
hppPerFarmRepository := repportRepo.NewHppPerFarmRepository(db)
|
||||
expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db)
|
||||
productionResultRepository := repportRepo.NewProductionResultRepository(db)
|
||||
customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db)
|
||||
@@ -65,6 +66,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
|
||||
purchaseSupplierRepository,
|
||||
debtSupplierRepository,
|
||||
hppPerKandangRepository,
|
||||
hppPerFarmRepository,
|
||||
productionResultRepository,
|
||||
customerPaymentRepository,
|
||||
balanceMonitoringRepository,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
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"
|
||||
)
|
||||
|
||||
// HppPerFarmFlockMetaRow describes a LAYING project flock and the farm
|
||||
// (location) it belongs to. Farm identity is project_flocks.location_id.
|
||||
type HppPerFarmFlockMetaRow struct {
|
||||
ProjectFlockID uint
|
||||
FlockName string
|
||||
LocationID uint
|
||||
LocationName string
|
||||
AreaID uint
|
||||
}
|
||||
|
||||
// HppPerFarmDocRow holds the DOC/pullet acquisition cost trace per flock.
|
||||
// Used only as an informational field (average_doc_price_rp); it is NOT part
|
||||
// of total_cost because the pullet cost is expensed through depreciation.
|
||||
type HppPerFarmDocRow struct {
|
||||
ProjectFlockID uint
|
||||
DocCost float64
|
||||
DocQty float64
|
||||
}
|
||||
|
||||
type HppPerFarmRepository interface {
|
||||
GetCandidateFlocks(ctx context.Context, start time.Time, areaIDs, locationIDs []int64) ([]HppPerFarmFlockMetaRow, error)
|
||||
SumRecordingEggWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error)
|
||||
SumMarketingDoTelurWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error)
|
||||
GetDocCostByFlock(ctx context.Context, projectFlockIDs []uint) (map[uint]HppPerFarmDocRow, error)
|
||||
DB() *gorm.DB
|
||||
}
|
||||
|
||||
type hppPerFarmRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHppPerFarmRepository(db *gorm.DB) HppPerFarmRepository {
|
||||
return &hppPerFarmRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *hppPerFarmRepository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// GetCandidateFlocks returns the LAYING project flocks (with their farm/location
|
||||
// metadata) that are still active on or after the range start, scoped by area
|
||||
// and location. Mirrors ExpenseDepreciationRepository.GetCandidateFarms but adds
|
||||
// location info so flocks can be grouped per farm.
|
||||
func (r *hppPerFarmRepository) GetCandidateFlocks(ctx context.Context, start time.Time, areaIDs, locationIDs []int64) ([]HppPerFarmFlockMetaRow, error) {
|
||||
rows := make([]HppPerFarmFlockMetaRow, 0)
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("project_flocks AS pf").
|
||||
Select(`
|
||||
DISTINCT pf.id AS project_flock_id,
|
||||
pf.flock_name AS flock_name,
|
||||
pf.location_id AS location_id,
|
||||
loc.name AS location_name,
|
||||
pf.area_id AS area_id`).
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id").
|
||||
Joins("JOIN locations AS loc ON loc.id = pf.location_id").
|
||||
Where("pf.deleted_at IS NULL").
|
||||
Where("pf.category = ?", utils.ProjectFlockCategoryLaying).
|
||||
Where("(pfk.closed_at IS NULL OR DATE(pfk.closed_at) >= DATE(?))", start)
|
||||
|
||||
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 err := query.Order("pf.location_id ASC, pf.id ASC").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// SumRecordingEggWeightByFlock sums recording_eggs.weight (kg) per project flock
|
||||
// for non-rejected recordings whose record_datetime falls inside [start, endExclusive).
|
||||
func (r *hppPerFarmRepository) SumRecordingEggWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error) {
|
||||
result := make(map[uint]float64)
|
||||
if len(projectFlockIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
latestApproval := r.db.WithContext(ctx).
|
||||
Table("approvals AS a").
|
||||
Select("a.approvable_id, a.action").
|
||||
Joins(`
|
||||
JOIN (
|
||||
SELECT approvable_id, MAX(action_at) AS latest_action_at
|
||||
FROM approvals
|
||||
WHERE approvable_type = ?
|
||||
GROUP BY approvable_id
|
||||
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
|
||||
string(utils.ApprovalWorkflowRecording),
|
||||
)
|
||||
|
||||
type eggRow struct {
|
||||
ProjectFlockID uint
|
||||
Weight float64
|
||||
}
|
||||
rows := make([]eggRow, 0)
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select(`
|
||||
pfk.project_flock_id AS project_flock_id,
|
||||
COALESCE(SUM(re.weight), 0) AS weight`).
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
|
||||
Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval).
|
||||
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
|
||||
Where("pfk.project_flock_id IN ?", projectFlockIDs).
|
||||
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, endExclusive).
|
||||
Where("r.deleted_at IS NULL").
|
||||
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
|
||||
Group("pfk.project_flock_id")
|
||||
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
result[row.ProjectFlockID] = row.Weight
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SumMarketingDoTelurWeightByFlock sums delivered TELUR weight (marketing_delivery_products.total_weight)
|
||||
// per project flock, for delivery_date inside [start, endExclusive). A delivery product that is
|
||||
// attributed to multiple flocks is prorated by each flock's allocated qty share, so that
|
||||
// the farm total equals the sum of its flocks.
|
||||
func (r *hppPerFarmRepository) SumMarketingDoTelurWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error) {
|
||||
result := make(map[uint]float64)
|
||||
if len(projectFlockIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
telurFlags := []string{
|
||||
string(utils.FlagTelur),
|
||||
string(utils.FlagTelurUtuh),
|
||||
string(utils.FlagTelurPecah),
|
||||
string(utils.FlagTelurPutih),
|
||||
string(utils.FlagTelurRetak),
|
||||
}
|
||||
|
||||
// allocated qty per (marketing_delivery_product, project_flock)
|
||||
attrByFlock := r.db.WithContext(ctx).
|
||||
Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.db.WithContext(ctx))).
|
||||
Select(`
|
||||
mda.marketing_delivery_product_id AS mdp_id,
|
||||
mda.project_flock_id AS project_flock_id,
|
||||
SUM(mda.allocated_qty) AS flock_qty`).
|
||||
Group("mda.marketing_delivery_product_id, mda.project_flock_id")
|
||||
|
||||
// prorate each delivery product's total_weight across its attributed flocks.
|
||||
// Use EXISTS for the TELUR flag filter (not a JOIN) so a product carrying
|
||||
// multiple egg flags does not fan out and double-count the weight share.
|
||||
shareQuery := r.db.WithContext(ctx).
|
||||
Table("(?) AS a", attrByFlock).
|
||||
Select(`
|
||||
a.project_flock_id AS project_flock_id,
|
||||
mdp.total_weight * a.flock_qty / NULLIF(SUM(a.flock_qty) OVER (PARTITION BY a.mdp_id), 0) AS weight_share`).
|
||||
Joins("JOIN marketing_delivery_products AS mdp ON mdp.id = a.mdp_id").
|
||||
Joins("JOIN marketing_products AS mp ON mp.id = mdp.marketing_product_id").
|
||||
Joins("JOIN product_warehouses AS pw ON pw.id = mp.product_warehouse_id").
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, telurFlags).
|
||||
Where("mdp.delivery_date >= ? AND mdp.delivery_date < ?", start, endExclusive)
|
||||
|
||||
type doRow struct {
|
||||
ProjectFlockID uint
|
||||
Weight float64
|
||||
}
|
||||
rows := make([]doRow, 0)
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("(?) AS s", shareQuery).
|
||||
Select(`
|
||||
s.project_flock_id AS project_flock_id,
|
||||
COALESCE(SUM(s.weight_share), 0) AS weight`).
|
||||
Where("s.project_flock_id IN ?", projectFlockIDs).
|
||||
Group("s.project_flock_id")
|
||||
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
result[row.ProjectFlockID] = row.Weight
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDocCostByFlock returns the DOC acquisition cost (qty * purchase price) and qty
|
||||
// traced to chick-in per project flock. Informational only.
|
||||
func (r *hppPerFarmRepository) GetDocCostByFlock(ctx context.Context, projectFlockIDs []uint) (map[uint]HppPerFarmDocRow, error) {
|
||||
result := make(map[uint]HppPerFarmDocRow)
|
||||
if len(projectFlockIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rows := make([]HppPerFarmDocRow, 0)
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("project_chickins AS pc").
|
||||
Select(`
|
||||
pfk.project_flock_id AS project_flock_id,
|
||||
COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS doc_cost,
|
||||
COALESCE(SUM(sa.qty), 0) AS doc_qty`).
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
|
||||
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("pfk.project_flock_id IN ?", projectFlockIDs).
|
||||
Group("pfk.project_flock_id")
|
||||
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
result[row.ProjectFlockID] = row
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -24,6 +24,7 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
|
||||
route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier)
|
||||
route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier)
|
||||
route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang)
|
||||
route.Get("/hpp-per-farm", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerFarm)
|
||||
route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown)
|
||||
route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult)
|
||||
route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
)
|
||||
|
||||
// production-scope total should sum only parts tagged production_cost (a part
|
||||
// tagged with both scopes still counts once).
|
||||
func TestHppPerFarmProductionScopeTotalPartLevelScopes(t *testing.T) {
|
||||
comp := &approvalService.HppV2Component{
|
||||
Code: "PAKAN",
|
||||
Parts: []approvalService.HppV2ComponentPart{
|
||||
{Total: 100, Scopes: []string{"production_cost"}},
|
||||
{Total: 50, Scopes: []string{"pullet_cost"}},
|
||||
{Total: 25, Scopes: []string{"production_cost", "pullet_cost"}},
|
||||
},
|
||||
}
|
||||
if got := hppPerFarmProductionScopeTotal(comp); got != 125 {
|
||||
t.Fatalf("expected 125, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// when parts carry no scopes, fall back to the component-level scope.
|
||||
func TestHppPerFarmProductionScopeTotalComponentLevelFallback(t *testing.T) {
|
||||
prod := &approvalService.HppV2Component{
|
||||
Code: "DIRECT_PULLET_PURCHASE",
|
||||
Scopes: []string{"production_cost"},
|
||||
Total: 300,
|
||||
Parts: []approvalService.HppV2ComponentPart{{Total: 300}},
|
||||
}
|
||||
if got := hppPerFarmProductionScopeTotal(prod); got != 300 {
|
||||
t.Fatalf("expected 300 component fallback, got %v", got)
|
||||
}
|
||||
|
||||
// DOC/pullet is pullet-scope only -> contributes 0 to production cost,
|
||||
// which is exactly why it must not be added to total_cost (depreciation
|
||||
// already expenses the pullet).
|
||||
pulletOnly := &approvalService.HppV2Component{
|
||||
Code: "DOC_CHICKIN",
|
||||
Scopes: []string{"pullet_cost"},
|
||||
Total: 999,
|
||||
Parts: []approvalService.HppV2ComponentPart{{Total: 999}},
|
||||
}
|
||||
if got := hppPerFarmProductionScopeTotal(pulletOnly); got != 0 {
|
||||
t.Fatalf("expected 0 for pullet-only component, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppPerFarmProductionScopeTotalsByCode(t *testing.T) {
|
||||
b := &approvalService.HppV2Breakdown{
|
||||
Components: []approvalService.HppV2Component{
|
||||
{Code: "PAKAN", Parts: []approvalService.HppV2ComponentPart{{Total: 100, Scopes: []string{"production_cost"}}}},
|
||||
{Code: "OVK", Parts: []approvalService.HppV2ComponentPart{{Total: 40, Scopes: []string{"production_cost"}}}},
|
||||
{Code: "DOC_CHICKIN", Scopes: []string{"pullet_cost"}, Total: 500, Parts: []approvalService.HppV2ComponentPart{{Total: 500}}},
|
||||
{Code: "DEPRECIATION", Scopes: []string{"production_cost"}, Total: 30, Parts: []approvalService.HppV2ComponentPart{{Total: 30, Scopes: []string{"production_cost"}}}},
|
||||
},
|
||||
}
|
||||
got := hppPerFarmProductionScopeTotalsByCode(b)
|
||||
if got["PAKAN"] != 100 {
|
||||
t.Fatalf("expected PAKAN 100, got %v", got["PAKAN"])
|
||||
}
|
||||
if got["OVK"] != 40 {
|
||||
t.Fatalf("expected OVK 40, got %v", got["OVK"])
|
||||
}
|
||||
if got["DOC_CHICKIN"] != 0 {
|
||||
t.Fatalf("expected DOC_CHICKIN production scope 0, got %v", got["DOC_CHICKIN"])
|
||||
}
|
||||
if got["DEPRECIATION"] != 30 {
|
||||
t.Fatalf("expected DEPRECIATION 30, got %v", got["DEPRECIATION"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppPerFarmSafeDiv(t *testing.T) {
|
||||
cases := []struct {
|
||||
num, den, want float64
|
||||
}{
|
||||
{100, 4, 25},
|
||||
{100, 0, 0},
|
||||
{100, -5, 0},
|
||||
{0, 0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := hppPerFarmSafeDiv(c.num, c.den); got != c.want {
|
||||
t.Fatalf("safeDiv(%v,%v)=%v want %v", c.num, c.den, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := hppPerFarmSafeDiv(math.Inf(1), 1); got != 0 {
|
||||
t.Fatalf("expected 0 for inf numerator, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,12 @@ func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *expenseDepreciationRepoMock) DeleteSnapshotsByFarmIDs(_ context.Context, farmIDs []uint) error {
|
||||
m.deleteCalled = true
|
||||
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
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ type RepportService interface {
|
||||
GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error)
|
||||
GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error)
|
||||
GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error)
|
||||
GetHppPerFarm(ctx *fiber.Ctx) (*dto.HppPerFarmResponseData, *dto.HppPerFarmMetaDTO, error)
|
||||
GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error)
|
||||
GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error)
|
||||
GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error)
|
||||
@@ -74,6 +75,7 @@ type repportService struct {
|
||||
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
|
||||
DebtSupplierRepo repportRepo.DebtSupplierRepository
|
||||
HppPerKandangRepo repportRepo.HppPerKandangRepository
|
||||
HppPerFarmRepo repportRepo.HppPerFarmRepository
|
||||
ProductionResultRepo repportRepo.ProductionResultRepository
|
||||
CustomerPaymentRepo repportRepo.CustomerPaymentRepository
|
||||
BalanceMonitoringRepo repportRepo.BalanceMonitoringRepository
|
||||
@@ -107,6 +109,7 @@ func NewRepportService(
|
||||
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
|
||||
debtSupplierRepo repportRepo.DebtSupplierRepository,
|
||||
hppPerKandangRepo repportRepo.HppPerKandangRepository,
|
||||
hppPerFarmRepo repportRepo.HppPerFarmRepository,
|
||||
productionResultRepo repportRepo.ProductionResultRepository,
|
||||
customerPaymentRepo repportRepo.CustomerPaymentRepository,
|
||||
balanceMonitoringRepo repportRepo.BalanceMonitoringRepository,
|
||||
@@ -131,6 +134,7 @@ func NewRepportService(
|
||||
PurchaseSupplierRepo: purchaseSupplierRepo,
|
||||
DebtSupplierRepo: debtSupplierRepo,
|
||||
HppPerKandangRepo: hppPerKandangRepo,
|
||||
HppPerFarmRepo: hppPerFarmRepo,
|
||||
ProductionResultRepo: productionResultRepo,
|
||||
CustomerPaymentRepo: customerPaymentRepo,
|
||||
BalanceMonitoringRepo: balanceMonitoringRepo,
|
||||
@@ -419,7 +423,10 @@ func (s *repportService) GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.Expense
|
||||
var totalDepreciationValue float64
|
||||
var totalPulletCostDayN float64
|
||||
var totalPopulation float64
|
||||
var allKandangComponents []depreciationKandangComponent
|
||||
var multiplicationPercentage float64
|
||||
var dayN int
|
||||
var chickinDate string
|
||||
var standardEffectiveDate string
|
||||
|
||||
for _, kandangID := range kandangIDs {
|
||||
breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &dayDate)
|
||||
@@ -440,70 +447,31 @@ func (s *repportService) GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.Expense
|
||||
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"),
|
||||
MultiplicationPercentage: hppV2DetailFloat(part.Details, "multiplication_percentage"),
|
||||
PulletCostDayN: hppV2DetailFloat(part.Details, "pullet_cost_day_n"),
|
||||
DepreciationValue: part.Total,
|
||||
TotalValuePulletAfterDepreciation: hppV2DetailFloat(part.Details, "total_value_pullet_after_depreciation"),
|
||||
DepreciationSource: part.Code,
|
||||
OriginDate: hppV2DetailString(part.Details, "origin_date"),
|
||||
ChickinDate: hppV2DetailString(part.Details, "origin_date"),
|
||||
StandardEffectiveDate: hppV2DetailString(part.Details, "standard_effective_date"),
|
||||
Population: hppV2DetailFloat(part.Details, "kandang_population"),
|
||||
partPulletCostDayN := hppV2DetailFloat(part.Details, "pullet_cost_day_n")
|
||||
partPopulation := hppV2DetailFloat(part.Details, "kandang_population")
|
||||
partDayN := hppV2DetailInt(part.Details, "schedule_day")
|
||||
partMultiplicationPercentage := hppV2DetailFloat(part.Details, "multiplication_percentage")
|
||||
partChickinDate := hppV2DetailString(part.Details, "chickin_date")
|
||||
if partChickinDate == "" {
|
||||
partChickinDate = hppV2DetailString(part.Details, "origin_date")
|
||||
}
|
||||
|
||||
if component.HouseType == "" {
|
||||
component.HouseType = approvalService.NormalizeDepreciationHouseType(hppV2DetailString(part.Details, "house_type"))
|
||||
}
|
||||
totalPulletCostDayN += partPulletCostDayN
|
||||
totalDepreciationValue += part.Total
|
||||
totalPopulation += partPopulation
|
||||
|
||||
if ref := hppV2FindReference(part.References, "laying_transfer"); ref != nil {
|
||||
component.TransferID = ref.ID
|
||||
component.TransferDate = ref.Date
|
||||
component.TransferQty = ref.Qty
|
||||
if dayN == 0 && multiplicationPercentage == 0 && chickinDate == "" &&
|
||||
(partDayN > 0 || partMultiplicationPercentage > 0 || partChickinDate != "") {
|
||||
dayN = partDayN
|
||||
multiplicationPercentage = partMultiplicationPercentage
|
||||
chickinDate = partChickinDate
|
||||
standardEffectiveDate = hppV2DetailString(part.Details, "standard_effective_date")
|
||||
}
|
||||
|
||||
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
|
||||
totalPopulation += component.Population
|
||||
allKandangComponents = append(allKandangComponents, component)
|
||||
}
|
||||
}
|
||||
|
||||
effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN)
|
||||
|
||||
components := depreciationFarmComponents{
|
||||
KandangCount: len(allKandangComponents),
|
||||
TotalPopulation: totalPopulation,
|
||||
Kandang: allKandangComponents,
|
||||
}
|
||||
componentsJSON, _ := json.Marshal(components)
|
||||
|
||||
multiplicationPercentage, dayN, chickinDate, standardEffectiveDate := depreciationSnapshotInfo(parseSnapshotComponents(componentsJSON))
|
||||
|
||||
rows = append(rows, dto.ExpenseDepreciationV2RowDTO{
|
||||
Date: dayStr,
|
||||
DepreciationPercentEffective: effectivePercent,
|
||||
@@ -515,7 +483,6 @@ func (s *repportService) GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.Expense
|
||||
TotalValuePulletAfterDepreciation: totalPulletCostDayN - totalDepreciationValue,
|
||||
StandardEffectiveDate: standardEffectiveDate,
|
||||
TotalPopulation: totalPopulation,
|
||||
Components: parseSnapshotComponents(componentsJSON),
|
||||
})
|
||||
actualDays++
|
||||
}
|
||||
@@ -3122,6 +3089,534 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp
|
||||
return params, filters, nil
|
||||
}
|
||||
|
||||
const (
|
||||
hppPerFarmProductionScope = "production_cost"
|
||||
hppPerFarmComponentDepreciation = "DEPRECIATION"
|
||||
hppPerFarmComponentPakan = "PAKAN"
|
||||
hppPerFarmComponentOvk = "OVK"
|
||||
hppPerFarmComponentBopRegular = "BOP_REGULAR"
|
||||
hppPerFarmComponentBopEkspedisi = "BOP_EKSPEDISI"
|
||||
hppPerFarmMaxRangeDays = 366
|
||||
)
|
||||
|
||||
// GetHppPerFarm builds the HPP-per-farm report: it groups all LAYING project
|
||||
// flocks by location/farm over [start_date, end_date] and reports, per farm,
|
||||
// the total cost (pakan + ovk + bop + depreciation) and two cost-per-kg figures
|
||||
// — one against egg weight produced (recording_eggs) and one against egg weight
|
||||
// sold/delivered (marketing delivery orders). DOC/pullet cost is informational
|
||||
// only (it is expensed through depreciation, so it is NOT added to total cost).
|
||||
func (s *repportService) GetHppPerFarm(ctx *fiber.Ctx) (*dto.HppPerFarmResponseData, *dto.HppPerFarmMetaDTO, error) {
|
||||
params, filters, err := s.parseHppPerFarmQuery(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.HppPerFarmRepo == nil {
|
||||
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "hpp per farm 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")
|
||||
}
|
||||
startDate, err := time.ParseInLocation("2006-01-02", params.StartDate, location)
|
||||
if err != nil {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "start_date must follow format YYYY-MM-DD")
|
||||
}
|
||||
endDate, err := time.ParseInLocation("2006-01-02", params.EndDate, location)
|
||||
if err != nil {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must follow format YYYY-MM-DD")
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date")
|
||||
}
|
||||
rangeDays := int(endDate.Sub(startDate).Hours()/24) + 1
|
||||
if rangeDays > hppPerFarmMaxRangeDays {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "date range must not exceed 366 days")
|
||||
}
|
||||
|
||||
startOfRange := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, location)
|
||||
endBreakdownDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location)
|
||||
endExclusive := endBreakdownDate.Add(24 * time.Hour)
|
||||
startBreakdownDate := startOfRange.AddDate(0, 0, -1)
|
||||
|
||||
limit := params.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
flockRows, err := s.HppPerFarmRepo.GetCandidateFlocks(ctx.Context(), startOfRange, params.AreaIDs, params.LocationIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(flockRows) == 0 {
|
||||
meta := &dto.HppPerFarmMetaDTO{
|
||||
Page: params.Page,
|
||||
Limit: limit,
|
||||
TotalPages: 1,
|
||||
TotalResults: 0,
|
||||
Filters: filters,
|
||||
}
|
||||
data := &dto.HppPerFarmResponseData{
|
||||
StartDate: params.StartDate,
|
||||
EndDate: params.EndDate,
|
||||
Rows: []dto.HppPerFarmRowDTO{},
|
||||
Summary: dto.HppPerFarmSummaryDTO{},
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
flockIDs := make([]uint, 0, len(flockRows))
|
||||
for _, row := range flockRows {
|
||||
flockIDs = append(flockIDs, row.ProjectFlockID)
|
||||
}
|
||||
|
||||
depByFlock, err := s.sumHppPerFarmDepreciationOverRange(ctx.Context(), startOfRange, endBreakdownDate, flockIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
recWeightByFlock, err := s.HppPerFarmRepo.SumRecordingEggWeightByFlock(ctx.Context(), startOfRange, endExclusive, flockIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
doWeightByFlock, err := s.HppPerFarmRepo.SumMarketingDoTelurWeightByFlock(ctx.Context(), startOfRange, endExclusive, flockIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
docByFlock, err := s.HppPerFarmRepo.GetDocCostByFlock(ctx.Context(), flockIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
type hppPerFarmAggregate struct {
|
||||
locationID uint
|
||||
locationName string
|
||||
totalCost float64
|
||||
feed float64
|
||||
ovk float64
|
||||
bop float64
|
||||
depreciation float64
|
||||
other float64
|
||||
recWeight float64
|
||||
doWeight float64
|
||||
docCost float64
|
||||
docQty float64
|
||||
flocks []dto.HppPerFarmFlockDTO
|
||||
}
|
||||
|
||||
farmOrder := make([]uint, 0)
|
||||
farms := make(map[uint]*hppPerFarmAggregate)
|
||||
|
||||
for _, flock := range flockRows {
|
||||
flockID := flock.ProjectFlockID
|
||||
|
||||
codeTotals, err := s.hppPerFarmFlockCostRange(ctx.Context(), flockID, startBreakdownDate, endBreakdownDate)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
feed := codeTotals[hppPerFarmComponentPakan]
|
||||
ovk := codeTotals[hppPerFarmComponentOvk]
|
||||
|
||||
// BOP dihitung range-correct via engine (hindari differential rasio egg-weight yang bisa
|
||||
// negatif saat share antar kandang bergeser). Keluarkan kode BOP dari codeTotals agar tidak
|
||||
// ikut terjumlah dua kali di akumulasi 'nonDepreciation'/'other'.
|
||||
delete(codeTotals, hppPerFarmComponentBopRegular)
|
||||
delete(codeTotals, hppPerFarmComponentBopEkspedisi)
|
||||
|
||||
bop, err := s.hppPerFarmFlockBopRange(ctx.Context(), flockID, startBreakdownDate, endBreakdownDate)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
nonDepreciation := bop
|
||||
for _, value := range codeTotals {
|
||||
nonDepreciation += value
|
||||
}
|
||||
other := nonDepreciation - feed - ovk - bop
|
||||
depreciation := depByFlock[flockID]
|
||||
totalCost := nonDepreciation + depreciation
|
||||
|
||||
recWeight := recWeightByFlock[flockID]
|
||||
doWeight := doWeightByFlock[flockID]
|
||||
|
||||
averageDocPrice := int64(0)
|
||||
if doc, ok := docByFlock[flockID]; ok && doc.DocQty > 0 {
|
||||
averageDocPrice = int64(math.Round(doc.DocCost / doc.DocQty))
|
||||
}
|
||||
|
||||
flockDTO := dto.HppPerFarmFlockDTO{
|
||||
ProjectFlockID: int64(flockID),
|
||||
FlockName: flock.FlockName,
|
||||
TotalCostRp: totalCost,
|
||||
FeedCostRp: feed,
|
||||
OvkCostRp: ovk,
|
||||
BopCostRp: bop,
|
||||
DepreciationRp: depreciation,
|
||||
OtherCostRp: other,
|
||||
EggWeightRecordingKg: recWeight,
|
||||
EggWeightDoKg: doWeight,
|
||||
HppPerKgProduction: hppPerFarmSafeDiv(totalCost, recWeight),
|
||||
HppPerKgSales: hppPerFarmSafeDiv(totalCost, doWeight),
|
||||
AverageDocPriceRp: averageDocPrice,
|
||||
}
|
||||
|
||||
farm, ok := farms[flock.LocationID]
|
||||
if !ok {
|
||||
farm = &hppPerFarmAggregate{
|
||||
locationID: flock.LocationID,
|
||||
locationName: flock.LocationName,
|
||||
flocks: make([]dto.HppPerFarmFlockDTO, 0, 1),
|
||||
}
|
||||
farms[flock.LocationID] = farm
|
||||
farmOrder = append(farmOrder, flock.LocationID)
|
||||
}
|
||||
farm.flocks = append(farm.flocks, flockDTO)
|
||||
farm.totalCost += totalCost
|
||||
farm.feed += feed
|
||||
farm.ovk += ovk
|
||||
farm.bop += bop
|
||||
farm.depreciation += depreciation
|
||||
farm.other += other
|
||||
farm.recWeight += recWeight
|
||||
farm.doWeight += doWeight
|
||||
if doc, ok := docByFlock[flockID]; ok {
|
||||
farm.docCost += doc.DocCost
|
||||
farm.docQty += doc.DocQty
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]dto.HppPerFarmRowDTO, 0, len(farmOrder))
|
||||
summary := dto.HppPerFarmSummaryDTO{}
|
||||
for _, locID := range farmOrder {
|
||||
farm := farms[locID]
|
||||
averageDocPrice := int64(0)
|
||||
if farm.docQty > 0 {
|
||||
averageDocPrice = int64(math.Round(farm.docCost / farm.docQty))
|
||||
}
|
||||
rows = append(rows, dto.HppPerFarmRowDTO{
|
||||
Location: dto.HppPerKandangLocationDTO{ID: int64(farm.locationID), Name: farm.locationName},
|
||||
TotalCostRp: farm.totalCost,
|
||||
FeedCostRp: farm.feed,
|
||||
OvkCostRp: farm.ovk,
|
||||
BopCostRp: farm.bop,
|
||||
DepreciationRp: farm.depreciation,
|
||||
OtherCostRp: farm.other,
|
||||
EggWeightRecordingKg: farm.recWeight,
|
||||
EggWeightDoKg: farm.doWeight,
|
||||
HppPerKgProduction: hppPerFarmSafeDiv(farm.totalCost, farm.recWeight),
|
||||
HppPerKgSales: hppPerFarmSafeDiv(farm.totalCost, farm.doWeight),
|
||||
AverageDocPriceRp: averageDocPrice,
|
||||
Flocks: farm.flocks,
|
||||
})
|
||||
summary.TotalCostRp += farm.totalCost
|
||||
summary.TotalEggWeightRecordingKg += farm.recWeight
|
||||
summary.TotalEggWeightDoKg += farm.doWeight
|
||||
}
|
||||
summary.AverageHppPerKgProduction = hppPerFarmSafeDiv(summary.TotalCostRp, summary.TotalEggWeightRecordingKg)
|
||||
summary.AverageHppPerKgSales = hppPerFarmSafeDiv(summary.TotalCostRp, summary.TotalEggWeightDoKg)
|
||||
|
||||
totalResults := int64(len(rows))
|
||||
totalPages := int64(1)
|
||||
if totalResults > 0 {
|
||||
totalPages = int64(math.Ceil(float64(totalResults) / float64(limit)))
|
||||
}
|
||||
|
||||
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.HppPerFarmMetaDTO{
|
||||
Page: params.Page,
|
||||
Limit: limit,
|
||||
TotalPages: totalPages,
|
||||
TotalResults: totalResults,
|
||||
Filters: filters,
|
||||
}
|
||||
data := &dto.HppPerFarmResponseData{
|
||||
StartDate: params.StartDate,
|
||||
EndDate: params.EndDate,
|
||||
Rows: rows[offset:end],
|
||||
Summary: summary,
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// hppPerFarmFlockCostRange returns the range-scoped production cost per component
|
||||
// code for a project flock, EXCLUDING depreciation (which is summed separately
|
||||
// from daily snapshots). Each non-depreciation production component is cumulative
|
||||
// up to a date in the HPP v2 engine, so the range value is the difference between
|
||||
// the cumulative breakdown at end and at the day before the range start.
|
||||
func (s *repportService) hppPerFarmFlockCostRange(ctx context.Context, projectFlockID uint, startBreakdownDate, endBreakdownDate time.Time) (map[string]float64, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
codeTotals := make(map[string]float64)
|
||||
pfkIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, projectFlockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pfkID := range pfkIDs {
|
||||
endBreakdown, err := s.HppV2Svc.CalculateHppBreakdown(pfkID, &endBreakdownDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startBreakdown, err := s.HppV2Svc.CalculateHppBreakdown(pfkID, &startBreakdownDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endMap := hppPerFarmProductionScopeTotalsByCode(endBreakdown)
|
||||
startMap := hppPerFarmProductionScopeTotalsByCode(startBreakdown)
|
||||
|
||||
seen := make(map[string]bool, len(endMap)+len(startMap))
|
||||
for code := range endMap {
|
||||
seen[code] = true
|
||||
}
|
||||
for code := range startMap {
|
||||
seen[code] = true
|
||||
}
|
||||
for code := range seen {
|
||||
if code == hppPerFarmComponentDepreciation {
|
||||
continue
|
||||
}
|
||||
codeTotals[code] += endMap[code] - startMap[code]
|
||||
}
|
||||
}
|
||||
|
||||
return codeTotals, nil
|
||||
}
|
||||
|
||||
// hppPerFarmFlockBopRange menjumlah BOP production_cost range-correct (BOP_REGULAR + BOP_EKSPEDISI)
|
||||
// untuk seluruh PFK dalam flock, memakai GetBop*ProductionScopeRange di engine. Pendekatan ini
|
||||
// menghitung delta expense kumulatif lalu memproratanya dengan rasio akhir-range — bukan
|
||||
// men-differensiasi dua angka yang sudah diprorata berbeda — sehingga tidak pernah negatif.
|
||||
func (s *repportService) hppPerFarmFlockBopRange(ctx context.Context, projectFlockID uint, startBreakdownDate, endBreakdownDate time.Time) (float64, error) {
|
||||
if s.HppCostRepo == nil {
|
||||
return 0, errors.New("hpp cost repository is not configured")
|
||||
}
|
||||
if s.HppV2Svc == nil {
|
||||
return 0, errors.New("hpp v2 service is not configured")
|
||||
}
|
||||
|
||||
pfkIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, projectFlockID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
total := 0.0
|
||||
for _, pfkID := range pfkIDs {
|
||||
reg, err := s.HppV2Svc.GetBopRegularProductionScopeRange(pfkID, &startBreakdownDate, &endBreakdownDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
eksp, err := s.HppV2Svc.GetBopEkspedisiProductionScopeRange(pfkID, &startBreakdownDate, &endBreakdownDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += reg + eksp
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// sumHppPerFarmDepreciationOverRange sums the daily depreciation_value from
|
||||
// farm_depreciation_snapshots across [startDate, endDate] per project flock,
|
||||
// computing (and persisting) any missing daily snapshot on demand — same lazy
|
||||
// compute path the single-day depreciation report uses.
|
||||
func (s *repportService) sumHppPerFarmDepreciationOverRange(ctx context.Context, startDate, endDate time.Time, projectFlockIDs []uint) (map[uint]float64, error) {
|
||||
acc := make(map[uint]float64, len(projectFlockIDs))
|
||||
if len(projectFlockIDs) == 0 {
|
||||
return acc, nil
|
||||
}
|
||||
if s.ExpenseDepreciationRepo == nil {
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
|
||||
}
|
||||
|
||||
for day := startDate; !day.After(endDate); day = day.AddDate(0, 0, 1) {
|
||||
snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx, day, projectFlockIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
byID[snapshot.ProjectFlockId] = snapshot
|
||||
}
|
||||
|
||||
missing := make([]uint, 0)
|
||||
for _, id := range projectFlockIDs {
|
||||
if _, ok := byID[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
computed, err := s.computeExpenseDepreciationSnapshots(ctx, day, missing, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(computed) > 0 {
|
||||
if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx, computed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, snapshot := range computed {
|
||||
byID[snapshot.ProjectFlockId] = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id, snapshot := range byID {
|
||||
acc[id] += snapshot.DepreciationValue
|
||||
}
|
||||
}
|
||||
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func hppPerFarmProductionScopeTotalsByCode(breakdown *approvalService.HppV2Breakdown) map[string]float64 {
|
||||
out := make(map[string]float64)
|
||||
if breakdown == nil {
|
||||
return out
|
||||
}
|
||||
for i := range breakdown.Components {
|
||||
comp := &breakdown.Components[i]
|
||||
out[comp.Code] += hppPerFarmProductionScopeTotal(comp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hppPerFarmProductionScopeTotal mirrors the engine's componentScopeTotal for the
|
||||
// production_cost scope (that helper is unexported in the common service package).
|
||||
func hppPerFarmProductionScopeTotal(component *approvalService.HppV2Component) float64 {
|
||||
if component == nil {
|
||||
return 0
|
||||
}
|
||||
total := 0.0
|
||||
hasPartScopes := false
|
||||
for i := range component.Parts {
|
||||
part := &component.Parts[i]
|
||||
if len(part.Scopes) == 0 {
|
||||
continue
|
||||
}
|
||||
hasPartScopes = true
|
||||
for _, scope := range part.Scopes {
|
||||
if scope == hppPerFarmProductionScope {
|
||||
total += part.Total
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasPartScopes {
|
||||
return total
|
||||
}
|
||||
for _, scope := range component.Scopes {
|
||||
if scope == hppPerFarmProductionScope {
|
||||
return component.Total
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func hppPerFarmSafeDiv(numerator, denominator float64) float64 {
|
||||
if denominator <= 0 {
|
||||
return 0
|
||||
}
|
||||
value := numerator / denominator
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *repportService) parseHppPerFarmQuery(ctx *fiber.Ctx) (*validation.HppPerFarmQuery, dto.HppPerFarmFiltersDTO, 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", "")
|
||||
startDate := ctx.Query("start_date", "")
|
||||
endDate := ctx.Query("end_date", "")
|
||||
|
||||
if strings.TrimSpace(startDate) == "" {
|
||||
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "start_date is required")
|
||||
}
|
||||
if strings.TrimSpace(endDate) == "" {
|
||||
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "end_date is required")
|
||||
}
|
||||
if strings.TrimSpace(rawLocation) == "" {
|
||||
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "location_id is required")
|
||||
}
|
||||
|
||||
areaIDs, err := parseCommaSeparatedInt64s(rawArea)
|
||||
if err != nil {
|
||||
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
locationIDs, err := parseCommaSeparatedInt64s(rawLocation)
|
||||
if err != nil {
|
||||
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB())
|
||||
if err != nil {
|
||||
return nil, dto.HppPerFarmFiltersDTO{}, err
|
||||
}
|
||||
areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB())
|
||||
if err != nil {
|
||||
return nil, dto.HppPerFarmFiltersDTO{}, 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.HppPerFarmQuery{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
AreaIDs: areaIDs,
|
||||
LocationIDs: locationIDs,
|
||||
}
|
||||
filters := dto.NewHppPerFarmFiltersDTO(rawArea, rawLocation, startDate, endDate)
|
||||
return params, filters, nil
|
||||
}
|
||||
|
||||
func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validation.ExpenseDepreciationQuery, dto.ExpenseDepreciationFiltersDTO, error) {
|
||||
page := ctx.QueryInt("page", 1)
|
||||
if page < 1 {
|
||||
|
||||
@@ -78,6 +78,15 @@ type HppPerKandangQuery struct {
|
||||
WeightMax *float64 `query:"-"`
|
||||
}
|
||||
|
||||
type HppPerFarmQuery struct {
|
||||
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
|
||||
StartDate string `query:"start_date" validate:"required,datetime=2006-01-02"`
|
||||
EndDate string `query:"end_date" validate:"required,datetime=2006-01-02"`
|
||||
AreaIDs []int64 `query:"-"`
|
||||
LocationIDs []int64 `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"`
|
||||
|
||||
@@ -205,6 +205,7 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
|
||||
|
||||
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
|
||||
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
|
||||
firstCommonWeekByStd := make(map[uint]int, len(standardIDs))
|
||||
|
||||
for standardID := range standardIDs {
|
||||
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
|
||||
@@ -242,6 +243,10 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
|
||||
growthMap[growth.Week] = &growth
|
||||
}
|
||||
growthDetailByStd[standardID] = growthMap
|
||||
|
||||
if firstCommonWeek, ok := firstCommonStandardWeek(detailMap, growthMap); ok {
|
||||
firstCommonWeekByStd[standardID] = firstCommonWeek
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-load laying transfer targets → EARLIEST source PFK chick_in_date per target.
|
||||
@@ -284,6 +289,9 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
|
||||
continue
|
||||
}
|
||||
week := computeTransferAwareWeek(item, sourceChickInByTarget)
|
||||
if firstCommonWeek, ok := firstCommonWeekByStd[standardID]; ok {
|
||||
week = effectiveProductionStandardWeek(item, week, firstCommonWeek)
|
||||
}
|
||||
item.StandardWeek = &week
|
||||
cacheKey := standardKey{standardID: standardID, week: week}
|
||||
if cached, ok := cache[cacheKey]; ok {
|
||||
@@ -324,6 +332,38 @@ func applyProductionStandardValues(item *entity.Recording, values productionStan
|
||||
item.StandardFcr = fcr
|
||||
}
|
||||
|
||||
func firstCommonStandardWeek(
|
||||
detailMap map[int]*entity.ProductionStandardDetail,
|
||||
growthMap map[int]*entity.StandardGrowthDetail,
|
||||
) (int, bool) {
|
||||
firstWeek := 0
|
||||
for week := range detailMap {
|
||||
if week <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := growthMap[week]; !ok {
|
||||
continue
|
||||
}
|
||||
if firstWeek == 0 || week < firstWeek {
|
||||
firstWeek = week
|
||||
}
|
||||
}
|
||||
return firstWeek, firstWeek > 0
|
||||
}
|
||||
|
||||
func effectiveProductionStandardWeek(item *entity.Recording, actualWeek int, firstCommonWeek int) int {
|
||||
if item == nil || actualWeek <= 0 || firstCommonWeek <= 0 {
|
||||
return actualWeek
|
||||
}
|
||||
if !IsLayingRecording(*item) {
|
||||
return actualWeek
|
||||
}
|
||||
if actualWeek < firstCommonWeek {
|
||||
return firstCommonWeek
|
||||
}
|
||||
return actualWeek
|
||||
}
|
||||
|
||||
// collectLayingPFKIDs mengumpulkan semua project_flock_kandang_id dari recording laying
|
||||
func collectLayingPFKIDs(items []*entity.Recording) []uint {
|
||||
seen := make(map[uint]struct{})
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package recording
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMapDepletionsKeepsSourceWarehouseRoutes(t *testing.T) {
|
||||
@@ -45,3 +50,126 @@ func TestMapEggsSetsProjectFlockKandangID(t *testing.T) {
|
||||
t.Fatalf("expected project flock kandang id 44, got %+v", got[0].ProjectFlockKandangId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachProductionStandardsClampsLayingPreStandardWeek(t *testing.T) {
|
||||
db := setupAttachProductionStandardTestDB(t)
|
||||
|
||||
day := 91
|
||||
recordDate := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
||||
chickInDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
recording := &entity.Recording{
|
||||
Id: 501,
|
||||
ProjectFlockKandangId: 103,
|
||||
RecordDatetime: recordDate,
|
||||
Day: &day,
|
||||
ProjectFlockKandang: &entity.ProjectFlockKandang{
|
||||
Id: 103,
|
||||
ProjectFlock: entity.ProjectFlock{
|
||||
Id: 52,
|
||||
Category: string(utils.ProjectFlockCategoryLaying),
|
||||
ProductionStandardId: 1,
|
||||
ProductionStandard: entity.ProductionStandard{
|
||||
Id: 1,
|
||||
Name: "STD Laying",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
actualWeek := computeTransferAwareWeek(recording, map[uint]time.Time{103: chickInDate})
|
||||
if actualWeek != 13 {
|
||||
t.Fatalf("expected actual transfer-aware week 13, got %d", actualWeek)
|
||||
}
|
||||
|
||||
if err := AttachProductionStandards(context.Background(), db, false, nil, recording); err != nil {
|
||||
t.Fatalf("expected attach standard to succeed, got %v", err)
|
||||
}
|
||||
|
||||
if recording.Day == nil || *recording.Day != 91 {
|
||||
t.Fatalf("expected actual recording day to remain 91, got %+v", recording.Day)
|
||||
}
|
||||
if recording.StandardWeek == nil || *recording.StandardWeek != 18 {
|
||||
t.Fatalf("expected effective standard week 18, got %+v", recording.StandardWeek)
|
||||
}
|
||||
if recording.StandardFeedIntake == nil || *recording.StandardFeedIntake != 120 {
|
||||
t.Fatalf("expected feed intake std from week 18, got %+v", recording.StandardFeedIntake)
|
||||
}
|
||||
if recording.StandardHenDay == nil || *recording.StandardHenDay != 80 {
|
||||
t.Fatalf("expected hen day std from week 18, got %+v", recording.StandardHenDay)
|
||||
}
|
||||
if recording.StandardFcr == nil || *recording.StandardFcr != 2.1 {
|
||||
t.Fatalf("expected fcr std from week 18, got %+v", recording.StandardFcr)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAttachProductionStandardTestDB(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)
|
||||
}
|
||||
|
||||
statements := []string{
|
||||
`CREATE TABLE production_standard_details (
|
||||
id INTEGER PRIMARY KEY,
|
||||
production_standard_id INTEGER NOT NULL,
|
||||
week INTEGER NOT NULL,
|
||||
target_hen_day_production NUMERIC NULL,
|
||||
target_hen_house_production NUMERIC NULL,
|
||||
target_egg_weight NUMERIC NULL,
|
||||
target_egg_mass NUMERIC NULL,
|
||||
standard_fcr NUMERIC NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL
|
||||
)`,
|
||||
`CREATE TABLE standard_growth_details (
|
||||
id INTEGER PRIMARY KEY,
|
||||
production_standard_id INTEGER NOT NULL,
|
||||
target_mean_bw NUMERIC NULL,
|
||||
max_depletion NUMERIC NULL,
|
||||
min_uniformity NUMERIC NOT NULL,
|
||||
week INTEGER NOT NULL,
|
||||
feed_intake NUMERIC NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
created_by INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE laying_transfer_targets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
laying_transfer_id INTEGER NOT NULL,
|
||||
target_project_flock_kandang_id INTEGER NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
)`,
|
||||
`CREATE TABLE laying_transfers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_project_flock_kandang_id INTEGER NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
)`,
|
||||
`CREATE TABLE project_chickins (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_flock_kandang_id INTEGER NOT NULL,
|
||||
chick_in_date TIMESTAMP NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
)`,
|
||||
`INSERT INTO production_standard_details
|
||||
(id, production_standard_id, week, target_hen_day_production, target_hen_house_production, target_egg_weight, target_egg_mass, standard_fcr)
|
||||
VALUES (1, 1, 18, 80, 70, 55, 44, 2.1)`,
|
||||
`INSERT INTO standard_growth_details
|
||||
(id, production_standard_id, week, feed_intake, max_depletion, min_uniformity, created_by)
|
||||
VALUES (1, 1, 18, 120, 1.5, 80, 1)`,
|
||||
`INSERT INTO laying_transfers (id, source_project_flock_kandang_id, deleted_at) VALUES
|
||||
(77, 83, NULL)`,
|
||||
`INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES
|
||||
(88, 77, 103, NULL)`,
|
||||
`INSERT INTO project_chickins (id, project_flock_kandang_id, chick_in_date, deleted_at) VALUES
|
||||
(99, 83, '2026-01-01 00:00:00', NULL)`,
|
||||
}
|
||||
|
||||
for _, stmt := range statements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
t.Fatalf("failed preparing schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user