mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dc4592f08 | |||
| 690db8b485 | |||
| 22bf66dbb9 | |||
| f836685253 | |||
| 5e9286428f | |||
| 61e15dd95d | |||
| 59d72f20b4 | |||
| 540434e33b | |||
| 0ebad48348 | |||
| 9ab4e1a6ef | |||
| 0a900986e7 | |||
| 217f35b250 | |||
| b3887b8d08 | |||
| 2ddfa57aed | |||
| 085d2f9bfe | |||
| 61d375a59a | |||
| 09242a6998 | |||
| 7639e30326 | |||
| 2216f572c2 | |||
| edfd6ac95c |
@@ -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
|
DepreciationPercentEffective float64
|
||||||
DepreciationValue float64
|
DepreciationValue float64
|
||||||
PulletCostDayNTotal float64
|
PulletCostDayNTotal float64
|
||||||
|
Components []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type HppV2CostRepository interface {
|
type HppV2CostRepository interface {
|
||||||
@@ -404,7 +405,7 @@ func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeri
|
|||||||
var row HppV2FarmDepreciationSnapshotRow
|
var row HppV2FarmDepreciationSnapshotRow
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Table("farm_depreciation_snapshots").
|
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("project_flock_id = ?", projectFlockID).
|
||||||
Where("period_date = DATE(?)", periodDate).
|
Where("period_date = DATE(?)", periodDate).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
@@ -1472,6 +1473,18 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
|
|||||||
depreciationPercent = (appliedDepreciation / appliedPulletCostDayN) * 100
|
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{
|
return &HppV2ComponentPart{
|
||||||
Code: hppV2PartDepreciationFarmSnapshot,
|
Code: hppV2PartDepreciationFarmSnapshot,
|
||||||
Title: "Farm Snapshot",
|
Title: "Farm Snapshot",
|
||||||
@@ -1483,14 +1496,7 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
|
|||||||
Denominator: denominator,
|
Denominator: denominator,
|
||||||
Ratio: ratio,
|
Ratio: ratio,
|
||||||
},
|
},
|
||||||
Details: map[string]any{
|
Details: details,
|
||||||
"basis_total": snapshot.DepreciationValue,
|
|
||||||
"pullet_cost_day_n": appliedPulletCostDayN,
|
|
||||||
"depreciation_percent": depreciationPercent,
|
|
||||||
"snapshot_id": snapshot.ID,
|
|
||||||
"snapshot_period_date": formatDateOnly(snapshot.PeriodDate),
|
|
||||||
"snapshot_project_flock": snapshot.ProjectFlockID,
|
|
||||||
},
|
|
||||||
References: []HppV2Reference{
|
References: []HppV2Reference{
|
||||||
{
|
{
|
||||||
Type: "farm_depreciation_snapshot",
|
Type: "farm_depreciation_snapshot",
|
||||||
@@ -1504,6 +1510,84 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
|
|||||||
}, nil
|
}, 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(
|
func (s *hppV2Service) buildNormalTransferDepreciationPart(
|
||||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||||
transferInput *commonRepo.HppV2LatestTransferInputRow,
|
transferInput *commonRepo.HppV2LatestTransferInputRow,
|
||||||
|
|||||||
@@ -825,6 +825,28 @@ func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggPro
|
|||||||
DepreciationPercentEffective: 10,
|
DepreciationPercentEffective: 10,
|
||||||
DepreciationValue: 1000,
|
DepreciationValue: 1000,
|
||||||
PulletCostDayNTotal: 10000,
|
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 {
|
eggProductionByPFK: map[uint]struct {
|
||||||
@@ -873,6 +895,21 @@ func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggPro
|
|||||||
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
|
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
|
||||||
t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details)
|
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 {
|
func stubKey(ids []uint, flags []string) string {
|
||||||
|
|||||||
+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;
|
||||||
+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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE phases SET is_active = true
|
||||||
|
WHERE id IN (2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE phases SET is_active = false
|
||||||
|
WHERE id IN (2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26);
|
||||||
@@ -7,7 +7,20 @@ type RecordingStock struct {
|
|||||||
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"`
|
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"`
|
||||||
UsageQty *float64 `gorm:"column:usage_qty"`
|
UsageQty *float64 `gorm:"column:usage_qty"`
|
||||||
PendingQty *float64 `gorm:"column:pending_qty"`
|
PendingQty *float64 `gorm:"column:pending_qty"`
|
||||||
|
TotalPrice float64 `gorm:"-"`
|
||||||
|
Allocations []RecordingStockAlloc `gorm:"-"`
|
||||||
|
|
||||||
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
||||||
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecordingStockAlloc struct {
|
||||||
|
SourceType string
|
||||||
|
SourceId uint
|
||||||
|
PrNumber string
|
||||||
|
PoNumber string
|
||||||
|
AdjNumber string
|
||||||
|
Qty float64
|
||||||
|
UnitPrice float64
|
||||||
|
Subtotal float64
|
||||||
|
}
|
||||||
|
|||||||
@@ -789,11 +789,56 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
|
|||||||
|
|
||||||
totalActualPopulation := totalChickinQty - totalDepletion
|
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)
|
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount)
|
||||||
|
|
||||||
return &result, nil
|
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 {
|
type activeKandangMetricRow struct {
|
||||||
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
|
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
|
||||||
ProjectFlockID uint `gorm:"column:project_flock_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)
|
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)
|
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
|
||||||
return &data, nil
|
return &data, nil
|
||||||
@@ -386,7 +386,7 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti
|
|||||||
return dto.ToHPPSection(hppItems, hppSummary)
|
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
|
totalWeightProduced := production.TotalWeightProduced
|
||||||
totalEggWeightKg := production.TotalEggWeightKg
|
totalEggWeightKg := production.TotalEggWeightKg
|
||||||
@@ -394,6 +394,11 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
|
|||||||
totalWeightSold := production.TotalWeightSold
|
totalWeightSold := production.TotalWeightSold
|
||||||
totalBirdSold := production.TotalBirdSold
|
totalBirdSold := production.TotalBirdSold
|
||||||
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
|
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)
|
isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)
|
||||||
|
|
||||||
|
|||||||
@@ -1215,7 +1215,9 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(phaseIDs) > 0 {
|
if len(phaseIDs) > 0 {
|
||||||
phases, err := s.PhaseRepo.GetByIDs(c.Context(), phaseIDs, nil)
|
phases, err := s.PhaseRepo.GetByIDs(c.Context(), phaseIDs, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Where("is_active = true")
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Phase not found")
|
return fiber.NewError(fiber.StatusBadRequest, "Phase not found")
|
||||||
|
|||||||
@@ -72,9 +72,9 @@ func (s kandangGroupService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
|
|||||||
}
|
}
|
||||||
|
|
||||||
if params.OrderBy == "desc" || params.OrderBy == "" {
|
if params.OrderBy == "desc" || params.OrderBy == "" {
|
||||||
db = db.Order(fmt.Sprintf("kandang_groups.%s DESC", params.SortBy))
|
db = db.Order(fmt.Sprintf("kandang_groups.%s DESC, kandang_groups.id ASC", params.SortBy))
|
||||||
} else {
|
} else {
|
||||||
db = db.Order(fmt.Sprintf("kandang_groups.%s ASC", params.SortBy))
|
db = db.Order(fmt.Sprintf("kandang_groups.%s ASC, kandang_groups.id ASC", params.SortBy))
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
|
|||||||
@@ -20,6 +20,6 @@ type Query struct {
|
|||||||
Search string `query:"search" validate:"omitempty,max=50"`
|
Search string `query:"search" validate:"omitempty,max=50"`
|
||||||
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
|
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
|
||||||
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
|
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
|
||||||
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
|
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"name"`
|
||||||
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
|
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"asc"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.
|
|||||||
|
|
||||||
phasess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
phasess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
||||||
db = s.withRelations(db)
|
db = s.withRelations(db)
|
||||||
|
db = db.Where("is_active = true")
|
||||||
if params.Search != "" {
|
if params.Search != "" {
|
||||||
return db.Where("name ILIKE ?", "%"+params.Search+"%")
|
return db.Where("name ILIKE ?", "%"+params.Search+"%")
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-11
@@ -387,35 +387,87 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
week := ((day - 1) / 7) + 1
|
requestedWeek := ((day - 1) / 7) + 1
|
||||||
if week <= 0 {
|
if requestedWeek <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
upperCategory := strings.ToUpper(category)
|
upperCategory := strings.ToUpper(category)
|
||||||
if upperCategory == string(utils.ProjectFlockCategoryLaying) {
|
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 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
|
return err
|
||||||
}
|
}
|
||||||
if detail == nil {
|
if ok && requestedWeek < firstCommonWeek {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
|
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 err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if growthDetail == nil {
|
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
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,10 +131,23 @@ type RecordingDepletionDTO struct {
|
|||||||
ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"`
|
ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecordingStockAllocDTO struct {
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
SourceId uint `json:"source_id"`
|
||||||
|
PrNumber string `json:"pr_number"`
|
||||||
|
PoNumber string `json:"po_number"`
|
||||||
|
AdjNumber string `json:"adj_number"`
|
||||||
|
Qty float64 `json:"qty"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
Subtotal float64 `json:"subtotal"`
|
||||||
|
}
|
||||||
|
|
||||||
type RecordingStockDTO struct {
|
type RecordingStockDTO struct {
|
||||||
ProductWarehouseId uint `json:"product_warehouse_id"`
|
ProductWarehouseId uint `json:"product_warehouse_id"`
|
||||||
UsageAmount float64 `json:"usage_amount"`
|
UsageAmount float64 `json:"usage_amount"`
|
||||||
PendingQty float64 `json:"pending_qty"`
|
PendingQty float64 `json:"pending_qty"`
|
||||||
|
TotalPrice float64 `json:"total_price"`
|
||||||
|
Allocations []RecordingStockAllocDTO `json:"allocations"`
|
||||||
ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"`
|
ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,10 +210,26 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO {
|
|||||||
pendingQty = *s.PendingQty
|
pendingQty = *s.PendingQty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allocs := make([]RecordingStockAllocDTO, len(s.Allocations))
|
||||||
|
for j, a := range s.Allocations {
|
||||||
|
allocs[j] = RecordingStockAllocDTO{
|
||||||
|
SourceType: a.SourceType,
|
||||||
|
SourceId: a.SourceId,
|
||||||
|
PrNumber: a.PrNumber,
|
||||||
|
PoNumber: a.PoNumber,
|
||||||
|
AdjNumber: a.AdjNumber,
|
||||||
|
Qty: a.Qty,
|
||||||
|
UnitPrice: a.UnitPrice,
|
||||||
|
Subtotal: a.Subtotal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result[i] = RecordingStockDTO{
|
result[i] = RecordingStockDTO{
|
||||||
ProductWarehouseId: s.ProductWarehouseId,
|
ProductWarehouseId: s.ProductWarehouseId,
|
||||||
UsageAmount: usageAmount,
|
UsageAmount: usageAmount,
|
||||||
PendingQty: pendingQty,
|
PendingQty: pendingQty,
|
||||||
|
TotalPrice: s.TotalPrice,
|
||||||
|
Allocations: allocs,
|
||||||
ProductWarehouse: mapProductWarehouseDTO(&s.ProductWarehouse),
|
ProductWarehouse: mapProductWarehouseDTO(&s.ProductWarehouse),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ type RecordingRepository interface {
|
|||||||
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
|
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
|
||||||
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
|
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
|
||||||
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
|
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
|
||||||
|
GetStockAllocationsByIDs(ctx context.Context, stockIDs []uint) (map[uint][]entity.RecordingStockAlloc, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecordingRepositoryImpl struct {
|
type RecordingRepositoryImpl struct {
|
||||||
@@ -1231,3 +1232,71 @@ func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectF
|
|||||||
|
|
||||||
return result.TotalWeight, err
|
return result.TotalWeight, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *RecordingRepositoryImpl) GetStockAllocationsByIDs(ctx context.Context, stockIDs []uint) (map[uint][]entity.RecordingStockAlloc, error) {
|
||||||
|
if len(stockIDs) == 0 {
|
||||||
|
return map[uint][]entity.RecordingStockAlloc{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
RecordingStockId uint
|
||||||
|
SourceType string
|
||||||
|
SourceId uint
|
||||||
|
PrNumber string
|
||||||
|
PoNumber string
|
||||||
|
AdjNumber string
|
||||||
|
Qty float64
|
||||||
|
UnitPrice float64
|
||||||
|
Subtotal float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
err := r.DB().WithContext(ctx).Raw(`
|
||||||
|
SELECT
|
||||||
|
sa.usable_id AS recording_stock_id,
|
||||||
|
sa.stockable_type AS source_type,
|
||||||
|
sa.stockable_id AS source_id,
|
||||||
|
COALESCE(p.pr_number, '') AS pr_number,
|
||||||
|
COALESCE(p.po_number, '') AS po_number,
|
||||||
|
COALESCE(ast.adj_number, '') AS adj_number,
|
||||||
|
sa.qty AS qty,
|
||||||
|
COALESCE(CASE
|
||||||
|
WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN pi.price
|
||||||
|
WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN ast.price
|
||||||
|
END, 0) AS unit_price,
|
||||||
|
sa.qty * COALESCE(CASE
|
||||||
|
WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN pi.price
|
||||||
|
WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN ast.price
|
||||||
|
END, 0) AS subtotal
|
||||||
|
FROM stock_allocations sa
|
||||||
|
LEFT JOIN purchase_items pi
|
||||||
|
ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'
|
||||||
|
LEFT JOIN purchases p
|
||||||
|
ON p.id = pi.purchase_id
|
||||||
|
LEFT JOIN adjustment_stocks ast
|
||||||
|
ON ast.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'
|
||||||
|
WHERE sa.usable_type = 'RECORDING_STOCK'
|
||||||
|
AND sa.usable_id IN ?
|
||||||
|
AND sa.status = 'ACTIVE'
|
||||||
|
AND sa.allocation_purpose = 'CONSUME'
|
||||||
|
ORDER BY sa.usable_id, sa.id
|
||||||
|
`, stockIDs).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[uint][]entity.RecordingStockAlloc)
|
||||||
|
for _, row := range rows {
|
||||||
|
result[row.RecordingStockId] = append(result[row.RecordingStockId], entity.RecordingStockAlloc{
|
||||||
|
SourceType: row.SourceType,
|
||||||
|
SourceId: row.SourceId,
|
||||||
|
PrNumber: row.PrNumber,
|
||||||
|
PoNumber: row.PoNumber,
|
||||||
|
AdjNumber: row.AdjNumber,
|
||||||
|
Qty: row.Qty,
|
||||||
|
UnitPrice: row.UnitPrice,
|
||||||
|
Subtotal: row.Subtotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type RecordingService interface {
|
type RecordingService interface {
|
||||||
@@ -279,6 +278,26 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
|
|||||||
s.Log.Errorf("Failed get recording by id: %+v", err)
|
s.Log.Errorf("Failed get recording by id: %+v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(recording.Stocks) > 0 {
|
||||||
|
stockIDs := make([]uint, len(recording.Stocks))
|
||||||
|
for i, s := range recording.Stocks {
|
||||||
|
stockIDs[i] = s.Id
|
||||||
|
}
|
||||||
|
if allocMap, err := s.Repository.GetStockAllocationsByIDs(c.Context(), stockIDs); err != nil {
|
||||||
|
s.Log.Warnf("Failed to get stock allocations for recording %d: %+v", id, err)
|
||||||
|
} else {
|
||||||
|
for i := range recording.Stocks {
|
||||||
|
allocs := allocMap[recording.Stocks[i].Id]
|
||||||
|
recording.Stocks[i].Allocations = allocs
|
||||||
|
var total float64
|
||||||
|
for _, a := range allocs {
|
||||||
|
total += a.Subtotal
|
||||||
|
}
|
||||||
|
recording.Stocks[i].TotalPrice = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := recordingutil.AttachLatestApproval(c.Context(), recording, s.ApprovalSvc, s.Log); err != nil {
|
if err := recordingutil.AttachLatestApproval(c.Context(), recording, s.ApprovalSvc, s.Log); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -586,10 +605,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
|
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
|
||||||
s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
action := entity.ApprovalActionCreated
|
action := entity.ApprovalActionCreated
|
||||||
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
|
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
|
||||||
@@ -892,12 +907,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hasStockChanges {
|
|
||||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
|
||||||
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
action := entity.ApprovalActionUpdated
|
action := entity.ApprovalActionUpdated
|
||||||
actorID := recordingEntity.CreatedBy
|
actorID := recordingEntity.CreatedBy
|
||||||
@@ -1159,10 +1168,6 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
|
||||||
s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
|
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1949,172 +1954,6 @@ func (s *recordingService) getEarliestChickInDateByProjectFlockKandangID(ctx con
|
|||||||
return row.ChickInDate, nil
|
return row.ChickInDate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordingService) syncFarmDepreciationManualInputFromRecordingStocks(
|
|
||||||
ctx context.Context,
|
|
||||||
tx *gorm.DB,
|
|
||||||
projectFlockKandangID uint,
|
|
||||||
fallbackCutoverDate time.Time,
|
|
||||||
) error {
|
|
||||||
if projectFlockKandangID == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDB := s.Repository.DB()
|
|
||||||
if tx != nil {
|
|
||||||
targetDB = tx
|
|
||||||
}
|
|
||||||
|
|
||||||
projectFlockID, err := s.resolveProjectFlockIDByProjectFlockKandangID(ctx, targetDB, projectFlockKandangID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if projectFlockID == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCost, err := s.sumNoTransferRecordingStockCostByProjectFlockID(ctx, targetDB, projectFlockID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, err := s.getFarmDepreciationManualInputByProjectFlockID(ctx, targetDB, projectFlockID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoverDate := normalizeDateOnlyUTC(fallbackCutoverDate)
|
|
||||||
if existing != nil && !existing.CutoverDate.IsZero() {
|
|
||||||
cutoverDate = normalizeDateOnlyUTC(existing.CutoverDate)
|
|
||||||
}
|
|
||||||
if cutoverDate.IsZero() {
|
|
||||||
earliestDate, dateErr := s.getEarliestNoTransferRecordingDateByProjectFlockID(ctx, targetDB, projectFlockID)
|
|
||||||
if dateErr != nil {
|
|
||||||
return dateErr
|
|
||||||
}
|
|
||||||
if earliestDate != nil && !earliestDate.IsZero() {
|
|
||||||
cutoverDate = normalizeDateOnlyUTC(*earliestDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cutoverDate.IsZero() {
|
|
||||||
cutoverDate = normalizeDateOnlyUTC(time.Now().UTC())
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
row := entity.FarmDepreciationManualInput{
|
|
||||||
ProjectFlockId: projectFlockID,
|
|
||||||
TotalCost: totalCost,
|
|
||||||
CutoverDate: cutoverDate,
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
row.Note = existing.Note
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetDB.WithContext(ctx).
|
|
||||||
Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "project_flock_id"}},
|
|
||||||
DoUpdates: clause.Assignments(map[string]any{
|
|
||||||
"total_cost": row.TotalCost,
|
|
||||||
"cutover_date": row.CutoverDate,
|
|
||||||
"updated_at": now,
|
|
||||||
}),
|
|
||||||
}).
|
|
||||||
Create(&row).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *recordingService) resolveProjectFlockIDByProjectFlockKandangID(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (uint, error) {
|
|
||||||
var row struct {
|
|
||||||
ProjectFlockID uint `gorm:"column:project_flock_id"`
|
|
||||||
}
|
|
||||||
err := db.WithContext(ctx).
|
|
||||||
Table("project_flock_kandangs").
|
|
||||||
Select("project_flock_id").
|
|
||||||
Where("id = ?", projectFlockKandangID).
|
|
||||||
Take(&row).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return row.ProjectFlockID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *recordingService) sumNoTransferRecordingStockCostByProjectFlockID(ctx context.Context, db *gorm.DB, projectFlockID uint) (float64, error) {
|
|
||||||
if projectFlockID == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var total float64
|
|
||||||
err := db.WithContext(ctx).
|
|
||||||
Table("recording_stocks AS rs").
|
|
||||||
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
|
||||||
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
|
|
||||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
|
|
||||||
Joins(
|
|
||||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
|
||||||
fifo.UsableKeyRecordingStock.String(),
|
|
||||||
fifo.StockableKeyPurchaseItems.String(),
|
|
||||||
entity.StockAllocationStatusActive,
|
|
||||||
entity.StockAllocationPurposeConsume,
|
|
||||||
).
|
|
||||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
|
||||||
Where("pfk.project_flock_id = ?", projectFlockID).
|
|
||||||
Where("rs.project_flock_kandang_id IS NULL").
|
|
||||||
Scan(&total).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *recordingService) getFarmDepreciationManualInputByProjectFlockID(
|
|
||||||
ctx context.Context,
|
|
||||||
db *gorm.DB,
|
|
||||||
projectFlockID uint,
|
|
||||||
) (*entity.FarmDepreciationManualInput, error) {
|
|
||||||
if projectFlockID == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var row entity.FarmDepreciationManualInput
|
|
||||||
err := db.WithContext(ctx).
|
|
||||||
Where("project_flock_id = ?", projectFlockID).
|
|
||||||
Take(&row).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *recordingService) getEarliestNoTransferRecordingDateByProjectFlockID(
|
|
||||||
ctx context.Context,
|
|
||||||
db *gorm.DB,
|
|
||||||
projectFlockID uint,
|
|
||||||
) (*time.Time, error) {
|
|
||||||
if projectFlockID == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var row struct {
|
|
||||||
RecordDate *time.Time `gorm:"column:record_date"`
|
|
||||||
}
|
|
||||||
err := db.WithContext(ctx).
|
|
||||||
Table("recording_stocks AS rs").
|
|
||||||
Select("MIN(r.record_datetime) AS record_date").
|
|
||||||
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
|
|
||||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
|
|
||||||
Where("pfk.project_flock_id = ?", projectFlockID).
|
|
||||||
Where("rs.project_flock_kandang_id IS NULL").
|
|
||||||
Scan(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return row.RecordDate, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *recordingService) resolveEggRequestsToFarmWarehouses(
|
func (s *recordingService) resolveEggRequestsToFarmWarehouses(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
pfk *entity.ProjectFlockKandang,
|
pfk *entity.ProjectFlockKandang,
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ type ExpenseDepreciationV2RowDTO struct {
|
|||||||
TotalValuePulletAfterDepreciation float64 `json:"total_value_pullet_after_depreciation"`
|
TotalValuePulletAfterDepreciation float64 `json:"total_value_pullet_after_depreciation"`
|
||||||
StandardEffectiveDate string `json:"standard_effective_date,omitempty"`
|
StandardEffectiveDate string `json:"standard_effective_date,omitempty"`
|
||||||
TotalPopulation float64 `json:"total_population"`
|
TotalPopulation float64 `json:"total_population"`
|
||||||
Components any `json:"components"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO {
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppByDeliver
|
|||||||
realizationDate = *mdp.DeliveryDate
|
realizationDate = *mdp.DeliveryDate
|
||||||
}
|
}
|
||||||
|
|
||||||
totalWeightKg := mdp.UsageQty * mdp.AvgWeight
|
totalWeightKg := mdp.TotalWeight
|
||||||
salesAmount := totalWeightKg * mdp.UnitPrice
|
salesAmount := totalWeightKg * mdp.UnitPrice
|
||||||
|
|
||||||
var hpp float64
|
var hpp float64
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToMarketingReportItemsUsesDeliveryProductTotalWeight(t *testing.T) {
|
||||||
|
mdps := []entity.MarketingDeliveryProduct{
|
||||||
|
{
|
||||||
|
Id: 1,
|
||||||
|
UsageQty: 10,
|
||||||
|
AvgWeight: 2.5,
|
||||||
|
TotalWeight: 17.75,
|
||||||
|
UnitPrice: 1000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ToMarketingReportItems(mdps, nil, nil, nil)
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 marketing report item, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].TotalWeightKg != 17.75 {
|
||||||
|
t.Fatalf("expected total_weight_kg to use delivery product total_weight 17.75, got %.2f", got[0].TotalWeightKg)
|
||||||
|
}
|
||||||
|
if got[0].Qty != 10 {
|
||||||
|
t.Fatalf("expected qty to stay from usage_qty, got %.2f", got[0].Qty)
|
||||||
|
}
|
||||||
|
if got[0].AverageWeightKg != 2.5 {
|
||||||
|
t.Fatalf("expected average_weight_kg to stay from avg_weight, got %.2f", got[0].AverageWeightKg)
|
||||||
|
}
|
||||||
|
if got[0].SalesAmount != 17750 {
|
||||||
|
t.Fatalf("expected sales_amount to use delivery product total_weight, got %.2f", got[0].SalesAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarketingSummaryUsesReportItemTotalWeight(t *testing.T) {
|
||||||
|
items := []RepportMarketingItemDTO{
|
||||||
|
{
|
||||||
|
Qty: 10,
|
||||||
|
TotalWeightKg: 17.75,
|
||||||
|
SalesAmount: 17750,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Qty: 5,
|
||||||
|
TotalWeightKg: 8.25,
|
||||||
|
SalesAmount: 8250,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ToSummaryFromDTOItems(items)
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected summary, got nil")
|
||||||
|
}
|
||||||
|
if got.TotalWeightKg != 26 {
|
||||||
|
t.Fatalf("expected summary total_weight_kg to sum item total weights, got %.2f", got.TotalWeightKg)
|
||||||
|
}
|
||||||
|
if diff := math.Abs(got.AverageWeightKg - (26.0 / 15.0)); diff > 0.000001 {
|
||||||
|
t.Fatalf("expected summary average_weight_kg to use total_weight_kg / total_qty, got %.6f", got.AverageWeightKg)
|
||||||
|
}
|
||||||
|
if got.TotalQty != 15 {
|
||||||
|
t.Fatalf("expected total qty 15, got %d", got.TotalQty)
|
||||||
|
}
|
||||||
|
if got.TotalSalesAmount != 26000 {
|
||||||
|
t.Fatalf("expected total sales amount 26000, got %d", got.TotalSalesAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,12 @@ func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context,
|
|||||||
return nil
|
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) {
|
func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Context, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationManualInputRow, error) {
|
||||||
return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil
|
return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,7 +423,10 @@ func (s *repportService) GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.Expense
|
|||||||
var totalDepreciationValue float64
|
var totalDepreciationValue float64
|
||||||
var totalPulletCostDayN float64
|
var totalPulletCostDayN float64
|
||||||
var totalPopulation float64
|
var totalPopulation float64
|
||||||
var allKandangComponents []depreciationKandangComponent
|
var multiplicationPercentage float64
|
||||||
|
var dayN int
|
||||||
|
var chickinDate string
|
||||||
|
var standardEffectiveDate string
|
||||||
|
|
||||||
for _, kandangID := range kandangIDs {
|
for _, kandangID := range kandangIDs {
|
||||||
breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &dayDate)
|
breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &dayDate)
|
||||||
@@ -444,70 +447,31 @@ func (s *repportService) GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.Expense
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
houseType := approvalService.NormalizeDepreciationHouseType(breakdown.HouseType)
|
partPulletCostDayN := hppV2DetailFloat(part.Details, "pullet_cost_day_n")
|
||||||
component := depreciationKandangComponent{
|
partPopulation := hppV2DetailFloat(part.Details, "kandang_population")
|
||||||
ProjectFlockKandangID: breakdown.ProjectFlockKandangID,
|
partDayN := hppV2DetailInt(part.Details, "schedule_day")
|
||||||
KandangID: breakdown.KandangID,
|
partMultiplicationPercentage := hppV2DetailFloat(part.Details, "multiplication_percentage")
|
||||||
KandangName: breakdown.KandangName,
|
partChickinDate := hppV2DetailString(part.Details, "chickin_date")
|
||||||
SourceProjectFlockID: hppV2DetailUint(part.Details, "source_project_flock_id"),
|
if partChickinDate == "" {
|
||||||
HouseType: houseType,
|
partChickinDate = hppV2DetailString(part.Details, "origin_date")
|
||||||
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"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if component.HouseType == "" {
|
totalPulletCostDayN += partPulletCostDayN
|
||||||
component.HouseType = approvalService.NormalizeDepreciationHouseType(hppV2DetailString(part.Details, "house_type"))
|
totalDepreciationValue += part.Total
|
||||||
}
|
totalPopulation += partPopulation
|
||||||
|
|
||||||
if ref := hppV2FindReference(part.References, "laying_transfer"); ref != nil {
|
if dayN == 0 && multiplicationPercentage == 0 && chickinDate == "" &&
|
||||||
component.TransferID = ref.ID
|
(partDayN > 0 || partMultiplicationPercentage > 0 || partChickinDate != "") {
|
||||||
component.TransferDate = ref.Date
|
dayN = partDayN
|
||||||
component.TransferQty = ref.Qty
|
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)
|
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{
|
rows = append(rows, dto.ExpenseDepreciationV2RowDTO{
|
||||||
Date: dayStr,
|
Date: dayStr,
|
||||||
DepreciationPercentEffective: effectivePercent,
|
DepreciationPercentEffective: effectivePercent,
|
||||||
@@ -519,7 +483,6 @@ func (s *repportService) GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.Expense
|
|||||||
TotalValuePulletAfterDepreciation: totalPulletCostDayN - totalDepreciationValue,
|
TotalValuePulletAfterDepreciation: totalPulletCostDayN - totalDepreciationValue,
|
||||||
StandardEffectiveDate: standardEffectiveDate,
|
StandardEffectiveDate: standardEffectiveDate,
|
||||||
TotalPopulation: totalPopulation,
|
TotalPopulation: totalPopulation,
|
||||||
Components: parseSnapshotComponents(componentsJSON),
|
|
||||||
})
|
})
|
||||||
actualDays++
|
actualDays++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
|
|||||||
|
|
||||||
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
|
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
|
||||||
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
|
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
|
||||||
|
firstCommonWeekByStd := make(map[uint]int, len(standardIDs))
|
||||||
|
|
||||||
for standardID := range standardIDs {
|
for standardID := range standardIDs {
|
||||||
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
|
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
|
||||||
@@ -242,6 +243,10 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
|
|||||||
growthMap[growth.Week] = &growth
|
growthMap[growth.Week] = &growth
|
||||||
}
|
}
|
||||||
growthDetailByStd[standardID] = growthMap
|
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.
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
week := computeTransferAwareWeek(item, sourceChickInByTarget)
|
week := computeTransferAwareWeek(item, sourceChickInByTarget)
|
||||||
|
if firstCommonWeek, ok := firstCommonWeekByStd[standardID]; ok {
|
||||||
|
week = effectiveProductionStandardWeek(item, week, firstCommonWeek)
|
||||||
|
}
|
||||||
item.StandardWeek = &week
|
item.StandardWeek = &week
|
||||||
cacheKey := standardKey{standardID: standardID, week: week}
|
cacheKey := standardKey{standardID: standardID, week: week}
|
||||||
if cached, ok := cache[cacheKey]; ok {
|
if cached, ok := cache[cacheKey]; ok {
|
||||||
@@ -324,6 +332,38 @@ func applyProductionStandardValues(item *entity.Recording, values productionStan
|
|||||||
item.StandardFcr = fcr
|
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
|
// collectLayingPFKIDs mengumpulkan semua project_flock_kandang_id dari recording laying
|
||||||
func collectLayingPFKIDs(items []*entity.Recording) []uint {
|
func collectLayingPFKIDs(items []*entity.Recording) []uint {
|
||||||
seen := make(map[uint]struct{})
|
seen := make(map[uint]struct{})
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package recording
|
package recording
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
|
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) {
|
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)
|
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