Compare commits

...

57 Commits

Author SHA1 Message Date
giovanni aa3e655a67 adjust hpp per farm query to take feed and ovk 2026-06-06 10:29:33 +07:00
Giovanni Gabriel Septriadi 98bfdac3c5 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!603
2026-06-06 01:47:16 +00:00
Giovanni Gabriel Septriadi a98d026ccb Merge branch 'feat/hpp-per-farm' into 'development'
adjust list marketing

See merge request mbugroup/lti-api!604
2026-06-06 01:40:01 +00:00
giovanni c3eab60f49 adjust list marketing 2026-06-06 08:38:38 +07:00
Giovanni Gabriel Septriadi e455889dae Merge branch 'feat/hpp-per-farm' into 'development'
Feat/hpp per farm

See merge request mbugroup/lti-api!602
2026-06-06 01:20:25 +00:00
Giovanni Gabriel Septriadi be8b99e7e8 Merge branch 'feat/depresiasi-v2' into 'development'
adjust

See merge request mbugroup/lti-api!601
2026-06-06 01:12:45 +00:00
giovanni 5760bb6de8 adjust 2026-06-06 08:12:06 +07:00
Giovanni Gabriel Septriadi 1e8651b8f2 Merge branch 'fix/migration-do' into 'development'
fix over consume by code, revert migration overconsume sell

See merge request mbugroup/lti-api!600
2026-06-06 00:53:25 +00:00
giovanni 33bae94d43 fix over consume by code, revert migration overconsume sell 2026-06-06 07:49:34 +07:00
Giovanni Gabriel Septriadi efe9f0ce3c Merge branch 'feat/depresiasi-v2' into 'development'
Feat/depresiasi v2

See merge request mbugroup/lti-api!599
2026-06-05 06:51:58 +00:00
giovanni 1ef32407f1 create api get depresiasi v2 2026-06-05 13:51:09 +07:00
Giovanni Gabriel Septriadi 6d2b6a0cb8 Merge branch 'rc/01' into 'production'
Rc/01

See merge request mbugroup/lti-api!598
2026-06-05 06:24:40 +00:00
Giovanni Gabriel Septriadi 4d3f654772 Merge branch 'fix/daily-checklist-fk' into 'rc/01'
Fix/daily checklist fk

See merge request mbugroup/lti-api!597
2026-06-05 06:20:41 +00:00
Giovanni Gabriel Septriadi 1cd72e5598 Merge branch 'fix/daily-checklist-fk' into 'development'
Fix/daily checklist fk

See merge request mbugroup/lti-api!596
2026-06-05 06:00:54 +00:00
giovanni 2a101ed0db fix fk empty kandang to kandang_group 2026-06-05 12:58:08 +07:00
Giovanni Gabriel Septriadi 3e6ec39091 Merge branch 'rc/01' into 'production'
feat: add date range filter to marketing list API

See merge request mbugroup/lti-api!595
2026-06-04 17:30:25 +00:00
Giovanni Gabriel Septriadi 1b3642ef1d Merge branch 'feat/marketing-filter-range-date' into 'rc/01'
feat: add date range filter to marketing list API

See merge request mbugroup/lti-api!592
2026-06-04 16:57:32 +00:00
Giovanni Gabriel Septriadi 6b5a6a61b6 Merge branch 'feat/cut-over-depresiasi' into 'rc/01'
Feat/cut over depresiasi

See merge request mbugroup/lti-api!594
2026-06-04 16:57:11 +00:00
Giovanni Gabriel Septriadi d6304d9b39 Merge branch 'fix/recording-chickin' into 'rc/01'
Fix/recording chickin

See merge request mbugroup/lti-api!593
2026-06-04 16:51:24 +00:00
Giovanni Gabriel Septriadi b966777095 Merge branch 'feat/patch-chickindate' into 'rc/01'
Feat/patch chickindate

See merge request mbugroup/lti-api!591
2026-06-04 16:50:23 +00:00
giovanni f64839dfe1 add delete snapshoot if change chickin date 2026-06-04 23:46:25 +07:00
Giovanni Gabriel Septriadi 48870a60dc Merge branch 'feat/overselling-telur' into 'rc/01'
Feat/overselling telur

See merge request mbugroup/lti-api!590
2026-06-04 15:46:00 +00:00
Giovanni Gabriel Septriadi 7f701511d3 Merge branch 'feat/cut-over-depresiasi' into 'development'
Feat/cut over depresiasi

See merge request mbugroup/lti-api!589
2026-06-04 07:14:49 +00:00
giovanni 675c0ade61 fix calculate schedule day 2026-06-04 14:11:55 +07:00
giovanni b4f7c15d03 Merge branch 'feat/patch-chickindate' into feat/cut-over-depresiasi 2026-06-04 12:29:10 +07:00
giovanni 37de931b37 adjust migration for seed and depresiasi 2026-06-04 12:28:35 +07:00
giovanni a51e5302c3 add migration for seed to standar depresiasi and update data cutover sesuai excel ebitda 2026-06-03 22:08:26 +07:00
giovanni 968305fad0 Merge branch 'production' into feat/cut-over-depresiasi 2026-06-03 21:59:47 +07:00
Giovanni Gabriel Septriadi 9405c9d64b Merge branch 'feat/patch-chickindate' into 'development'
add api PATCH for edit chickin date

See merge request mbugroup/lti-api!588
2026-06-03 04:57:50 +00:00
giovanni 0ff720453f add api for edit chickin date 2026-06-03 11:56:32 +07:00
Giovanni Gabriel Septriadi b179ed2bc9 Merge branch 'feat/overselling-telur' into 'development'
add validasi overselling telur

See merge request mbugroup/lti-api!587
2026-06-03 03:28:17 +00:00
giovanni a70a69a5be add validasi overselling telur 2026-06-03 10:26:40 +07:00
giovanni 255e6a16d3 add validate query param 2026-06-03 09:43:34 +07:00
giovanni 93ed89b4ef ini api per farm 2026-06-03 00:30:41 +07:00
Rivaldi A N S b9201c2a4f Merge branch 'feat/marketing-filter-range-date' into 'development'
[FEAT][BE] Marketing Filter Range Date

See merge request mbugroup/lti-api!586
2026-06-02 09:50:02 +00:00
ValdiANS 981fb98248 fix: use soDate instead of deliveryDate for Delivery Order rows in marketing export
In the Excel export, Delivery Order rows were writing `group.DeliveryDate`
(the actual delivery date) to column B ("Tanggal"), while the web UI always
shows `so_date` for every row. This caused a visible mismatch — e.g. DO-01954
displayed "31 Mei 2026" on the web but "01-06-2026" in the exported file.

Changes:
- Remove the `doDate` variable from the DO branch; both the empty-deliveries
  fallback row and each per-delivery row now write `soDate` to column B,
  consistent with what the web shows
- Fix a pre-existing nil pointer dereference: `prod.ProductWarehouse.Warehouse`
  was accessed without a nil guard in the SO branch
- Update the export test to match the current 17-column layout (headers and
  row assertions were stale), and add a regression case that explicitly
  asserts a DO row with soDate=2026-05-31 / deliveryDate=2026-06-01 produces
  "31-05-2026" in column B

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:48:06 +07:00
Rivaldi A N S f443686505 Merge branch 'feat/marketing-filter-range-date' into 'development'
[FEAT][BE] Marketing Filter Range Date

See merge request mbugroup/lti-api!585
2026-06-02 06:27:57 +00:00
ValdiANS 4b9e86427d feat: add date range filter to marketing list API
Added start_date, end_date, and filter_by query parameters to the
GET /api/marketing/ endpoint. Users can now filter marketing records
by a date range using either so_date (Sales Order date, default) or
created_at as the target column.

Changes:
- validation: added StartDate, EndDate (YYYY-MM-DD format), and
  FilterBy (oneof: so_date, created_at) to DeliveryOrderQuery struct
- controller: parse the three new query params in GetAll handler
- service: apply >=start / <end+1day date range filter in the query
  modifier using the existing utils.ParseDateRangeForQuery helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:19:52 +07:00
Giovanni Gabriel Septriadi 9d8d54bd3c Merge branch 'fix/recording-chickin' into 'development'
Fix/recording chickin

See merge request mbugroup/lti-api!584
2026-06-02 03:40:59 +00:00
giovanni 4cb37e481b fix submit recording laying did not have chickin date 2026-06-02 10:39:50 +07:00
Giovanni Gabriel Septriadi ef2f9568ad Merge branch 'rc/01' into 'production'
Rc/01

See merge request mbugroup/lti-api!583
2026-06-01 15:01:07 +00:00
Giovanni Gabriel Septriadi badbe4086a Merge branch 'fix/reconcile-fifo' into 'rc/01'
add command to fix reconcile fifo; fix fifo stock v2

See merge request mbugroup/lti-api!582
2026-06-01 14:46:45 +00:00
Giovanni Gabriel Septriadi 6528739bfd Merge branch 'feat/export-marketing' into 'rc/01'
Feat/export marketing and recording

See merge request mbugroup/lti-api!581
2026-06-01 14:45:41 +00:00
giovanni 44b82a8e38 init add function command for create seed depretitaion standard 2026-06-01 21:07:30 +07:00
Giovanni Gabriel Septriadi 791c5880fd Merge branch 'fix/reconcile-fifo' into 'development'
Fix/reconcile fifo

See merge request mbugroup/lti-api!580
2026-06-01 14:03:06 +00:00
Giovanni Gabriel Septriadi 0581bf4a17 Merge branch 'feat/export-marketing' into 'development'
filter warehouse id to marketing; export recording add detail eggs; adjust format export marketing; adjust resposne list marketing

See merge request mbugroup/lti-api!579
2026-06-01 13:53:01 +00:00
giovanni 68bddd5c78 adjust response list marketing add grand total so dan do 2026-05-31 16:38:22 +07:00
giovanni 90efd0ba5a add command to fix reconcile fifo; fix fifo stock v2 2026-05-31 16:25:16 +07:00
giovanni bfef144668 add filter warehouse to marketing;add detail export recording egg; adjust format export marketing 2026-05-31 16:23:22 +07:00
Giovanni Gabriel Septriadi 09b1f19d19 Merge branch 'rc/01' into 'production'
Rc/01

See merge request mbugroup/lti-api!578
2026-05-30 03:14:16 +00:00
Giovanni Gabriel Septriadi 672f80a3ba Merge branch 'fix/week-recording' into 'rc/01'
Fix/week recording

See merge request mbugroup/lti-api!577
2026-05-30 03:05:50 +00:00
Giovanni Gabriel Septriadi 1a5dfbb162 Merge branch 'fix/week-recording' into 'development'
Fix/week recording

See merge request mbugroup/lti-api!576
2026-05-30 03:05:08 +00:00
Giovanni Gabriel Septriadi b28ffdf9c6 Merge branch 'feat/trf-dep' into 'development'
Feat/trf dep

See merge request mbugroup/lti-api!574
2026-05-29 14:50:10 +00:00
Giovanni Gabriel Septriadi 90a921ff46 Merge branch 'feat/db' into 'development'
add command for cleanup relesed stock allocations

See merge request mbugroup/lti-api!569
2026-05-29 10:41:33 +00:00
Giovanni Gabriel Septriadi 2c9ae1d5ab Merge branch 'fix/nomor-po' into 'development'
Fix/nomor po

See merge request mbugroup/lti-api!568
2026-05-29 10:23:22 +00:00
Giovanni Gabriel Septriadi bf93770798 Merge branch 'feat/fifo-ar' into 'development'
init ar fifo

See merge request mbugroup/lti-api!566
2026-05-28 19:10:33 +00:00
Giovanni Gabriel Septriadi 98d031cc18 Merge branch 'fix/jamali' into 'development'
Fix/jamali

See merge request mbugroup/lti-api!564
2026-05-28 15:59:40 +00:00
61 changed files with 5101 additions and 162 deletions
+484
View File
@@ -0,0 +1,484 @@
// Command reconcile-fifo-total-used memperbaiki "phantom total_used" pada
// stockable lot FIFO v2 (recording_eggs, stock_transfer_details, dst.).
//
// LATAR BELAKANG
// Sebelum fix di population_allocation.go, ReleaseByUsable melepas SEMUA alokasi
// CONSUME sebuah usable (termasuk RECORDING_EGG / STOCK_TRANSFER_IN) tanpa
// men-decrement total_used stockable-nya. Akibatnya total_used "nyangkut" lebih
// besar dari jumlah alokasi ACTIVE yang membackup-nya (phantom) → available
// dihitung 0 padahal stok fisik ada → Delivery Order telur nyangkut di pending.
//
// PERBAIKAN
// Sumber kebenaran konsumsi = stock_allocations status ACTIVE & purpose CONSUME.
// Command ini menyetel ulang total_used setiap lot = SUM(alokasi ACTIVE CONSUME
// untuk lot itu), lalu menjalankan FIFO v2 Reflow per (PW, flag group) sehingga
// pending dialokasi ulang ke stok yang kini available dan product_warehouses.qty
// dihitung ulang.
//
// PENTING: jalankan command ini SETELAH fix kode (population_allocation.go)
// ter-deploy, dan SEBELUM mengaktifkan blok over-sell telur.
//
// Cara pakai:
//
// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 # dry-run 1 PW
// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 -apply # apply 1 PW
// go run ./cmd/reconcile-fifo-total-used/ -pw=1292,1296,1268 -apply
// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 -apply -output=json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"regexp"
"strconv"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
const (
outputTable = "table"
outputJSON = "json"
)
var identifierRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
type options struct {
Apply bool
Output string
DBSSLMode string
PWs []uint
}
// stockableRule menggambarkan satu jenis stockable (mis. RECORDING_EGG) beserta
// tabel & kolom yang dipakai FIFO v2 untuk melacak stok masuk.
type stockableRule struct {
LegacyTypeKey string
SourceTable string
SourceIDColumn string
UsedQuantityCol string
ProductWarehouseCol string
QuantityCol string
ScopeSQL string
}
type pwResult struct {
ProductWarehouseID uint `json:"product_warehouse_id"`
Product string `json:"product"`
Warehouse string `json:"warehouse"`
FlagGroups []string `json:"flag_groups"`
QtyBefore float64 `json:"qty_before"`
TotalUsedBefore float64 `json:"total_used_before"`
ActiveConsume float64 `json:"active_consume"`
Phantom float64 `json:"phantom"`
PendingBefore float64 `json:"pending_before"`
QtyAfter float64 `json:"qty_after,omitempty"`
PendingAfter float64 `json:"pending_after,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type runSummary struct {
Mode string `json:"mode"`
TargetPWs []uint `json:"target_pws"`
Results []pwResult `json:"results"`
DurationSeconds float64 `json:"duration_seconds"`
OverallStatus string `json:"overall_status"`
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
if opts.DBSSLMode != "" {
config.DBSSLMode = opts.DBSSLMode
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
// Quiet the per-query GORM logging; this command emits its own summary and
// the reflow step would otherwise produce a very noisy query log.
db = db.Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
logger := logrus.New()
logger.SetLevel(logrus.WarnLevel)
svc := commonSvc.NewFifoStockV2Service(db, logger)
start := time.Now()
stockableRules, err := loadStockableRules(ctx, db)
if err != nil {
log.Fatalf("failed to load stockable route rules: %v", err)
}
pendingRules, err := loadUsablePendingRules(ctx, db)
if err != nil {
log.Fatalf("failed to load usable route rules: %v", err)
}
summary := runSummary{
Mode: modeLabel(opts.Apply),
TargetPWs: opts.PWs,
OverallStatus: "PASS",
}
for _, pw := range opts.PWs {
res := reconcilePW(ctx, db, svc, pw, stockableRules, pendingRules, opts.Apply)
if res.Status == "FAIL" {
summary.OverallStatus = "FAIL"
}
summary.Results = append(summary.Results, res)
}
summary.DurationSeconds = time.Since(start).Seconds()
render(opts.Output, summary)
if !opts.Apply {
fmt.Println("\nDry-run only. Re-run with -apply to reset total_used and reflow the PW(s) above.")
}
if summary.OverallStatus == "FAIL" {
os.Exit(1)
}
}
// reconcilePW mengukur kondisi PW, lalu (jika -apply) menyetel ulang total_used
// tiap lot dan menjalankan reflow, semuanya dalam satu transaksi.
func reconcilePW(
ctx context.Context,
db *gorm.DB,
svc commonSvc.FifoStockV2Service,
pw uint,
stockableRules []stockableRule,
pendingRules []stockableRule,
apply bool,
) pwResult {
res := pwResult{ProductWarehouseID: pw, Status: "OK"}
if name, wh, err := loadPWIdentity(ctx, db, pw); err != nil {
res.Status = "FAIL"
res.Error = fmt.Sprintf("load identity: %v", err)
return res
} else {
res.Product, res.Warehouse = name, wh
}
flagGroups, err := loadFlagGroups(ctx, db, pw)
if err != nil {
res.Status = "FAIL"
res.Error = fmt.Sprintf("load flag groups: %v", err)
return res
}
res.FlagGroups = flagGroups
res.QtyBefore, _ = loadQty(ctx, db, pw)
res.TotalUsedBefore, _ = sumStockableUsed(ctx, db, pw, stockableRules)
res.ActiveConsume, _ = loadActiveConsume(ctx, db, pw)
res.PendingBefore, _ = sumPending(ctx, db, pw, pendingRules)
res.Phantom = res.TotalUsedBefore - res.ActiveConsume
if !apply {
return res
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, rule := range stockableRules {
if err := recomputeUsed(ctx, tx, rule, pw); err != nil {
return fmt.Errorf("recompute %s: %w", rule.LegacyTypeKey, err)
}
}
for _, fg := range flagGroups {
if _, err := svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: fg,
ProductWarehouseID: pw,
Tx: tx,
}); err != nil {
return fmt.Errorf("reflow flag_group=%s: %w", fg, err)
}
}
return nil
})
if err != nil {
res.Status = "FAIL"
res.Error = err.Error()
return res
}
res.QtyAfter, _ = loadQty(ctx, db, pw)
res.PendingAfter, _ = sumPending(ctx, db, pw, pendingRules)
return res
}
func recomputeUsed(ctx context.Context, tx *gorm.DB, rule stockableRule, pw uint) error {
q := fmt.Sprintf(`
UPDATE %s t
SET %s = COALESCE((
SELECT SUM(sa.qty) FROM stock_allocations sa
WHERE sa.stockable_type = ?
AND sa.stockable_id = t.%s
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
), 0)
WHERE t.%s = ?`, rule.SourceTable, rule.UsedQuantityCol, rule.SourceIDColumn, rule.ProductWarehouseCol)
if strings.TrimSpace(rule.ScopeSQL) != "" {
q += " AND (" + rule.ScopeSQL + ")"
}
return tx.WithContext(ctx).Exec(q, rule.LegacyTypeKey, pw).Error
}
// ---- loaders ----
func loadStockableRules(ctx context.Context, db *gorm.DB) ([]stockableRule, error) {
type row struct {
LegacyTypeKey string `gorm:"column:legacy_type_key"`
SourceTable string `gorm:"column:source_table"`
SourceIDColumn string `gorm:"column:source_id_column"`
UsedQuantityCol string `gorm:"column:used_quantity_col"`
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
QuantityCol string `gorm:"column:quantity_col"`
ScopeSQL string `gorm:"column:scope_sql"`
}
var rows []row
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Select("DISTINCT legacy_type_key, source_table, source_id_column, COALESCE(used_quantity_col,'') AS used_quantity_col, product_warehouse_col, COALESCE(quantity_col,'') AS quantity_col, COALESCE(scope_sql,'') AS scope_sql").
Where("lane = ? AND is_active = TRUE", "STOCKABLE").
Where("used_quantity_col IS NOT NULL AND used_quantity_col <> ''").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := make([]stockableRule, 0, len(rows))
seen := map[string]bool{}
for _, r := range rows {
if !validIdentifiers(r.SourceTable, r.SourceIDColumn, r.UsedQuantityCol, r.ProductWarehouseCol) {
return nil, fmt.Errorf("unsafe identifier in route rule %s (table=%s used=%s pw=%s)", r.LegacyTypeKey, r.SourceTable, r.UsedQuantityCol, r.ProductWarehouseCol)
}
key := r.LegacyTypeKey + "|" + r.SourceTable + "|" + r.UsedQuantityCol + "|" + r.ProductWarehouseCol
if seen[key] {
continue
}
seen[key] = true
out = append(out, stockableRule(r))
}
return out, nil
}
func loadUsablePendingRules(ctx context.Context, db *gorm.DB) ([]stockableRule, error) {
type row struct {
SourceTable string `gorm:"column:source_table"`
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
PendingCol string `gorm:"column:pending_quantity_col"`
ScopeSQL string `gorm:"column:scope_sql"`
}
var rows []row
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Select("DISTINCT source_table, product_warehouse_col, pending_quantity_col, COALESCE(scope_sql,'') AS scope_sql").
Where("lane = ? AND is_active = TRUE", "USABLE").
Where("pending_quantity_col IS NOT NULL AND pending_quantity_col <> ''").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := make([]stockableRule, 0, len(rows))
seen := map[string]bool{}
for _, r := range rows {
if !validIdentifiers(r.SourceTable, r.ProductWarehouseCol, r.PendingCol) {
return nil, fmt.Errorf("unsafe identifier in usable rule (table=%s pw=%s pending=%s)", r.SourceTable, r.ProductWarehouseCol, r.PendingCol)
}
key := r.SourceTable + "|" + r.PendingCol + "|" + r.ProductWarehouseCol
if seen[key] {
continue
}
seen[key] = true
out = append(out, stockableRule{
SourceTable: r.SourceTable,
ProductWarehouseCol: r.ProductWarehouseCol,
UsedQuantityCol: r.PendingCol, // reuse field as the column to SUM
ScopeSQL: r.ScopeSQL,
})
}
return out, nil
}
func loadPWIdentity(ctx context.Context, db *gorm.DB, pw uint) (string, string, error) {
type row struct {
Product string `gorm:"column:product"`
Warehouse string `gorm:"column:warehouse"`
}
var out row
err := db.WithContext(ctx).
Table("product_warehouses pw").
Select("p.name AS product, w.name AS warehouse").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("pw.id = ?", pw).
Take(&out).Error
return out.Product, out.Warehouse, err
}
func loadFlagGroups(ctx context.Context, db *gorm.DB, pw uint) ([]string, error) {
var groups []string
err := db.WithContext(ctx).
Table("stock_allocations").
Distinct("flag_group_code").
Where("product_warehouse_id = ? AND flag_group_code IS NOT NULL AND flag_group_code <> ''", pw).
Order("flag_group_code ASC").
Scan(&groups).Error
return groups, err
}
func loadQty(ctx context.Context, db *gorm.DB, pw uint) (float64, error) {
var v float64
err := db.WithContext(ctx).
Table("product_warehouses").
Select("COALESCE(qty,0)").
Where("id = ?", pw).
Scan(&v).Error
return v, err
}
func loadActiveConsume(ctx context.Context, db *gorm.DB, pw uint) (float64, error) {
var v float64
err := db.WithContext(ctx).
Table("stock_allocations").
Select("COALESCE(SUM(qty),0)").
Where("product_warehouse_id = ? AND status = 'ACTIVE' AND allocation_purpose = 'CONSUME'", pw).
Scan(&v).Error
return v, err
}
func sumStockableUsed(ctx context.Context, db *gorm.DB, pw uint, rules []stockableRule) (float64, error) {
total := 0.0
for _, rule := range rules {
v, err := sumColumn(ctx, db, rule.SourceTable, rule.UsedQuantityCol, rule.ProductWarehouseCol, rule.ScopeSQL, pw)
if err != nil {
return total, err
}
total += v
}
return total, nil
}
func sumPending(ctx context.Context, db *gorm.DB, pw uint, rules []stockableRule) (float64, error) {
total := 0.0
for _, rule := range rules {
v, err := sumColumn(ctx, db, rule.SourceTable, rule.UsedQuantityCol, rule.ProductWarehouseCol, rule.ScopeSQL, pw)
if err != nil {
return total, err
}
total += v
}
return total, nil
}
func sumColumn(ctx context.Context, db *gorm.DB, table, col, pwCol, scope string, pw uint) (float64, error) {
q := fmt.Sprintf("SELECT COALESCE(SUM(%s),0) FROM %s WHERE %s = ?", col, table, pwCol)
if strings.TrimSpace(scope) != "" {
q += " AND (" + scope + ")"
}
var v float64
err := db.WithContext(ctx).Raw(q, pw).Scan(&v).Error
return v, err
}
// ---- flags / render ----
func parseFlags() (*options, error) {
var opts options
var pwsRaw string
flag.BoolVar(&opts.Apply, "apply", false, "Apply the reconciliation (omit for dry-run)")
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Database sslmode override")
flag.StringVar(&pwsRaw, "pw", "", "Comma-separated product_warehouse ids to reconcile (required)")
flag.Parse()
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
if opts.Output == "" {
opts.Output = outputTable
}
if opts.Output != outputTable && opts.Output != outputJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
pwsRaw = strings.TrimSpace(pwsRaw)
if pwsRaw == "" {
return nil, fmt.Errorf("-pw is required (e.g. -pw=1292 or -pw=1292,1296)")
}
for _, part := range strings.Split(pwsRaw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseUint(part, 10, 64)
if err != nil || id == 0 {
return nil, fmt.Errorf("invalid product_warehouse id %q", part)
}
opts.PWs = append(opts.PWs, uint(id))
}
if len(opts.PWs) == 0 {
return nil, fmt.Errorf("no valid product_warehouse ids parsed from -pw")
}
return &opts, nil
}
func validIdentifiers(ids ...string) bool {
for _, id := range ids {
if !identifierRe.MatchString(id) {
return false
}
}
return true
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY_RUN"
}
func render(mode string, summary runSummary) {
if mode == outputJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(summary)
return
}
fmt.Printf("=== Reconcile FIFO total_used ===\n")
fmt.Printf("Mode : %s\n", summary.Mode)
for _, r := range summary.Results {
fmt.Printf("\n--- PW %d (%s @ %s) [%s] ---\n", r.ProductWarehouseID, r.Product, r.Warehouse, r.Status)
if r.Error != "" {
fmt.Printf("ERROR : %s\n", r.Error)
}
fmt.Printf("Flag groups : %s\n", strings.Join(r.FlagGroups, ", "))
fmt.Printf("qty (before) : %.3f\n", r.QtyBefore)
fmt.Printf("Σ total_used : %.3f\n", r.TotalUsedBefore)
fmt.Printf("Σ active CONSUME: %.3f\n", r.ActiveConsume)
fmt.Printf("PHANTOM : %.3f (total_used yang akan dilepas)\n", r.Phantom)
fmt.Printf("pending (before): %.3f\n", r.PendingBefore)
if summary.Mode == "APPLY" && r.Status == "OK" {
fmt.Printf("qty (after) : %.3f\n", r.QtyAfter)
fmt.Printf("pending (after) : %.3f\n", r.PendingAfter)
}
}
fmt.Printf("\nDuration : %.2fs\n", summary.DurationSeconds)
fmt.Printf("Overall status : %s\n", summary.OverallStatus)
}
@@ -0,0 +1,638 @@
// Command seed-house-depreciation-standards membaca kurva depresiasi per-day
// dari file Excel, lalu meng-generate file migration {up,down}.sql yang
// menyisipkan baris house_depreciation_standards dengan project_flock_ids
// berisi semua flock yang memakai kurva tersebut.
//
// Kurva disimpan SEKALI di DB sebagai satu baris dengan
// project_flock_ids = ARRAY[52,53,54]::bigint[]. Lookup di engine pakai
// ? = ANY(project_flock_ids), sehingga tidak ada duplikasi baris.
//
// Hanya multiplication_percentage yang di-override; house_type & standard_week
// diwarisi dari baris global (project_flock_ids IS NULL) untuk hari yang sama,
// dan depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
//
// Jalankan lokal (tidak ada API yang di-hit di production):
//
// go run ./cmd/seed-house-depreciation-standards \
// -project-flock-ids=52,53,54 -file=curve.xlsx # dry-run
// go run ./cmd/seed-house-depreciation-standards \
// -project-flock-ids=52,53,54 -file=curve.xlsx -apply # tulis migration
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
const (
dateLayout = "2006-01-02"
timestampLayout = "20060102150405"
defaultMigrations = "internal/database/migrations"
headerDay = "day"
headerMultiplier = "multiplication_percentage"
)
type options struct {
ProjectFlockIDs string // comma-separated flock IDs (wajib, min 1)
FilePath string
Sheet string
EffectiveDate string
HouseType string
OutDir string
Apply bool
}
type curveRow struct {
Day int
Mult float64
ColRef string // Excel column letter (e.g. "B"), untuk error reporting
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) String() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
func main() {
var opts options
flag.StringVar(&opts.ProjectFlockIDs, "project-flock-ids", "", "Comma-separated LAYING project flock IDs yang memakai kurva ini (required, e.g. 52,53,54)")
flag.StringVar(&opts.FilePath, "file", "", "Path ke .xlsx — format horizontal: baris 'day' dan 'multiplication_percentage' (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Nama sheet (opsional; default: sheet pertama)")
flag.StringVar(&opts.EffectiveDate, "effective-date", "", "effective_date untuk baris yang di-insert (YYYY-MM-DD; default: hari ini)")
flag.StringVar(&opts.HouseType, "house-type", "", "Override house_type (open_house|close_house). Default: di-derive dari kandang flock")
flag.StringVar(&opts.OutDir, "out-dir", defaultMigrations, "Direktori output file migration")
flag.BoolVar(&opts.Apply, "apply", false, "Tulis file migration. Jika false: dry-run (cetak SQL ke stdout)")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
opts.OutDir = strings.TrimSpace(opts.OutDir)
if strings.TrimSpace(opts.ProjectFlockIDs) == "" {
log.Fatal("--project-flock-ids is required")
}
flockIDs, err := parseFlockIDs(opts.ProjectFlockIDs)
if err != nil {
log.Fatalf("--project-flock-ids: %v", err)
}
if opts.FilePath == "" {
log.Fatal("--file is required")
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
log.Fatalf("failed to load timezone Asia/Jakarta: %v", err)
}
effectiveDate, err := resolveEffectiveDate(opts.EffectiveDate, location)
if err != nil {
log.Fatalf("invalid --effective-date: %v", err)
}
opts.EffectiveDate = effectiveDate.Format(dateLayout)
sheetName, curve, parseIssues, err := parseCurveFile(opts.FilePath, opts.Sheet)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
for _, id := range flockIDs {
if err := assertActiveLayingFlock(ctx, db, id); err != nil {
log.Fatalf("flock %d: %v", id, err)
}
}
houseTypes, err := resolveHouseTypesForFlocks(ctx, db, flockIDs, opts.HouseType)
if err != nil {
log.Fatalf("house_type resolution failed: %v", err)
}
// Days yang tidak ada di global standard cukup di-skip oleh JOIN LATERAL (inner join) —
// tidak perlu validasi coverage; tidak ada global row = tidak ada INSERT, bukan error.
issues := append([]validationIssue{}, parseIssues...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Project flock IDs: %s\n", formatFlockIDs(flockIDs))
fmt.Printf("House types: %s\n", strings.Join(houseTypes, ", "))
fmt.Printf("Effective date: %s\n", opts.EffectiveDate)
fmt.Printf("Curve rows parsed: %d\n", len(curve))
if len(curve) > 0 {
minDay, maxDay := dayRange(curve)
fmt.Printf("Day range: %d..%d\n", minDay, maxDay)
}
fmt.Printf("Validation errors: %d\n", len(issues))
fmt.Println()
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.String())
}
os.Exit(1)
}
upSQL := buildUpSQL(opts, houseTypes, curve, flockIDs)
downSQL := buildDownSQL(opts, flockIDs)
prefix := time.Now().In(location).Format(timestampLayout)
suffix := formatFlockIDsForFilename(flockIDs)
upName := fmt.Sprintf("%s_seed_house_depreciation_flocks_%s.up.sql", prefix, suffix)
downName := fmt.Sprintf("%s_seed_house_depreciation_flocks_%s.down.sql", prefix, suffix)
if !opts.Apply {
fmt.Printf("--- %s ---\n%s\n", upName, upSQL)
fmt.Printf("--- %s ---\n%s\n", downName, downSQL)
fmt.Printf("Dry-run: would write 2 files to %s. Re-run with -apply to create them.\n", opts.OutDir)
return
}
upPath := filepath.Join(opts.OutDir, upName)
downPath := filepath.Join(opts.OutDir, downName)
if err := os.WriteFile(upPath, []byte(upSQL), 0o644); err != nil {
log.Fatalf("failed writing %s: %v", upPath, err)
}
if err := os.WriteFile(downPath, []byte(downSQL), 0o644); err != nil {
log.Fatalf("failed writing %s: %v", downPath, err)
}
fmt.Printf("WROTE %s\n", upPath)
fmt.Printf("WROTE %s\n", downPath)
fmt.Println("Review the SQL, commit it, then deploy runs `make migrate-up`.")
}
// --- validation helpers -------------------------------------------------------
func parseFlockIDs(raw string) ([]uint, error) {
parts := strings.Split(raw, ",")
ids := make([]uint, 0, len(parts))
seen := make(map[uint]bool)
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
n, err := strconv.ParseUint(p, 10, 64)
if err != nil || n == 0 {
return nil, fmt.Errorf("invalid flock ID %q: must be a positive integer", p)
}
id := uint(n)
if seen[id] {
return nil, fmt.Errorf("duplicate flock ID %d", id)
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, fmt.Errorf("at least one project flock ID required")
}
// Sort IDs so generated SQL and filename are deterministic.
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids, nil
}
func formatFlockIDs(ids []uint) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatUint(uint64(id), 10)
}
return strings.Join(parts, ", ")
}
// formatFlockIDsForFilename returns "52_53_54" for use in migration filenames.
// Truncates to first 4 IDs if many, to keep filename reasonable.
func formatFlockIDsForFilename(ids []uint) string {
display := ids
suffix := ""
if len(ids) > 4 {
display = ids[:4]
suffix = fmt.Sprintf("_and_%d_more", len(ids)-4)
}
parts := make([]string, len(display))
for i, id := range display {
parts[i] = strconv.FormatUint(uint64(id), 10)
}
return strings.Join(parts, "_") + suffix
}
// --- effective date -----------------------------------------------------------
func resolveEffectiveDate(raw string, location *time.Location) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
now := time.Now().In(location)
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location), nil
}
parsed, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
}
return parsed, nil
}
// --- excel parsing -----------------------------------------------------------
// parseCurveFile membaca format horizontal dari Excel:
//
// Baris 1 (label "day"): day | 1 | 2 | 3 | ...
// Baris 2 (label "multiplication_percentage"): mp | 0.997.. | 0.997.. | ...
//
// Kedua baris bisa ada di urutan berapa pun, dideteksi lewat label di kolom A.
// Kolom A adalah label; data mulai dari kolom B seterusnya.
func parseCurveFile(filePath, requestedSheet string) (string, []curveRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() { _ = workbook.Close() }()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{{Field: "sheet", Message: "sheet is empty"}}, nil
}
var dayRow, multRow []string
for _, row := range allRows {
if len(row) == 0 {
continue
}
switch normalizeHeader(row[0]) {
case headerDay:
dayRow = row
case headerMultiplier:
multRow = row
}
}
issues := make([]validationIssue, 0)
if dayRow == nil {
issues = append(issues, validationIssue{Field: headerDay, Message: `baris dengan label "day" di kolom A tidak ditemukan`})
}
if multRow == nil {
issues = append(issues, validationIssue{Field: headerMultiplier, Message: `baris dengan label "multiplication_percentage" di kolom A tidak ditemukan`})
}
if len(issues) > 0 {
return sheetName, nil, issues, nil
}
maxCols := len(dayRow)
if len(multRow) > maxCols {
maxCols = len(multRow)
}
rows := make([]curveRow, 0, maxCols-1)
seenDays := make(map[int]string)
for colIdx := 1; colIdx < maxCols; colIdx++ {
dayRaw := strings.TrimSpace(cellValue(dayRow, colIdx))
multRaw := strings.TrimSpace(cellValue(multRow, colIdx))
if dayRaw == "" && multRaw == "" {
continue
}
colName, _ := excelize.ColumnNumberToName(colIdx + 1)
var colIssues []validationIssue
day, dayErr := parsePositiveInt(dayRaw)
if dayErr != nil {
colIssues = append(colIssues, validationIssue{
Field: headerDay,
Message: fmt.Sprintf("col=%s: %s", colName, dayErr.Error()),
})
}
mult, multErr := parseMultiplication(multRaw)
if multErr != nil {
colIssues = append(colIssues, validationIssue{
Field: headerMultiplier,
Message: fmt.Sprintf("col=%s: %s", colName, multErr.Error()),
})
}
if day > 0 {
if prevCol, exists := seenDays[day]; exists {
colIssues = append(colIssues, validationIssue{
Field: headerDay,
Message: fmt.Sprintf("col=%s: duplicate day %d (already in col %s)", colName, day, prevCol),
})
} else {
seenDays[day] = colName
}
}
if len(colIssues) > 0 {
issues = append(issues, colIssues...)
continue
}
rows = append(rows, curveRow{Day: day, Mult: mult, ColRef: colName})
}
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{Field: "data", Message: "tidak ada kolom data setelah kolom A (label)"})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Day < rows[j].Day })
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if strings.TrimSpace(requestedSheet) == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parsePositiveInt(raw string) (int, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
value, err := strconv.Atoi(raw)
if err != nil {
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil || floatValue != float64(int(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
value = int(floatValue)
}
if value < 1 {
return 0, fmt.Errorf("must be greater than or equal to 1")
}
return value, nil
}
func parseMultiplication(raw string) (float64, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be numeric")
}
if value < 0 || value > 1 {
return 0, fmt.Errorf("must be between 0 and 1 (inclusive)")
}
return value, nil
}
// --- SQL generation ----------------------------------------------------------
// buildUpSQL generates INSERT blocks for each houseType in houseTypes.
// If flocks span multiple house_types, one INSERT block is generated per type,
// all sharing the same project_flock_ids array.
func buildUpSQL(opts options, houseTypes []string, curve []curveRow, flockIDs []uint) string {
var b strings.Builder
fmt.Fprintf(&b, "-- Kurva depresiasi khusus flock %s (house_types=%s, effective_date=%s).\n",
formatFlockIDs(flockIDs), strings.Join(houseTypes, ","), opts.EffectiveDate)
b.WriteString("-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.\n")
b.WriteString("-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.\n")
b.WriteString("-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.\n\n")
valTuples := formatValuesTuples(curve)
arrayLit := formatArrayLiteral(flockIDs)
for _, houseType := range houseTypes {
fmt.Fprintf(&b, "-- house_type: %s\n", houseType)
b.WriteString("INSERT INTO house_depreciation_standards\n")
b.WriteString(" (project_flock_ids, house_type, day, effective_date,\n")
b.WriteString(" multiplication_percentage, depreciation_percent, standard_week, name)\n")
b.WriteString("SELECT\n")
fmt.Fprintf(&b, " %s, g.house_type, g.day, DATE '%s',\n", arrayLit, opts.EffectiveDate)
b.WriteString(" v.mult, (1 - v.mult) * 100, g.standard_week,\n")
fmt.Fprintf(&b, " 'Custom flocks %s (eff %s)'\n", formatFlockIDs(flockIDs), opts.EffectiveDate)
b.WriteString("FROM (VALUES\n")
b.WriteString(valTuples)
b.WriteString("\n) AS v(day, mult)\n")
b.WriteString("JOIN LATERAL (\n")
b.WriteString(" SELECT DISTINCT ON (day) house_type, day, standard_week\n")
b.WriteString(" FROM house_depreciation_standards\n")
b.WriteString(" WHERE project_flock_ids IS NULL\n")
fmt.Fprintf(&b, " AND house_type = '%s'::house_type_enum\n", houseType)
b.WriteString(" AND day = v.day\n")
b.WriteString(" ORDER BY day, effective_date DESC NULLS LAST\n")
b.WriteString(") g ON TRUE;\n\n")
}
b.WriteString("-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.\n")
fmt.Fprintf(&b, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (%s);\n", inClause(flockIDs))
return b.String()
}
func buildDownSQL(opts options, flockIDs []uint) string {
var b strings.Builder
b.WriteString("-- Hapus baris kurva custom dari house_depreciation_standards.\n")
b.WriteString("-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).\n")
fmt.Fprintf(&b, "DELETE FROM house_depreciation_standards\nWHERE project_flock_ids = %s\n AND effective_date = DATE '%s';\n\n",
formatArrayLiteral(flockIDs), opts.EffectiveDate)
b.WriteString("-- Recompute snapshot depresiasi.\n")
fmt.Fprintf(&b, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (%s);\n", inClause(flockIDs))
return b.String()
}
// formatArrayLiteral renders []uint{52,53,54} as ARRAY[52,53,54]::bigint[].
func formatArrayLiteral(ids []uint) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatUint(uint64(id), 10)
}
return fmt.Sprintf("ARRAY[%s]::bigint[]", strings.Join(parts, ","))
}
// formatValuesTuples renders (day, mult) tuples 5 per line.
// The first multiplier is cast ::numeric so PostgreSQL infers the column type correctly.
func formatValuesTuples(curve []curveRow) string {
tuples := make([]string, len(curve))
for i, row := range curve {
mult := formatFloat(row.Mult)
if i == 0 {
mult += "::numeric"
}
tuples[i] = fmt.Sprintf("(%d, %s)", row.Day, mult)
}
var b strings.Builder
const perLine = 5
for i := 0; i < len(tuples); i += perLine {
end := i + perLine
if end > len(tuples) {
end = len(tuples)
}
b.WriteString(" ")
b.WriteString(strings.Join(tuples[i:end], ", "))
if end < len(tuples) {
b.WriteString(",\n")
}
}
return b.String()
}
// inClause formats []uint{52,53,54} as "52, 53, 54" for SQL IN (...).
func inClause(ids []uint) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatUint(uint64(id), 10)
}
return strings.Join(parts, ", ")
}
func formatFloat(value float64) string {
return strconv.FormatFloat(value, 'g', -1, 64)
}
// --- DB helpers --------------------------------------------------------------
func assertActiveLayingFlock(ctx context.Context, db *gorm.DB, projectFlockID uint) error {
var count int64
err := db.WithContext(ctx).
Table("project_flocks").
Where("id = ?", projectFlockID).
Where("deleted_at IS NULL").
Where("category = ?", string(utils.ProjectFlockCategoryLaying)).
Count(&count).Error
if err != nil {
return err
}
if count == 0 {
return fmt.Errorf("project_flock_id %d must reference an active LAYING project_flock", projectFlockID)
}
return nil
}
// resolveHouseTypesForFlocks mengembalikan SEMUA distinct house_type dari kandang
// semua flock yang diberikan. Kalau -house-type di-pass → hanya type itu.
// Tidak error kalau multiple (generate INSERT block per type secara otomatis).
func resolveHouseTypesForFlocks(ctx context.Context, db *gorm.DB, flockIDs []uint, override string) ([]string, error) {
if strings.TrimSpace(override) != "" {
ht, err := normalizeHouseType(override)
if err != nil {
return nil, err
}
return []string{ht}, nil
}
houseTypes := make([]string, 0)
err := db.WithContext(ctx).Raw(`
SELECT DISTINCT k.house_type::text AS house_type
FROM project_flock_kandangs pfk
JOIN kandangs k ON k.id = pfk.kandang_id
WHERE pfk.project_flock_id IN ? AND k.house_type IS NOT NULL
ORDER BY house_type
`, flockIDs).Scan(&houseTypes).Error
if err != nil {
return nil, err
}
if len(houseTypes) == 0 {
return nil, fmt.Errorf("no kandang house_type found for any of the specified flocks; pass -house-type explicitly")
}
// Bisa 1 atau lebih — generate INSERT block per type.
return houseTypes, nil
}
func normalizeHouseType(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
switch normalized {
case "open_house", "close_house":
return normalized, nil
default:
return "", fmt.Errorf("house_type %q must be open_house or close_house", raw)
}
}
// --- misc helpers ------------------------------------------------------------
func dayRange(curve []curveRow) (int, int) {
minDay, maxDay := curve[0].Day, curve[0].Day
for _, row := range curve {
if row.Day < minDay {
minDay = row.Day
}
if row.Day > maxDay {
maxDay = row.Day
}
}
return minDay, maxDay
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
@@ -0,0 +1,373 @@
package main
import (
"os"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
)
// --- helper: build horizontal-format temp xlsx --------------------------------
// makeHorizXlsx creates a temp .xlsx with two horizontal rows:
//
// Row 1: "day" | dayValues...
// Row 2: "multiplication_percentage" | multValues...
func makeHorizXlsx(t *testing.T, dayValues, multValues []any) string {
t.Helper()
f := excelize.NewFile()
defer f.Close()
f.SetCellValue("Sheet1", "A1", "day")
f.SetCellValue("Sheet1", "A2", "multiplication_percentage")
for i, v := range dayValues {
colName, _ := excelize.ColumnNumberToName(i + 2)
switch val := v.(type) {
case int:
f.SetCellInt("Sheet1", colName+"1", val)
case string:
f.SetCellValue("Sheet1", colName+"1", val)
}
}
for i, v := range multValues {
colName, _ := excelize.ColumnNumberToName(i + 2)
switch val := v.(type) {
case float64:
f.SetCellFloat("Sheet1", colName+"2", val, 15, 64)
case string:
f.SetCellValue("Sheet1", colName+"2", val)
}
}
tmp, err := os.CreateTemp("", "test_curve_*.xlsx")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
tmp.Close()
t.Cleanup(func() { os.Remove(tmp.Name()) })
if err := f.SaveAs(tmp.Name()); err != nil {
t.Fatalf("SaveAs: %v", err)
}
return tmp.Name()
}
// --- parseCurveFile tests -----------------------------------------------------
func TestParseCurveFile_HappyPath(t *testing.T) {
path := makeHorizXlsx(t,
[]any{1, 2, 3},
[]any{0.997742664, 0.997737557, 0.997732426},
)
sheetName, rows, issues, err := parseCurveFile(path, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(issues) != 0 {
t.Fatalf("unexpected issues: %v", issues)
}
if sheetName != "Sheet1" {
t.Fatalf("wrong sheet: %s", sheetName)
}
if len(rows) != 3 {
t.Fatalf("want 3 rows, got %d", len(rows))
}
if rows[0].Day != 1 || rows[1].Day != 2 || rows[2].Day != 3 {
t.Fatalf("wrong days: %v", rows)
}
if rows[0].ColRef != "B" {
t.Fatalf("wrong ColRef for day 1: %s", rows[0].ColRef)
}
}
func TestParseCurveFile_RowOrderFlexible(t *testing.T) {
f := excelize.NewFile()
defer f.Close()
f.SetCellValue("Sheet1", "A1", "multiplication_percentage")
f.SetCellFloat("Sheet1", "B1", 0.997, 15, 64)
f.SetCellValue("Sheet1", "A2", "day")
f.SetCellInt("Sheet1", "B2", 5)
tmp, _ := os.CreateTemp("", "*.xlsx")
tmp.Close()
t.Cleanup(func() { os.Remove(tmp.Name()) })
f.SaveAs(tmp.Name())
_, rows, issues, err := parseCurveFile(tmp.Name(), "")
if err != nil || len(issues) != 0 {
t.Fatalf("err=%v issues=%v", err, issues)
}
if len(rows) != 1 || rows[0].Day != 5 {
t.Fatalf("unexpected rows: %v", rows)
}
}
func TestParseCurveFile_MissingDayRow(t *testing.T) {
f := excelize.NewFile()
defer f.Close()
f.SetCellValue("Sheet1", "A1", "multiplication_percentage")
f.SetCellFloat("Sheet1", "B1", 0.997, 15, 64)
tmp, _ := os.CreateTemp("", "*.xlsx")
tmp.Close()
t.Cleanup(func() { os.Remove(tmp.Name()) })
f.SaveAs(tmp.Name())
_, _, issues, err := parseCurveFile(tmp.Name(), "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(issues) != 1 || issues[0].Field != headerDay {
t.Fatalf("expected missing-day-row issue, got %v", issues)
}
}
func TestParseCurveFile_DuplicateDay(t *testing.T) {
path := makeHorizXlsx(t,
[]any{1, 2, 1},
[]any{0.99, 0.98, 0.97},
)
_, _, issues, err := parseCurveFile(path, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(issues) != 1 {
t.Fatalf("expected 1 duplicate-day issue, got %v", issues)
}
if !strings.Contains(issues[0].Message, "duplicate day 1") {
t.Fatalf("wrong issue message: %s", issues[0].Message)
}
if !strings.Contains(issues[0].Message, "col=D") {
t.Fatalf("issue should mention col=D: %s", issues[0].Message)
}
}
func TestParseCurveFile_InvalidMultiplication(t *testing.T) {
path := makeHorizXlsx(t,
[]any{1, 2},
[]any{"bad", 1.5},
)
_, _, issues, err := parseCurveFile(path, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(issues) != 2 {
t.Fatalf("expected 2 issues, got %v", issues)
}
}
func TestParseCurveFile_SkipsEmptyColumns(t *testing.T) {
f := excelize.NewFile()
defer f.Close()
f.SetCellValue("Sheet1", "A1", "day")
f.SetCellInt("Sheet1", "B1", 1)
f.SetCellInt("Sheet1", "D1", 3)
f.SetCellValue("Sheet1", "A2", "multiplication_percentage")
f.SetCellFloat("Sheet1", "B2", 0.997, 15, 64)
f.SetCellFloat("Sheet1", "D2", 0.995, 15, 64)
tmp, _ := os.CreateTemp("", "*.xlsx")
tmp.Close()
t.Cleanup(func() { os.Remove(tmp.Name()) })
f.SaveAs(tmp.Name())
_, rows, issues, err := parseCurveFile(tmp.Name(), "")
if err != nil || len(issues) != 0 {
t.Fatalf("err=%v issues=%v", err, issues)
}
if len(rows) != 2 {
t.Fatalf("want 2 rows (col C skipped), got %d: %v", len(rows), rows)
}
}
// --- pure-function tests ------------------------------------------------------
func TestParseFlockIDs(t *testing.T) {
t.Run("single", func(t *testing.T) {
ids, err := parseFlockIDs("52")
if err != nil || len(ids) != 1 || ids[0] != 52 {
t.Fatalf("got ids=%v err=%v", ids, err)
}
})
t.Run("multiple_sorted", func(t *testing.T) {
ids, err := parseFlockIDs("54,52,53")
if err != nil || len(ids) != 3 {
t.Fatalf("got ids=%v err=%v", ids, err)
}
// should be sorted
if ids[0] != 52 || ids[1] != 53 || ids[2] != 54 {
t.Fatalf("expected sorted [52,53,54], got %v", ids)
}
})
t.Run("duplicate", func(t *testing.T) {
if _, err := parseFlockIDs("52,52"); err == nil {
t.Fatal("expected error for duplicate")
}
})
t.Run("zero", func(t *testing.T) {
if _, err := parseFlockIDs("0"); err == nil {
t.Fatal("expected error for zero")
}
})
t.Run("empty", func(t *testing.T) {
if _, err := parseFlockIDs(""); err == nil {
t.Fatal("expected error for empty")
}
})
}
func TestFormatArrayLiteral(t *testing.T) {
got := formatArrayLiteral([]uint{52, 53, 54})
want := "ARRAY[52,53,54]::bigint[]"
if got != want {
t.Fatalf("want %q, got %q", want, got)
}
}
func TestFormatFlockIDsForFilename(t *testing.T) {
if got := formatFlockIDsForFilename([]uint{52, 53, 54}); got != "52_53_54" {
t.Fatalf("got %s", got)
}
// More than 4 IDs → truncate
many := []uint{1, 2, 3, 4, 5}
got := formatFlockIDsForFilename(many)
if !strings.Contains(got, "1_2_3_4") || !strings.Contains(got, "1_more") {
t.Fatalf("unexpected: %s", got)
}
}
func TestParsePositiveInt(t *testing.T) {
cases := map[string]bool{
"1": true, "532": true, "12.0": true,
"0": false, "-3": false, "1.5": false, "": false, "x": false,
}
for raw, ok := range cases {
_, err := parsePositiveInt(raw)
if ok && err != nil {
t.Errorf("%q: unexpected error %v", raw, err)
}
if !ok && err == nil {
t.Errorf("%q: expected error", raw)
}
}
}
func TestParseMultiplication(t *testing.T) {
cases := map[string]bool{
"0.997742664": true, "1": true, "9.11e-12": true, "0": true,
"-0.1": false, "1.0001": false, "": false, "abc": false,
}
for raw, ok := range cases {
_, err := parseMultiplication(raw)
if ok && err != nil {
t.Errorf("%q: unexpected error %v", raw, err)
}
if !ok && err == nil {
t.Errorf("%q: expected error", raw)
}
}
}
func TestFormatValuesTuplesFirstNumericCast(t *testing.T) {
out := formatValuesTuples([]curveRow{
{Day: 1, Mult: 0.997742664},
{Day: 2, Mult: 1},
})
if !strings.Contains(out, "(1, 0.997742664::numeric)") {
t.Fatalf("first tuple must cast ::numeric: %s", out)
}
if strings.Contains(out, "(2, 1::numeric)") {
t.Fatalf("only the first tuple should be cast: %s", out)
}
}
func TestBuildUpSQL_SingleHouseType(t *testing.T) {
opts := options{EffectiveDate: "2026-05-31"}
curve := []curveRow{{Day: 1, Mult: 0.997742664}, {Day: 2, Mult: 0.5}}
flockIDs := []uint{52, 53}
sql := buildUpSQL(opts, []string{"close_house"}, curve, flockIDs)
mustContain(t, sql, "INSERT INTO house_depreciation_standards")
mustContain(t, sql, "project_flock_ids")
mustContain(t, sql, "ARRAY[52,53]::bigint[]")
mustContain(t, sql, "DATE '2026-05-31'")
mustContain(t, sql, "v.mult, (1 - v.mult) * 100, g.standard_week")
mustContain(t, sql, "project_flock_ids IS NULL")
mustContain(t, sql, "house_type = 'close_house'::house_type_enum")
mustContain(t, sql, "(1, 0.997742664::numeric)")
mustContain(t, sql, "JOIN LATERAL")
mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)")
}
func TestBuildUpSQL_MultipleHouseTypes(t *testing.T) {
opts := options{EffectiveDate: "2026-05-31"}
curve := []curveRow{{Day: 1, Mult: 0.5}}
flockIDs := []uint{52, 53}
sql := buildUpSQL(opts, []string{"close_house", "open_house"}, curve, flockIDs)
// Both house_types get their own INSERT block
mustContain(t, sql, "house_type = 'close_house'::house_type_enum")
mustContain(t, sql, "house_type = 'open_house'::house_type_enum")
// VALUES tuple appears only once (reused by both blocks)
mustContain(t, sql, "(1, 0.5::numeric)")
// Snapshot invalidation appears once at the end
mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)")
}
func TestBuildDownSQL(t *testing.T) {
opts := options{EffectiveDate: "2026-05-31"}
flockIDs := []uint{52, 53}
sql := buildDownSQL(opts, flockIDs)
// Delete by exact array match
mustContain(t, sql, "DELETE FROM house_depreciation_standards")
mustContain(t, sql, "project_flock_ids = ARRAY[52,53]::bigint[]")
mustContain(t, sql, "effective_date = DATE '2026-05-31'")
mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)")
}
func TestResolveEffectiveDate(t *testing.T) {
loc := time.UTC
if _, err := resolveEffectiveDate("2026-05-31", loc); err != nil {
t.Fatalf("valid date errored: %v", err)
}
if _, err := resolveEffectiveDate("31-05-2026", loc); err == nil {
t.Fatalf("expected error for wrong format")
}
got, err := resolveEffectiveDate("", loc)
if err != nil {
t.Fatalf("default date errored: %v", err)
}
if got.Hour() != 0 || got.Minute() != 0 {
t.Fatalf("default date should be midnight, got %v", got)
}
}
func TestNormalizeHouseType(t *testing.T) {
for _, ok := range []string{"open_house", "CLOSE_HOUSE", " close_house "} {
if _, err := normalizeHouseType(ok); err != nil {
t.Errorf("%q should be valid: %v", ok, err)
}
}
if _, err := normalizeHouseType("barn"); err == nil {
t.Errorf("barn should be invalid")
}
}
func TestInClause(t *testing.T) {
if got := inClause([]uint{52, 53, 54}); got != "52, 53, 54" {
t.Fatalf("wrong inClause: %s", got)
}
}
// --- helpers ------------------------------------------------------------------
func mustContain(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Fatalf("expected to find %q in:\n%s", needle, haystack)
}
}
@@ -112,8 +112,14 @@ type HppV2CostRepository interface {
GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error)
GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error)
GetChickinPopulationByPFKForFarm(ctx context.Context, projectFlockID uint) (map[uint]float64, error) GetChickinPopulationByPFKForFarm(ctx context.Context, projectFlockID uint) (map[uint]float64, error)
GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, map[string]*time.Time, error) GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, projectFlockID uint) (map[string]map[int]float64, map[string]*time.Time, error)
ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error) ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error)
// ListLayingUsageCostRowsByProductFlags meng-anchor atribusi ke kandang recording
// (recordings.project_flock_kandangs_id), bukan ke recording_stocks.project_flock_kandang_id.
// Diperlukan karena pakan/OVK kandang LAYING yang dikonsumsi dari gudang tipe LOKASI
// punya recording_stocks.project_flock_kandang_id = NULL — kasus ini harus tetap diatribusikan
// ke kandang laying sebagai production_cost (bukan jatuh ke RECORDING_STOCK_ROUTE / pullet_cost).
ListLayingUsageCostRowsByProductFlags(ctx context.Context, layingProjectFlockKandangID uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error)
ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error) ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error)
ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error)
ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error)
@@ -367,18 +373,19 @@ func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlo
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pfk_rec.project_flock_id = ?", projectFlockID). Where("pfk_rec.project_flock_id = ?", projectFlockID).
Where("DATE(r.record_datetime) <= DATE(?)", periodDate). Where("DATE(r.record_datetime) <= DATE(?)", periodDate).
// Hanya routing cross-kandang ASLI: stok yang dicatat di recording kandang X tetapi
// recording_stocks.project_flock_kandang_id menunjuk kandang lain (Y) saat ada transfer.
// Cabang lama "NOT(transferExists) AND rs.pfk IS NULL" DIHAPUS — kasus pakan/OVK laying
// dari gudang LOKASI (pfk NULL) kini diatribusikan sebagai production_cost via
// ListLayingUsageCostRowsByProductFlags, sehingga kedua jalur jadi disjoint (tanpa dobel).
Where( Where(
fmt.Sprintf( fmt.Sprintf(
"((%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id) OR (NOT (%s) AND rs.project_flock_kandang_id IS NULL)", "(%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id",
transferExistsCondition,
transferExistsCondition, transferExistsCondition,
), ),
periodDate, periodDate,
string(utils.ApprovalWorkflowTransferToLaying), string(utils.ApprovalWorkflowTransferToLaying),
entity.ApprovalActionApproved, entity.ApprovalActionApproved,
periodDate,
string(utils.ApprovalWorkflowTransferToLaying),
entity.ApprovalActionApproved,
). ).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error Scan(&total).Error
@@ -466,6 +473,7 @@ func (r *HppV2RepositoryImpl) GetMultiplicationPercentages(
ctx context.Context, ctx context.Context,
houseTypes []string, houseTypes []string,
maxDay int, maxDay int,
projectFlockID uint,
) (map[string]map[int]float64, map[string]*time.Time, error) { ) (map[string]map[int]float64, map[string]*time.Time, error) {
result := make(map[string]map[int]float64) result := make(map[string]map[int]float64)
effectiveDates := make(map[string]*time.Time) effectiveDates := make(map[string]*time.Time)
@@ -486,8 +494,9 @@ func (r *HppV2RepositoryImpl) GetMultiplicationPercentages(
house_type::text AS house_type, day, multiplication_percentage, effective_date house_type::text AS house_type, day, multiplication_percentage, effective_date
FROM house_depreciation_standards FROM house_depreciation_standards
WHERE house_type::text IN ? AND day <= ? WHERE house_type::text IN ? AND day <= ?
ORDER BY house_type, day, effective_date DESC NULLS LAST AND (project_flock_ids IS NULL OR ? = ANY(project_flock_ids))
`, houseTypes, maxDay).Scan(&rows).Error ORDER BY house_type, day, (project_flock_ids IS NOT NULL) DESC, effective_date DESC NULLS LAST
`, houseTypes, maxDay, projectFlockID).Scan(&rows).Error
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -583,6 +592,91 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
return rows, nil return rows, nil
} }
// ListLayingUsageCostRowsByProductFlags identik dengan ListUsageCostRowsByProductFlags,
// tetapi atribusi baris ditentukan oleh kandang RECORDING (r.project_flock_kandangs_id),
// dengan recording_stocks.project_flock_kandang_id boleh NULL (gudang LOKASI) atau sama
// dengan kandang laying. Baris yang routed ke kandang lain (rs.pfk <> kandang recording)
// SENGAJA TIDAK diikutkan di sini — itu ranah RECORDING_STOCK_ROUTE.
func (r *HppV2RepositoryImpl) ListLayingUsageCostRowsByProductFlags(
ctx context.Context,
layingProjectFlockKandangID uint,
flagNames []string,
date *time.Time,
) ([]HppV2UsageCostRow, error) {
if layingProjectFlockKandangID == 0 || len(flagNames) == 0 {
return []HppV2UsageCostRow{}, nil
}
if date == nil {
now := time.Now()
date = &now
}
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
rows := make([]HppV2UsageCostRow, 0)
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
sa.stockable_type AS stockable_type,
sa.stockable_id AS stockable_id,
COALESCE(pi.product_id, ast_pw.product_id, 0) AS source_product_id,
COALESCE(pi_prod.name, ast_prod.name, '') AS source_product_name,
COALESCE(SUM(sa.qty), 0) AS qty,
COALESCE(MAX(CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0) AS unit_price,
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0) AS total_cost,
MIN(r.record_datetime) AS first_used_at,
MAX(r.record_datetime) AS last_used_at
`,
stockablePurchase,
stockableAdjustment,
stockablePurchase,
stockableAdjustment,
).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableRecordingStock,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id").
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id").
Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id").
Where("r.project_flock_kandangs_id = ?", layingProjectFlockKandangID).
Where("(rs.project_flock_kandang_id IS NULL OR rs.project_flock_kandang_id = ?)", layingProjectFlockKandangID).
Where("r.deleted_at IS NULL").
Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames).
Group(`
sa.stockable_type,
sa.stockable_id,
COALESCE(pi.product_id, ast_pw.product_id, 0),
COALESCE(pi_prod.name, ast_prod.name, '')
`).
Order("MIN(r.record_datetime) ASC, sa.stockable_type ASC, sa.stockable_id ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags( func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags(
ctx context.Context, ctx context.Context,
projectFlockKandangIDs []uint, projectFlockKandangIDs []uint,
@@ -192,19 +192,27 @@ func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t
repo := &HppV2RepositoryImpl{db: db} repo := &HppV2RepositoryImpl{db: db}
// Route sekarang HANYA menangkap routing cross-kandang asli
// (transferExists AND rs.pfk IS NOT NULL AND rs.pfk <> r.project_flock_kandangs_id).
// Baris pfk NULL (gudang LOKASI) tidak lagi masuk route — kini jadi production_cost
// laying-usage via ListLayingUsageCostRowsByProductFlags.
// Pada 2026-04-30 hanya rs 102 yang lolos: recording pfk 101 (transfer 1001 approved &
// executed, effective 04-05 <= 04-30), rs.pfk 201 <> 101 → 1 × 110 = 110.
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00") periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate) total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
assertFloatEquals(t, total, 750) assertFloatEquals(t, total, 110)
// Pada 2026-04-10 hanya recording pfk 101 & 102 yang masuk rentang tanggal; tetap hanya
// rs 102 (cross-kandang) yang lolos → 110.
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59") earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod) earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
assertFloatEquals(t, earlyTotal, 240) assertFloatEquals(t, earlyTotal, 110)
} }
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
+167 -24
View File
@@ -55,6 +55,10 @@ type HppV2Service interface {
GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
// GetBopRegularProductionScopeRange / GetBopEkspedisiProductionScopeRange mengembalikan BOP
// production_cost untuk rentang [startDate, endDate] secara range-correct (tidak pernah negatif).
GetBopRegularProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error)
GetBopEkspedisiProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
} }
@@ -453,7 +457,7 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat
total += growingCutoverPart.Total total += growingCutoverPart.Total
} }
layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, false) layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, endDate, config, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -462,7 +466,7 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat
total += layingNormalPart.Total total += layingNormalPart.Total
} }
layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, true) layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, endDate, config, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -737,6 +741,7 @@ func (s *hppV2Service) buildGrowingUsagePart(
func (s *hppV2Service) buildLayingUsagePart( func (s *hppV2Service) buildLayingUsagePart(
projectFlockKandangId uint, projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time, endDate *time.Time,
config hppV2StockComponentConfig, config hppV2StockComponentConfig,
cutover bool, cutover bool,
@@ -778,7 +783,16 @@ func (s *hppV2Service) buildLayingUsagePart(
}, nil }, nil
} }
rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate) // Untuk kandang LAYING, atribusi pakan/OVK berbasis kandang recording (termasuk konsumsi
// dari gudang LOKASI yang punya recording_stocks.project_flock_kandang_id = NULL). Untuk
// kandang non-laying, pertahankan semantik lama (strict rs.project_flock_kandang_id IN [pfk]).
var rows []commonRepo.HppV2UsageCostRow
var err error
if contextRow != nil && contextRow.ProjectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
rows, err = s.hppRepo.ListLayingUsageCostRowsByProductFlags(context.Background(), projectFlockKandangId, config.NormalFlags, endDate)
} else {
rows, err = s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -931,17 +945,48 @@ func (s *hppV2Service) buildLayingExpenseFarmPart(
return nil, nil return nil, nil
} }
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) ratio, proration, err := s.layingFarmExpenseRatio(projectFlockKandangId, contextRow, endDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if ratio <= 0 {
return nil, nil
}
return buildExpensePartFromRows(
rows,
hppV2PartLayingFarm,
"Laying Farm",
[]string{hppV2ScopeProductionCost},
proration,
ratio,
), nil
}
// layingFarmExpenseRatio menghitung porsi (share) kandang laying terhadap seluruh farm pada
// endDate berdasarkan bobot telur KUMULATIF (fallback ke jumlah butir bila bobot 0). Return
// ratio 0 bila tak terhitung. Diekstrak agar dipakai bersama oleh buildLayingExpenseFarmPart
// dan GetExpenseProductionScopeRange (perhitungan BOP range-correct).
func (s *hppV2Service) layingFarmExpenseRatio(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
) (float64, *HppV2Proration, error) {
if contextRow == nil {
return 0, nil, nil
}
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return 0, nil, err
}
targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return nil, err return 0, nil, err
} }
farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate) farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate)
if err != nil { if err != nil {
return nil, err return 0, nil, err
} }
basis := hppV2ProrationEggWeight basis := hppV2ProrationEggWeight
@@ -953,27 +998,120 @@ func (s *hppV2Service) buildLayingExpenseFarmPart(
denominator = farmPieces denominator = farmPieces
} }
if denominator <= 0 { if denominator <= 0 {
return nil, nil return 0, nil, nil
} }
ratio := numerator / denominator ratio := numerator / denominator
if ratio <= 0 { if ratio <= 0 {
return nil, nil return 0, nil, nil
} }
return buildExpensePartFromRows( return ratio, &HppV2Proration{
rows,
hppV2PartLayingFarm,
"Laying Farm",
[]string{hppV2ScopeProductionCost},
&HppV2Proration{
Basis: basis, Basis: basis,
Numerator: numerator, Numerator: numerator,
Denominator: denominator, Denominator: denominator,
Ratio: ratio, Ratio: ratio,
}, }, nil
ratio, }
), nil
// GetExpenseProductionScopeRange menghitung BOP production_cost satu komponen expense untuk rentang
// [startDate, endDate] secara range-correct (tidak pernah negatif untuk expense non-negatif).
// - laying-direct (ratio 1, monoton): selisih kumulatif end - start.
// - laying-farm (prorated): (expenseCum(end) - expenseCum(start)) × ratio(end).
//
// Ini mengganti pola lama di report yang men-differensiasi dua angka yang sudah diprorata dengan
// ratio berbeda (ratio(end) vs ratio(start)) — sumber bug BOP negatif saat share antar kandang bergeser.
func (s *hppV2Service) GetExpenseProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time, config hppV2ExpenseComponentConfig) (float64, error) {
if s.hppRepo == nil {
return 0, nil
}
contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
// Samakan semantik tanggal dengan CalculateHppBreakdown: kumulatif dihitung sampai AKHIR hari
// (endOfDay). Penting karena ratio egg-weight memakai r.record_datetime (granular jam).
_, endOfEndDay, err := hppV2DayWindow(endDate)
if err != nil {
return 0, err
}
_, endOfStartDay, err := hppV2DayWindow(startDate)
if err != nil {
return 0, err
}
// laying-direct: delta kumulatif (monoton, >= 0).
directEnd, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, &endOfEndDay, config)
if err != nil {
return 0, err
}
directStart, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, &endOfStartDay, config)
if err != nil {
return 0, err
}
directDelta := hppV2PartTotal(directEnd) - hppV2PartTotal(directStart)
if directDelta < 0 {
directDelta = 0
}
// laying-farm: delta expense kumulatif × ratio(end).
farmRowsEnd, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, &endOfEndDay, config.Ekspedisi)
if err != nil {
return 0, err
}
farmRowsStart, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, &endOfStartDay, config.Ekspedisi)
if err != nil {
return 0, err
}
farmExpenseDelta := hppV2SumExpenseRows(farmRowsEnd) - hppV2SumExpenseRows(farmRowsStart)
if farmExpenseDelta < 0 {
farmExpenseDelta = 0
}
farmDelta := 0.0
if farmExpenseDelta > 0 {
ratio, _, err := s.layingFarmExpenseRatio(projectFlockKandangId, contextRow, &endOfEndDay)
if err != nil {
return 0, err
}
farmDelta = farmExpenseDelta * ratio
}
return directDelta + farmDelta, nil
}
// GetBopRegularProductionScopeRange / GetBopEkspedisiProductionScopeRange — wrapper range-correct
// untuk dua komponen BOP, memakai config yang sama dengan GetBopRegularBreakdown/GetBopEkspedisiBreakdown.
func (s *hppV2Service) GetBopRegularProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error) {
return s.GetExpenseProductionScopeRange(projectFlockKandangId, startDate, endDate, hppV2ExpenseComponentConfig{
Code: hppV2ComponentBopRegular,
Title: "BOP Regular",
Ekspedisi: false,
})
}
func (s *hppV2Service) GetBopEkspedisiProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error) {
return s.GetExpenseProductionScopeRange(projectFlockKandangId, startDate, endDate, hppV2ExpenseComponentConfig{
Code: hppV2ComponentBopEksp,
Title: "BOP Ekspedisi",
Ekspedisi: true,
})
}
func hppV2PartTotal(part *HppV2ComponentPart) float64 {
if part == nil {
return 0
}
return part.Total
}
func hppV2SumExpenseRows(rows []commonRepo.HppV2ExpenseCostRow) float64 {
total := 0.0
for _, row := range rows {
total += row.TotalCost
}
return total
} }
func (s *hppV2Service) getManualPulletCostComponent( func (s *hppV2Service) getManualPulletCostComponent(
@@ -1390,7 +1528,7 @@ func (s *hppV2Service) buildNormalTransferDepreciationPart(
} }
houseType := NormalizeDepreciationHouseType(contextRow.HouseType) houseType := NormalizeDepreciationHouseType(contextRow.HouseType)
multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, scheduleDay) multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, scheduleDay, contextRow.ProjectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1487,19 +1625,24 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart(
return nil, nil return nil, nil
} }
reportScheduleDay := DepreciationScheduleDay(*originDate, periodDate, contextRow.HouseType) // Hitung schedule day relatif terhadap cutover_date, bukan dari chick_in_date.
if reportScheduleDay <= 0 { // Ini menangani kasus cut-over flock yang belum 175 hari pada period date,
return nil, nil // karena bisnis sudah menetapkan cutover_date sebagai awal depresiasi.
} // Rumus setara secara matematis dengan DepreciationScheduleDay ketika flock >= 175 hari.
cutoverScheduleDay := DepreciationScheduleDay(*originDate, manualInput.CutoverDate, contextRow.HouseType) cutoverScheduleDay := DepreciationScheduleDay(*originDate, manualInput.CutoverDate, contextRow.HouseType)
startDay := 1 startDay := 1
if cutoverScheduleDay > 0 { if cutoverScheduleDay > 0 {
startDay = cutoverScheduleDay startDay = cutoverScheduleDay
} }
daysSinceCutover := int(dateOnly(periodDate).Sub(dateOnly(manualInput.CutoverDate)).Hours() / 24)
reportScheduleDay := startDay + daysSinceCutover
if reportScheduleDay <= 0 {
return nil, nil
}
houseType := NormalizeDepreciationHouseType(contextRow.HouseType) houseType := NormalizeDepreciationHouseType(contextRow.HouseType)
multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, reportScheduleDay) multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, reportScheduleDay, contextRow.ProjectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -25,6 +25,10 @@ type hppV2RepoStub struct {
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
// expenseRowsByFarmDateKey (opsional) membuat ListExpenseRealizationRowsByProjectFlockID
// date-aware untuk menguji perhitungan range BOP. Bila non-nil, dipakai menggantikan
// expenseRowsByFarmKey; key = "<flock>|<ekspedisi>|<YYYY-MM-DD>".
expenseRowsByFarmDateKey map[string][]commonRepo.HppV2ExpenseCostRow
routeCostByProject map[uint]float64 routeCostByProject map[uint]float64
totalPopulationByKey map[string]float64 totalPopulationByKey map[string]float64
transferSummaryByPFK map[uint]struct { transferSummaryByPFK map[uint]struct {
@@ -103,7 +107,7 @@ func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []
// GetMultiplicationPercentages — alias yang sama dengan GetDepreciationPercents untuk match // GetMultiplicationPercentages — alias yang sama dengan GetDepreciationPercents untuk match
// interface HppV2CostRepository (interface dipakai method name baru ini). // interface HppV2CostRepository (interface dipakai method name baru ini).
func (s *hppV2RepoStub) GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, map[string]*time.Time, error) { func (s *hppV2RepoStub) GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, _ uint) (map[string]map[int]float64, map[string]*time.Time, error) {
vals, err := s.GetDepreciationPercents(ctx, houseTypes, maxDay) vals, err := s.GetDepreciationPercents(ctx, houseTypes, maxDay)
return vals, make(map[string]*time.Time), err return vals, make(map[string]*time.Time), err
} }
@@ -118,6 +122,10 @@ func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, proje
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
} }
func (s *hppV2RepoStub) ListLayingUsageCostRowsByProductFlags(_ context.Context, layingProjectFlockKandangID uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey([]uint{layingProjectFlockKandangID}, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) { func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) {
return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
} }
@@ -126,7 +134,10 @@ func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ con
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil
} }
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
if s.expenseRowsByFarmDateKey != nil && date != nil {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmDateKey[expenseFarmDateKey(projectFlockID, ekspedisi, *date)]...), nil
}
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil
} }
@@ -904,6 +915,108 @@ func expenseFarmKey(projectFlockID uint, ekspedisi bool) string {
return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi) return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi)
} }
func expenseFarmDateKey(projectFlockID uint, ekspedisi bool, date time.Time) string {
return fmt.Sprintf("%d|%t|%s", projectFlockID, ekspedisi, date.Format("2006-01-02"))
}
func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string { func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string {
return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying))) return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying)))
} }
// TestHppV2PakanBreakdown_LayingAttributesLokasiFeedAsProductionCost membuktikan Fix 1:
// untuk kandang LAYING, pemakaian pakan (termasuk dari gudang LOKASI dengan pfk NULL) diatribusikan
// sebagai production_cost via ListLayingUsageCostRowsByProductFlags — BUKAN pullet_cost.
// Stub memetakan ListLayingUsageCostRowsByProductFlags(50,...) ke usageRowsByKey[[50]+PAKAN].
func TestHppV2PakanBreakdown_LayingAttributesLokasiFeedAsProductionCost(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
50: {
ProjectFlockKandangID: 50,
ProjectFlockID: 20,
ProjectFlockCategory: string(utils.ProjectFlockCategoryLaying),
KandangID: 1,
LocationID: 14,
HouseType: "close_house",
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{50}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9001, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 310, UnitPrice: 1, TotalCost: 310},
},
},
// Tanpa transferSummaryByPFK[50] -> growing part nil; tanpa adjustRowsByKey -> laying cutover nil.
}
svc := NewHppV2Service(repo)
component, err := svc.GetPakanBreakdown(50, mustDate(t, "2026-05-31"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if component == nil {
t.Fatal("expected PAKAN component")
}
if component.Total != 310 {
t.Fatalf("expected component total 310, got %v", component.Total)
}
if len(component.Parts) != 1 || component.Parts[0].Code != hppV2PartLayingNormal {
t.Fatalf("expected single laying_normal part, got %+v", component.Parts)
}
if got := componentScopeTotal(component, hppV2ScopeProductionCost); got != 310 {
t.Fatalf("expected production_cost 310, got %v", got)
}
if got := componentScopeTotal(component, hppV2ScopePulletCost); got != 0 {
t.Fatalf("expected pullet_cost 0 (feed laying bukan pullet), got %v", got)
}
}
// TestHppV2BopProductionScopeRange_NonNegativeAndProrated membuktikan Fix 2: BOP farm-level dihitung
// sebagai (expenseCum(end) - expenseCum(start)) × ratio(end) — range-correct & tidak pernah negatif.
// Range [2026-04-30, 2026-05-31] -> engine memakai endOfDay: start=2026-05-01, end=2026-06-01.
// Share kandang 50 = 30/(30+70) = 0.3.
// - REGULAR: expense farm tumbuh 1000 -> 1300 (delta 300) => 300 × 0.3 = 90.
// - EKSPEDISI: expense farm "turun" 500 -> 200 (delta -300, kasus uji clamp) => di-clamp ke 0.
func TestHppV2BopProductionScopeRange_NonNegativeAndProrated(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
50: {ProjectFlockKandangID: 50, ProjectFlockID: 20, ProjectFlockCategory: string(utils.ProjectFlockCategoryLaying)},
},
pfkIDsByProject: map[uint][]uint{
20: {50, 51},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
50: {pieces: 300, kg: 30},
51: {pieces: 700, kg: 70},
},
expenseRowsByFarmDateKey: map[string][]commonRepo.HppV2ExpenseCostRow{
// REGULAR (ekspedisi=false): kumulatif 1000 (start) -> 1300 (end)
expenseFarmDateKey(20, false, mustTime(t, "2026-05-01")): {{TotalCost: 1000}},
expenseFarmDateKey(20, false, mustTime(t, "2026-06-01")): {{TotalCost: 800}, {TotalCost: 500}},
// EKSPEDISI (ekspedisi=true): kumulatif 500 (start) -> 200 (end) => delta negatif, harus di-clamp
expenseFarmDateKey(20, true, mustTime(t, "2026-05-01")): {{TotalCost: 500}},
expenseFarmDateKey(20, true, mustTime(t, "2026-06-01")): {{TotalCost: 200}},
},
}
svc := NewHppV2Service(repo)
start := mustDate(t, "2026-04-30")
end := mustDate(t, "2026-05-31")
reg, err := svc.GetBopRegularProductionScopeRange(50, start, end)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if reg != 90 {
t.Fatalf("expected BOP regular range 90 (300 × 0.3), got %v", reg)
}
eksp, err := svc.GetBopEkspedisiProductionScopeRange(50, start, end)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if eksp != 0 {
t.Fatalf("expected BOP ekspedisi range clamped to 0 (delta negatif), got %v", eksp)
}
}
@@ -195,10 +195,13 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
if remaining > 0 { if remaining > 0 {
if !allowOverConsume { if !allowOverConsume {
return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty) s.logger.Warnf("FIFO v2: clearing historical pending (%.3f) for %s/%d at PW=%d — over-consume is blocked by rule",
} remaining, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID)
result.PendingQty = 0
} else {
result.PendingQty = remaining result.PendingQty = remaining
} }
}
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil { if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil {
return nil, err return nil, err
@@ -45,7 +45,16 @@ func ReleasePopulationConsumptionByUsable(
} }
} }
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil) // Only release the PROJECT_FLOCK_POPULATION allocations here. Releasing the
// other CONSUME allocations of this usable (RECORDING_EGG, STOCK_TRANSFER_IN,
// PURCHASE_ITEMS, etc.) would orphan their stockable total_used because this
// path only restores total_used_qty for population lots — leaving the FIFO
// stock counters permanently inflated (phantom stock). Those stock
// allocations are owned by the FIFO Reflow/Rollback path, which decrements
// total_used correctly via adjustStockableUsedQuantity.
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, func(db *gorm.DB) *gorm.DB {
return db.Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String())
})
} }
func AllocatePopulationConsumption( func AllocatePopulationConsumption(
@@ -0,0 +1,17 @@
BEGIN;
-- Revert the TELUR / TELUR_GRADE marketing over-sell block. Removing these rows
-- makes resolveOverConsume() fall back to the default allow rule again (the
-- post-20260313061525 behaviour). The reasons are unique to this migration, so
-- the DELETE only touches rows created here.
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IN ('TELUR', 'TELUR_GRADE')
AND reason IN (
'fifo_v2_exception_marketing_block_telur',
'fifo_v2_exception_marketing_block_telur_grade'
);
COMMIT;
@@ -0,0 +1,54 @@
BEGIN;
-- Restore the marketing over-sell block for TELUR and TELUR_GRADE only.
--
-- Migration 20260313061525 narrowed the MARKETING_OUT over-sell block to
-- flag_group_code='AYAM' and deactivated the global rule. That left TELUR /
-- TELUR_GRADE with no matching block, so resolveOverConsume() fell back to the
-- default rule 'fifo_v2_default_allow' (allow_overconsume=TRUE) and egg
-- Delivery Orders could over-sell silently into marketing_delivery_products.pending_qty.
--
-- These rules make resolveOverConsume('TELUR'|'TELUR_GRADE','MARKETING_OUT') = FALSE,
-- so an egg DO that exceeds available stock is REJECTED (ErrInsufficientStock)
-- instead of being recorded as pending. Scope is "Telur saja" — AYAM and
-- transfer behaviour are intentionally left unchanged.
--
-- NOTE: run the total_used reconciliation (cmd/reconcile-fifo-total-used) BEFORE
-- applying this in production. Enabling the block while phantom total_used still
-- inflates consumption would reject otherwise-valid egg orders.
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'TELUR', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR'
AND reason = 'fifo_v2_exception_marketing_block_telur'
);
UPDATE fifo_stock_v2_overconsume_rules
SET allow_overconsume = FALSE, priority = 20, is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR'
AND reason = 'fifo_v2_exception_marketing_block_telur';
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'TELUR_GRADE', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur_grade', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR_GRADE'
AND reason = 'fifo_v2_exception_marketing_block_telur_grade'
);
UPDATE fifo_stock_v2_overconsume_rules
SET allow_overconsume = FALSE, priority = 20, is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR_GRADE'
AND reason = 'fifo_v2_exception_marketing_block_telur_grade';
COMMIT;
@@ -0,0 +1,16 @@
-- Rollback: hapus kolom project_flock_ids dan semua index terkait,
-- kembalikan unique constraint lama (house_type, day, effective_date).
DROP INDEX IF EXISTS idx_hds_global_unique;
DROP INDEX IF EXISTS idx_hds_custom_unique;
DROP INDEX IF EXISTS idx_hds_project_flock_ids;
-- Safety net: hapus baris custom yang mungkin tersisa sebelum drop kolom.
DELETE FROM house_depreciation_standards WHERE project_flock_ids IS NOT NULL;
ALTER TABLE house_depreciation_standards DROP COLUMN project_flock_ids;
-- Kembalikan unique constraint lama.
ALTER TABLE house_depreciation_standards
ADD CONSTRAINT house_depreciation_standards_house_type_day_eff_unique
UNIQUE (house_type, day, effective_date);
@@ -0,0 +1,31 @@
-- Tambah kolom project_flock_ids BIGINT[] ke house_depreciation_standards.
-- Baris dengan project_flock_ids NOT NULL = kurva khusus untuk flock-flock tersebut.
-- Baris dengan project_flock_ids NULL = kurva global default (fallback semua flock).
--
-- BIGINT[] memungkinkan 1 baris dipakai oleh N flock tanpa duplikasi:
-- project_flock_ids = ARRAY[52, 53, 54] → baris ini berlaku untuk ketiga flock.
-- Lookup engine: WHERE project_flock_ids IS NULL OR ? = ANY(project_flock_ids)
-- 1. Hapus unique constraint lama (house_type, day, effective_date).
-- Digantikan oleh dua partial unique indexes di bawah.
ALTER TABLE house_depreciation_standards
DROP CONSTRAINT house_depreciation_standards_house_type_day_eff_unique;
-- 2. Tambah kolom baru.
ALTER TABLE house_depreciation_standards
ADD COLUMN project_flock_ids BIGINT[] NULL;
-- 3. GIN index untuk efficient ? = ANY(project_flock_ids) lookup.
CREATE INDEX idx_hds_project_flock_ids
ON house_depreciation_standards USING GIN (project_flock_ids);
-- 4. Partial unique: satu baris global per (house_type, day, effective_date).
CREATE UNIQUE INDEX idx_hds_global_unique
ON house_depreciation_standards (house_type, day, effective_date)
WHERE project_flock_ids IS NULL;
-- 5. Partial unique: satu baris custom per (house_type, day, effective_date).
-- Kalau butuh 2 kurva berbeda untuk hari+tanggal yang sama, pakai effective_date berbeda.
CREATE UNIQUE INDEX idx_hds_custom_unique
ON house_depreciation_standards (house_type, day, effective_date)
WHERE project_flock_ids IS NOT NULL;
@@ -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 427 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;
@@ -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,8 @@
DROP INDEX IF EXISTS idx_hds_custom_unique;
-- Remove all per-flock custom curves; they must be re-seeded after rolling back.
DELETE FROM house_depreciation_standards WHERE project_flock_ids IS NOT NULL;
CREATE UNIQUE INDEX idx_hds_custom_unique
ON house_depreciation_standards (house_type, day, effective_date)
WHERE project_flock_ids IS NOT NULL;
@@ -0,0 +1,10 @@
-- Allow multiple custom depreciation curves per (house_type, day, effective_date)
-- for different flock groups. Previously idx_hds_custom_unique only keyed on
-- (house_type, day, effective_date), so separate curves for different project_flock_ids
-- arrays on the same date were blocked.
DROP INDEX IF EXISTS idx_hds_custom_unique;
CREATE UNIQUE INDEX idx_hds_custom_unique
ON house_depreciation_standards (house_type, day, effective_date, project_flock_ids)
WHERE project_flock_ids IS NOT NULL;
@@ -0,0 +1,8 @@
-- Hapus baris kurva custom dari house_depreciation_standards.
-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[8,13,17]::bigint[]
AND effective_date = DATE '2026-06-03';
-- Recompute snapshot depresiasi.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (8, 13, 17);
@@ -0,0 +1,131 @@
-- Kurva depresiasi khusus flock 8, 13, 17 (house_type=open_house, effective_date=2026-06-03).
-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.
-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.
-- Hapus custom curve untuk array ini agar INSERT idempoten.
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[8,13,17]::bigint[]
AND house_type = 'open_house'::house_type_enum
AND effective_date = DATE '2026-06-03';
INSERT INTO house_depreciation_standards
(project_flock_ids, house_type, day, effective_date,
multiplication_percentage, depreciation_percent, standard_week, name)
SELECT
ARRAY[8,13,17]::bigint[], g.house_type, g.day, DATE '2026-06-03',
v.mult, (1 - v.mult) * 100, g.standard_week,
'Custom flocks 8, 13, 17 (eff 2026-06-03)'
FROM (VALUES
(1, 0.9978::numeric), (2, 0.9978), (3, 0.9978), (4, 0.9978), (5, 0.9978),
(6, 0.9978), (7, 0.9978), (8, 0.9978), (9, 0.9978), (10, 0.9978),
(11, 0.9978), (12, 0.9978), (13, 0.9978), (14, 0.9978), (15, 0.9978),
(16, 0.9978), (17, 0.9978), (18, 0.9978), (19, 0.9978), (20, 0.9978),
(21, 0.9977), (22, 0.9977), (23, 0.9977), (24, 0.9977), (25, 0.9977),
(26, 0.9977), (27, 0.9977), (28, 0.9977), (29, 0.9977), (30, 0.9975),
(31, 0.9974), (32, 0.9974), (33, 0.9974), (34, 0.9974), (35, 0.9974),
(36, 0.9974), (37, 0.9974), (38, 0.9974), (39, 0.9974), (40, 0.9974),
(41, 0.9974), (42, 0.9974), (43, 0.9977), (44, 0.9977), (45, 0.9977),
(46, 0.9977), (47, 0.9977), (48, 0.9977), (49, 0.9977), (50, 0.9973),
(51, 0.9973), (52, 0.9973), (53, 0.9973), (54, 0.9973), (55, 0.9973),
(56, 0.9973), (57, 0.9973), (58, 0.9973), (59, 0.9973), (60, 0.9973),
(61, 0.9972), (62, 0.9972), (63, 0.9972), (64, 0.9976), (65, 0.9976),
(66, 0.9976), (67, 0.9976), (68, 0.9976), (69, 0.9976), (70, 0.9976),
(71, 0.9972), (72, 0.9972), (73, 0.9972), (74, 0.9972), (75, 0.9971),
(76, 0.9971), (77, 0.9971), (78, 0.9975), (79, 0.9975), (80, 0.9975),
(81, 0.9975), (82, 0.9975), (83, 0.9975), (84, 0.9975), (85, 0.9971),
(86, 0.9971), (87, 0.9971), (88, 0.997), (89, 0.997), (90, 0.997),
(91, 0.997), (92, 0.997), (93, 0.997), (94, 0.997), (95, 0.997),
(96, 0.997), (97, 0.997), (98, 0.997), (99, 0.9974), (100, 0.9974),
(101, 0.9974), (102, 0.9974), (103, 0.9974), (104, 0.9973), (105, 0.9973),
(106, 0.9969), (107, 0.9969), (108, 0.9969), (109, 0.9969), (110, 0.9968),
(111, 0.9968), (112, 0.9968), (113, 0.9973), (114, 0.9973), (115, 0.9973),
(116, 0.9972), (117, 0.9972), (118, 0.9972), (119, 0.9972), (120, 0.9968),
(121, 0.9967), (122, 0.9967), (123, 0.9967), (124, 0.9967), (125, 0.9967),
(126, 0.9967), (127, 0.9972), (128, 0.9971), (129, 0.9971), (130, 0.9971),
(131, 0.9971), (132, 0.9971), (133, 0.9971), (134, 0.9966), (135, 0.9966),
(136, 0.9966), (137, 0.9966), (138, 0.9966), (139, 0.9966), (140, 0.9965),
(141, 0.997), (142, 0.997), (143, 0.997), (144, 0.997), (145, 0.997),
(146, 0.997), (147, 0.997), (148, 0.9965), (149, 0.9964), (150, 0.9964),
(151, 0.9964), (152, 0.9964), (153, 0.9964), (154, 0.9964), (155, 0.9969),
(156, 0.9969), (157, 0.9969), (158, 0.9969), (159, 0.9968), (160, 0.9968),
(161, 0.9968), (162, 0.9968), (163, 0.9968), (164, 0.9968), (165, 0.9968),
(166, 0.9968), (167, 0.9968), (168, 0.9968), (169, 0.9962), (170, 0.9962),
(171, 0.9962), (172, 0.9962), (173, 0.9961), (174, 0.9961), (175, 0.9961),
(176, 0.9967), (177, 0.9966), (178, 0.9966), (179, 0.9966), (180, 0.9966),
(181, 0.9966), (182, 0.9966), (183, 0.9966), (184, 0.9966), (185, 0.9965),
(186, 0.9965), (187, 0.9965), (188, 0.9965), (189, 0.9965), (190, 0.9959),
(191, 0.9959), (192, 0.9959), (193, 0.9959), (194, 0.9958), (195, 0.9958),
(196, 0.9958), (197, 0.9964), (198, 0.9964), (199, 0.9964), (200, 0.9963),
(201, 0.9963), (202, 0.9963), (203, 0.9963), (204, 0.9963), (205, 0.9963),
(206, 0.9963), (207, 0.9962), (208, 0.9962), (209, 0.9962), (210, 0.9962),
(211, 0.9962), (212, 0.9962), (213, 0.9962), (214, 0.9961), (215, 0.9961),
(216, 0.9961), (217, 0.9961), (218, 0.9961), (219, 0.9961), (220, 0.9961),
(221, 0.996), (222, 0.996), (223, 0.996), (224, 0.996), (225, 0.9953),
(226, 0.9953), (227, 0.9953), (228, 0.9952), (229, 0.9952), (230, 0.9952),
(231, 0.9952), (232, 0.9958), (233, 0.9958), (234, 0.9958), (235, 0.9958),
(236, 0.9958), (237, 0.9958), (238, 0.9957), (239, 0.9957), (240, 0.9957),
(241, 0.9957), (242, 0.9957), (243, 0.9956), (244, 0.9956), (245, 0.9956),
(246, 0.9956), (247, 0.9956), (248, 0.9955), (249, 0.9955), (250, 0.9955),
(251, 0.9955), (252, 0.9955), (253, 0.9954), (254, 0.9954), (255, 0.9954),
(256, 0.9954), (257, 0.9954), (258, 0.9953), (259, 0.9953), (260, 0.9953),
(261, 0.9953), (262, 0.9952), (263, 0.9952), (264, 0.9952), (265, 0.9952),
(266, 0.9952), (267, 0.9951), (268, 0.9951), (269, 0.9951), (270, 0.9951),
(271, 0.995), (272, 0.995), (273, 0.995), (274, 0.995), (275, 0.9949),
(276, 0.9949), (277, 0.9949), (278, 0.9949), (279, 0.9948), (280, 0.9948),
(281, 0.9948), (282, 0.9947), (283, 0.9947), (284, 0.9947), (285, 0.9947),
(286, 0.9946), (287, 0.9946), (288, 0.9955), (289, 0.9955), (290, 0.9954),
(291, 0.9954), (292, 0.9954), (293, 0.9954), (294, 0.9954), (295, 0.9944),
(296, 0.9944), (297, 0.9943), (298, 0.9943), (299, 0.9943), (300, 0.9942),
(301, 0.9942), (302, 0.9942), (303, 0.9941), (304, 0.9941), (305, 0.9941),
(306, 0.994), (307, 0.994), (308, 0.994), (309, 0.9939), (310, 0.9939),
(311, 0.9938), (312, 0.9938), (313, 0.9938), (314, 0.9937), (315, 0.9937),
(316, 0.9937), (317, 0.9936), (318, 0.9936), (319, 0.9935), (320, 0.9935),
(321, 0.9934), (322, 0.9934), (323, 0.9945), (324, 0.9944), (325, 0.9944),
(326, 0.9944), (327, 0.9943), (328, 0.9943), (329, 0.9943), (330, 0.9931),
(331, 0.993), (332, 0.993), (333, 0.9929), (334, 0.9929), (335, 0.9928),
(336, 0.9928), (337, 0.9939), (338, 0.9939), (339, 0.9939), (340, 0.9938),
(341, 0.9938), (342, 0.9938), (343, 0.9937), (344, 0.9924), (345, 0.9924),
(346, 0.9923), (347, 0.9922), (348, 0.9922), (349, 0.9921), (350, 0.9921),
(351, 0.992), (352, 0.9919), (353, 0.9919), (354, 0.9918), (355, 0.9917),
(356, 0.9917), (357, 0.9916), (358, 0.9929), (359, 0.9929), (360, 0.9928),
(361, 0.9928), (362, 0.9927), (363, 0.9927), (364, 0.9926), (365, 0.9911),
(366, 0.991), (367, 0.9909), (368, 0.9908), (369, 0.9907), (370, 0.9907),
(371, 0.9906), (372, 0.9921), (373, 0.992), (374, 0.9919), (375, 0.9919),
(376, 0.9918), (377, 0.9917), (378, 0.9917), (379, 0.9916), (380, 0.9915),
(381, 0.9915), (382, 0.9914), (383, 0.9913), (384, 0.9912), (385, 0.9912),
(386, 0.9893), (387, 0.9892), (388, 0.9891), (389, 0.9889), (390, 0.9888),
(391, 0.9887), (392, 0.9885), (393, 0.9903), (394, 0.9903), (395, 0.9902),
(396, 0.9901), (397, 0.99), (398, 0.9899), (399, 0.9898), (400, 0.9896),
(401, 0.9895), (402, 0.9894), (403, 0.9893), (404, 0.9892), (405, 0.9891),
(406, 0.989), (407, 0.9888), (408, 0.9887), (409, 0.9886), (410, 0.9885),
(411, 0.9883), (412, 0.9882), (413, 0.988), (414, 0.9855), (415, 0.9853),
(416, 0.985), (417, 0.9848), (418, 0.9846), (419, 0.9843), (420, 0.9841),
(421, 0.9865), (422, 0.9863), (423, 0.9861), (424, 0.986), (425, 0.9858),
(426, 0.9855), (427, 0.9853), (428, 0.9851), (429, 0.9849), (430, 0.9847),
(431, 0.9844), (432, 0.9842), (433, 0.9839), (434, 0.9837), (435, 0.9834),
(436, 0.9831), (437, 0.9828), (438, 0.9825), (439, 0.9822), (440, 0.9819),
(441, 0.9815), (442, 0.9812), (443, 0.9808), (444, 0.9805), (445, 0.9801),
(446, 0.9797), (447, 0.9793), (448, 0.9788), (449, 0.9784), (450, 0.9779),
(451, 0.9774), (452, 0.9769), (453, 0.9763), (454, 0.9757), (455, 0.9751),
(456, 0.9745), (457, 0.9738), (458, 0.9731), (459, 0.9724), (460, 0.9716),
(461, 0.9708), (462, 0.9699), (463, 0.9752), (464, 0.9745), (465, 0.9739),
(466, 0.9732), (467, 0.9724), (468, 0.9716), (469, 0.9708), (470, 0.9624),
(471, 0.9609), (472, 0.9593), (473, 0.9576), (474, 0.9558), (475, 0.9537),
(476, 0.9515), (477, 0.949), (478, 0.9462), (479, 0.9432), (480, 0.9398),
(481, 0.9359), (482, 0.9315), (483, 0.9265), (484, 0.9206), (485, 0.9138),
(486, 0.9057), (487, 0.8958), (488, 0.8837), (489, 0.8684), (490, 0.8485),
(491, 0.8571), (492, 0.8333), (493, 0.8), (494, 0.75), (495, 0.6667),
(496, 0.5), (497, 0)
) AS v(day, mult)
JOIN LATERAL (
SELECT DISTINCT ON (day) house_type, day, standard_week
FROM house_depreciation_standards
WHERE project_flock_ids IS NULL
AND house_type = 'open_house'::house_type_enum
AND day = v.day
ORDER BY day, effective_date DESC NULLS LAST
) g ON TRUE;
-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (8, 13, 17);
@@ -0,0 +1,8 @@
-- Hapus baris kurva custom dari house_depreciation_standards.
-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[18]::bigint[]
AND effective_date = DATE '2026-06-03';
-- Recompute snapshot depresiasi.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (18);
@@ -0,0 +1,131 @@
-- Kurva depresiasi khusus flock 18 (house_type=open_house, effective_date=2026-06-03).
-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.
-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.
-- Hapus custom curve untuk array ini agar INSERT idempoten.
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[18,14,12,15,9,19]::bigint[]
AND house_type = 'open_house'::house_type_enum
AND effective_date = DATE '2026-06-03';
INSERT INTO house_depreciation_standards
(project_flock_ids, house_type, day, effective_date,
multiplication_percentage, depreciation_percent, standard_week, name)
SELECT
ARRAY[18,14,12,15,9,19]::bigint[], g.house_type, g.day, DATE '2026-06-03',
v.mult, (1 - v.mult) * 100, g.standard_week,
'Custom flocks 18,14,12,15,9,19 (eff 2026-06-03)'
FROM (VALUES
(1, 0.9976::numeric), (2, 0.9976), (3, 0.9976), (4, 0.9976), (5, 0.9976),
(6, 0.9976), (7, 0.9976), (8, 0.9976), (9, 0.9976), (10, 0.9976),
(11, 0.9976), (12, 0.9976), (13, 0.9976), (14, 0.9975), (15, 0.9979),
(16, 0.9979), (17, 0.9979), (18, 0.9979), (19, 0.9979), (20, 0.9979),
(21, 0.9979), (22, 0.9975), (23, 0.9975), (24, 0.9975), (25, 0.9975),
(26, 0.9975), (27, 0.9975), (28, 0.9975), (29, 0.9975), (30, 0.9975),
(31, 0.9974), (32, 0.9974), (33, 0.9974), (34, 0.9974), (35, 0.9974),
(36, 0.9974), (37, 0.9974), (38, 0.9974), (39, 0.9974), (40, 0.9974),
(41, 0.9974), (42, 0.9974), (43, 0.9977), (44, 0.9977), (45, 0.9977),
(46, 0.9977), (47, 0.9977), (48, 0.9977), (49, 0.9977), (50, 0.9973),
(51, 0.9973), (52, 0.9973), (53, 0.9973), (54, 0.9973), (55, 0.9973),
(56, 0.9973), (57, 0.9973), (58, 0.9973), (59, 0.9973), (60, 0.9973),
(61, 0.9972), (62, 0.9972), (63, 0.9972), (64, 0.9976), (65, 0.9976),
(66, 0.9976), (67, 0.9976), (68, 0.9976), (69, 0.9976), (70, 0.9976),
(71, 0.9972), (72, 0.9972), (73, 0.9972), (74, 0.9972), (75, 0.9971),
(76, 0.9971), (77, 0.9971), (78, 0.9975), (79, 0.9975), (80, 0.9975),
(81, 0.9975), (82, 0.9975), (83, 0.9975), (84, 0.9975), (85, 0.9971),
(86, 0.9971), (87, 0.9971), (88, 0.997), (89, 0.997), (90, 0.997),
(91, 0.997), (92, 0.997), (93, 0.997), (94, 0.997), (95, 0.997),
(96, 0.997), (97, 0.997), (98, 0.997), (99, 0.9974), (100, 0.9974),
(101, 0.9974), (102, 0.9974), (103, 0.9974), (104, 0.9973), (105, 0.9973),
(106, 0.9969), (107, 0.9969), (108, 0.9969), (109, 0.9969), (110, 0.9968),
(111, 0.9968), (112, 0.9968), (113, 0.9973), (114, 0.9973), (115, 0.9973),
(116, 0.9972), (117, 0.9972), (118, 0.9972), (119, 0.9972), (120, 0.9968),
(121, 0.9967), (122, 0.9967), (123, 0.9967), (124, 0.9967), (125, 0.9967),
(126, 0.9967), (127, 0.9972), (128, 0.9971), (129, 0.9971), (130, 0.9971),
(131, 0.9971), (132, 0.9971), (133, 0.9971), (134, 0.9966), (135, 0.9966),
(136, 0.9966), (137, 0.9966), (138, 0.9966), (139, 0.9966), (140, 0.9965),
(141, 0.997), (142, 0.997), (143, 0.997), (144, 0.997), (145, 0.997),
(146, 0.997), (147, 0.997), (148, 0.9965), (149, 0.9964), (150, 0.9964),
(151, 0.9964), (152, 0.9964), (153, 0.9964), (154, 0.9964), (155, 0.9969),
(156, 0.9969), (157, 0.9969), (158, 0.9969), (159, 0.9968), (160, 0.9968),
(161, 0.9968), (162, 0.9968), (163, 0.9968), (164, 0.9968), (165, 0.9968),
(166, 0.9968), (167, 0.9968), (168, 0.9968), (169, 0.9962), (170, 0.9962),
(171, 0.9962), (172, 0.9962), (173, 0.9961), (174, 0.9961), (175, 0.9961),
(176, 0.9967), (177, 0.9966), (178, 0.9966), (179, 0.9966), (180, 0.9966),
(181, 0.9966), (182, 0.9966), (183, 0.9966), (184, 0.9966), (185, 0.9965),
(186, 0.9965), (187, 0.9965), (188, 0.9965), (189, 0.9965), (190, 0.9959),
(191, 0.9959), (192, 0.9959), (193, 0.9959), (194, 0.9958), (195, 0.9958),
(196, 0.9958), (197, 0.9964), (198, 0.9964), (199, 0.9964), (200, 0.9963),
(201, 0.9963), (202, 0.9963), (203, 0.9963), (204, 0.9963), (205, 0.9963),
(206, 0.9963), (207, 0.9962), (208, 0.9962), (209, 0.9962), (210, 0.9962),
(211, 0.9962), (212, 0.9962), (213, 0.9962), (214, 0.9961), (215, 0.9961),
(216, 0.9961), (217, 0.9961), (218, 0.9961), (219, 0.9961), (220, 0.9961),
(221, 0.996), (222, 0.996), (223, 0.996), (224, 0.996), (225, 0.9953),
(226, 0.9953), (227, 0.9953), (228, 0.9952), (229, 0.9952), (230, 0.9952),
(231, 0.9952), (232, 0.9958), (233, 0.9958), (234, 0.9958), (235, 0.9958),
(236, 0.9958), (237, 0.9958), (238, 0.9957), (239, 0.9957), (240, 0.9957),
(241, 0.9957), (242, 0.9957), (243, 0.9956), (244, 0.9956), (245, 0.9956),
(246, 0.9956), (247, 0.9956), (248, 0.9955), (249, 0.9955), (250, 0.9955),
(251, 0.9955), (252, 0.9955), (253, 0.9954), (254, 0.9954), (255, 0.9954),
(256, 0.9954), (257, 0.9954), (258, 0.9953), (259, 0.9953), (260, 0.9953),
(261, 0.9953), (262, 0.9952), (263, 0.9952), (264, 0.9952), (265, 0.9952),
(266, 0.9952), (267, 0.9951), (268, 0.9951), (269, 0.9951), (270, 0.9951),
(271, 0.995), (272, 0.995), (273, 0.995), (274, 0.995), (275, 0.9949),
(276, 0.9949), (277, 0.9949), (278, 0.9949), (279, 0.9948), (280, 0.9948),
(281, 0.9948), (282, 0.9947), (283, 0.9947), (284, 0.9947), (285, 0.9947),
(286, 0.9946), (287, 0.9946), (288, 0.9955), (289, 0.9955), (290, 0.9954),
(291, 0.9954), (292, 0.9954), (293, 0.9954), (294, 0.9954), (295, 0.9944),
(296, 0.9944), (297, 0.9943), (298, 0.9943), (299, 0.9943), (300, 0.9942),
(301, 0.9942), (302, 0.9942), (303, 0.9941), (304, 0.9941), (305, 0.9941),
(306, 0.994), (307, 0.994), (308, 0.994), (309, 0.9939), (310, 0.9939),
(311, 0.9938), (312, 0.9938), (313, 0.9938), (314, 0.9937), (315, 0.9937),
(316, 0.9937), (317, 0.9936), (318, 0.9936), (319, 0.9935), (320, 0.9935),
(321, 0.9934), (322, 0.9934), (323, 0.9945), (324, 0.9944), (325, 0.9944),
(326, 0.9944), (327, 0.9943), (328, 0.9943), (329, 0.9943), (330, 0.9931),
(331, 0.993), (332, 0.993), (333, 0.9929), (334, 0.9929), (335, 0.9928),
(336, 0.9928), (337, 0.9939), (338, 0.9939), (339, 0.9939), (340, 0.9938),
(341, 0.9938), (342, 0.9938), (343, 0.9937), (344, 0.9924), (345, 0.9924),
(346, 0.9923), (347, 0.9922), (348, 0.9922), (349, 0.9921), (350, 0.9921),
(351, 0.992), (352, 0.9919), (353, 0.9919), (354, 0.9918), (355, 0.9917),
(356, 0.9917), (357, 0.9916), (358, 0.9929), (359, 0.9929), (360, 0.9928),
(361, 0.9928), (362, 0.9927), (363, 0.9927), (364, 0.9926), (365, 0.9911),
(366, 0.991), (367, 0.9909), (368, 0.9908), (369, 0.9907), (370, 0.9907),
(371, 0.9906), (372, 0.9921), (373, 0.992), (374, 0.9919), (375, 0.9919),
(376, 0.9918), (377, 0.9917), (378, 0.9917), (379, 0.9916), (380, 0.9915),
(381, 0.9915), (382, 0.9914), (383, 0.9913), (384, 0.9912), (385, 0.9912),
(386, 0.9893), (387, 0.9892), (388, 0.9891), (389, 0.9889), (390, 0.9888),
(391, 0.9887), (392, 0.9885), (393, 0.9903), (394, 0.9903), (395, 0.9902),
(396, 0.9901), (397, 0.99), (398, 0.9899), (399, 0.9898), (400, 0.9896),
(401, 0.9895), (402, 0.9894), (403, 0.9893), (404, 0.9892), (405, 0.9891),
(406, 0.989), (407, 0.9888), (408, 0.9887), (409, 0.9886), (410, 0.9885),
(411, 0.9883), (412, 0.9882), (413, 0.988), (414, 0.9855), (415, 0.9853),
(416, 0.985), (417, 0.9848), (418, 0.9846), (419, 0.9843), (420, 0.9841),
(421, 0.9865), (422, 0.9863), (423, 0.9861), (424, 0.986), (425, 0.9858),
(426, 0.9855), (427, 0.9853), (428, 0.9851), (429, 0.9849), (430, 0.9847),
(431, 0.9844), (432, 0.9842), (433, 0.9839), (434, 0.9837), (435, 0.9834),
(436, 0.9831), (437, 0.9828), (438, 0.9825), (439, 0.9822), (440, 0.9819),
(441, 0.9815), (442, 0.9812), (443, 0.9808), (444, 0.9805), (445, 0.9801),
(446, 0.9797), (447, 0.9793), (448, 0.9788), (449, 0.9784), (450, 0.9779),
(451, 0.9774), (452, 0.9769), (453, 0.9763), (454, 0.9757), (455, 0.9751),
(456, 0.9745), (457, 0.9738), (458, 0.9731), (459, 0.9724), (460, 0.9716),
(461, 0.9708), (462, 0.9699), (463, 0.9752), (464, 0.9745), (465, 0.9739),
(466, 0.9732), (467, 0.9724), (468, 0.9716), (469, 0.9708), (470, 0.9624),
(471, 0.9609), (472, 0.9593), (473, 0.9576), (474, 0.9558), (475, 0.9537),
(476, 0.9515), (477, 0.949), (478, 0.9462), (479, 0.9432), (480, 0.9398),
(481, 0.9359), (482, 0.9315), (483, 0.9265), (484, 0.9206), (485, 0.9138),
(486, 0.9057), (487, 0.8958), (488, 0.8837), (489, 0.8684), (490, 0.8485),
(491, 0.8571), (492, 0.8333), (493, 0.8), (494, 0.75), (495, 0.6667),
(496, 0.5), (497, 0)
) AS v(day, mult)
JOIN LATERAL (
SELECT DISTINCT ON (day) house_type, day, standard_week
FROM house_depreciation_standards
WHERE project_flock_ids IS NULL
AND house_type = 'open_house'::house_type_enum
AND day = v.day
ORDER BY day, effective_date DESC NULLS LAST
) g ON TRUE;
-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (18, 14, 12, 15, 9, 19);
@@ -0,0 +1,8 @@
-- Hapus baris kurva custom dari house_depreciation_standards.
-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[26]::bigint[]
AND effective_date = DATE '2026-06-03';
-- Recompute snapshot depresiasi.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (26);
@@ -0,0 +1,139 @@
-- Kurva depresiasi khusus flock 26 (house_types=close_house, effective_date=2026-06-03).
-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.
-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.
-- Hapus custom curve untuk array ini agar INSERT idempoten.
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[26]::bigint[]
AND house_type = 'close_house'::house_type_enum
AND effective_date = DATE '2026-06-03';
-- house_type: close_house
INSERT INTO house_depreciation_standards
(project_flock_ids, house_type, day, effective_date,
multiplication_percentage, depreciation_percent, standard_week, name)
SELECT
ARRAY[26]::bigint[], g.house_type, g.day, DATE '2026-06-03',
v.mult, (1 - v.mult) * 100, g.standard_week,
'Custom flocks 26 (eff 2026-06-03)'
FROM (VALUES
(1, 0.9981::numeric), (2, 0.9981), (3, 0.9981), (4, 0.9981), (5, 0.9981),
(6, 0.9981), (7, 0.9981), (8, 0.9978), (9, 0.9978), (10, 0.9978),
(11, 0.9978), (12, 0.9978), (13, 0.9978), (14, 0.9978), (15, 0.9978),
(16, 0.9978), (17, 0.9978), (18, 0.9978), (19, 0.9978), (20, 0.9978),
(21, 0.9978), (22, 0.9981), (23, 0.9981), (24, 0.9981), (25, 0.9981),
(26, 0.9981), (27, 0.9981), (28, 0.9981), (29, 0.9978), (30, 0.9978),
(31, 0.9978), (32, 0.9978), (33, 0.9978), (34, 0.9978), (35, 0.9978),
(36, 0.9978), (37, 0.9978), (38, 0.9978), (39, 0.9978), (40, 0.9978),
(41, 0.9978), (42, 0.9978), (43, 0.9978), (44, 0.9978), (45, 0.9978),
(46, 0.9978), (47, 0.9978), (48, 0.9978), (49, 0.9978), (50, 0.9981),
(51, 0.9981), (52, 0.9981), (53, 0.9981), (54, 0.9981), (55, 0.9981),
(56, 0.9981), (57, 0.9978), (58, 0.9978), (59, 0.9978), (60, 0.9978),
(61, 0.9978), (62, 0.9978), (63, 0.9978), (64, 0.9978), (65, 0.9978),
(66, 0.9977), (67, 0.9977), (68, 0.9977), (69, 0.9977), (70, 0.9977),
(71, 0.9973), (72, 0.9973), (73, 0.9973), (74, 0.9973), (75, 0.9973),
(76, 0.9973), (77, 0.9973), (78, 0.9977), (79, 0.9977), (80, 0.9977),
(81, 0.9977), (82, 0.9977), (83, 0.9976), (84, 0.9976), (85, 0.9972),
(86, 0.9972), (87, 0.9972), (88, 0.9972), (89, 0.9972), (90, 0.9972),
(91, 0.9972), (92, 0.9972), (93, 0.9972), (94, 0.9972), (95, 0.9972),
(96, 0.9972), (97, 0.9972), (98, 0.9971), (99, 0.9975), (100, 0.9975),
(101, 0.9975), (102, 0.9975), (103, 0.9975), (104, 0.9975), (105, 0.9975),
(106, 0.9971), (107, 0.9971), (108, 0.9971), (109, 0.9971), (110, 0.9971),
(111, 0.997), (112, 0.997), (113, 0.9974), (114, 0.9974), (115, 0.9974),
(116, 0.9974), (117, 0.9974), (118, 0.9974), (119, 0.9974), (120, 0.997),
(121, 0.997), (122, 0.997), (123, 0.9969), (124, 0.9969), (125, 0.9969),
(126, 0.9969), (127, 0.9973), (128, 0.9973), (129, 0.9973), (130, 0.9973),
(131, 0.9973), (132, 0.9973), (133, 0.9973), (134, 0.9968), (135, 0.9968),
(136, 0.9968), (137, 0.9968), (138, 0.9968), (139, 0.9968), (140, 0.9968),
(141, 0.9972), (142, 0.9972), (143, 0.9972), (144, 0.9972), (145, 0.9972),
(146, 0.9972), (147, 0.9972), (148, 0.9967), (149, 0.9967), (150, 0.9967),
(151, 0.9967), (152, 0.9967), (153, 0.9967), (154, 0.9966), (155, 0.9971),
(156, 0.9971), (157, 0.9971), (158, 0.9971), (159, 0.9971), (160, 0.9971),
(161, 0.9971), (162, 0.9971), (163, 0.997), (164, 0.997), (165, 0.997),
(166, 0.997), (167, 0.997), (168, 0.997), (169, 0.9965), (170, 0.9965),
(171, 0.9965), (172, 0.9965), (173, 0.9964), (174, 0.9964), (175, 0.9964),
(176, 0.9969), (177, 0.9969), (178, 0.9969), (179, 0.9969), (180, 0.9969),
(181, 0.9969), (182, 0.9969), (183, 0.9968), (184, 0.9968), (185, 0.9968),
(186, 0.9968), (187, 0.9968), (188, 0.9968), (189, 0.9968), (190, 0.9962),
(191, 0.9962), (192, 0.9962), (193, 0.9962), (194, 0.9962), (195, 0.9962),
(196, 0.9962), (197, 0.9967), (198, 0.9967), (199, 0.9967), (200, 0.9967),
(201, 0.9966), (202, 0.9966), (203, 0.9966), (204, 0.9966), (205, 0.9966),
(206, 0.9966), (207, 0.9966), (208, 0.9966), (209, 0.9966), (210, 0.9965),
(211, 0.9965), (212, 0.9965), (213, 0.9965), (214, 0.9965), (215, 0.9965),
(216, 0.9965), (217, 0.9965), (218, 0.9964), (219, 0.9964), (220, 0.9964),
(221, 0.9964), (222, 0.9964), (223, 0.9964), (224, 0.9964), (225, 0.9957),
(226, 0.9957), (227, 0.9957), (228, 0.9957), (229, 0.9957), (230, 0.9957),
(231, 0.9956), (232, 0.9962), (233, 0.9962), (234, 0.9962), (235, 0.9962),
(236, 0.9962), (237, 0.9962), (238, 0.9962), (239, 0.9961), (240, 0.9961),
(241, 0.9961), (242, 0.9961), (243, 0.9961), (244, 0.9961), (245, 0.996),
(246, 0.996), (247, 0.996), (248, 0.996), (249, 0.996), (250, 0.996),
(251, 0.996), (252, 0.9959), (253, 0.9959), (254, 0.9959), (255, 0.9959),
(256, 0.9959), (257, 0.9959), (258, 0.9958), (259, 0.9958), (260, 0.9958),
(261, 0.9958), (262, 0.9958), (263, 0.9957), (264, 0.9957), (265, 0.9957),
(266, 0.9957), (267, 0.9957), (268, 0.9957), (269, 0.9956), (270, 0.9956),
(271, 0.9956), (272, 0.9956), (273, 0.9956), (274, 0.9955), (275, 0.9955),
(276, 0.9955), (277, 0.9955), (278, 0.9955), (279, 0.9954), (280, 0.9954),
(281, 0.9954), (282, 0.9954), (283, 0.9953), (284, 0.9953), (285, 0.9953),
(286, 0.9953), (287, 0.9953), (288, 0.996), (289, 0.996), (290, 0.996),
(291, 0.996), (292, 0.996), (293, 0.996), (294, 0.9959), (295, 0.9951),
(296, 0.9951), (297, 0.9951), (298, 0.995), (299, 0.995), (300, 0.995),
(301, 0.995), (302, 0.9949), (303, 0.9949), (304, 0.9949), (305, 0.9948),
(306, 0.9948), (307, 0.9948), (308, 0.9948), (309, 0.9947), (310, 0.9947),
(311, 0.9947), (312, 0.9947), (313, 0.9946), (314, 0.9946), (315, 0.9946),
(316, 0.9945), (317, 0.9945), (318, 0.9945), (319, 0.9944), (320, 0.9944),
(321, 0.9944), (322, 0.9944), (323, 0.9953), (324, 0.9952), (325, 0.9952),
(326, 0.9952), (327, 0.9952), (328, 0.9952), (329, 0.9951), (330, 0.9941),
(331, 0.9941), (332, 0.9941), (333, 0.994), (334, 0.994), (335, 0.994),
(336, 0.9939), (337, 0.9949), (338, 0.9949), (339, 0.9948), (340, 0.9948),
(341, 0.9948), (342, 0.9948), (343, 0.9947), (344, 0.9937), (345, 0.9936),
(346, 0.9936), (347, 0.9935), (348, 0.9935), (349, 0.9934), (350, 0.9934),
(351, 0.9934), (352, 0.9933), (353, 0.9933), (354, 0.9932), (355, 0.9932),
(356, 0.9931), (357, 0.9931), (358, 0.9942), (359, 0.9942), (360, 0.9941),
(361, 0.9941), (362, 0.9941), (363, 0.994), (364, 0.994), (365, 0.9927),
(366, 0.9927), (367, 0.9926), (368, 0.9926), (369, 0.9925), (370, 0.9925),
(371, 0.9924), (372, 0.9936), (373, 0.9936), (374, 0.9935), (375, 0.9935),
(376, 0.9935), (377, 0.9934), (378, 0.9934), (379, 0.9933), (380, 0.9933),
(381, 0.9932), (382, 0.9932), (383, 0.9931), (384, 0.9931), (385, 0.993),
(386, 0.9916), (387, 0.9915), (388, 0.9915), (389, 0.9914), (390, 0.9913),
(391, 0.9912), (392, 0.9912), (393, 0.9926), (394, 0.9925), (395, 0.9924),
(396, 0.9924), (397, 0.9923), (398, 0.9923), (399, 0.9922), (400, 0.9922),
(401, 0.9921), (402, 0.992), (403, 0.992), (404, 0.9919), (405, 0.9918),
(406, 0.9918), (407, 0.9917), (408, 0.9916), (409, 0.9916), (410, 0.9915),
(411, 0.9914), (412, 0.9913), (413, 0.9913), (414, 0.9894), (415, 0.9893),
(416, 0.9892), (417, 0.9891), (418, 0.989), (419, 0.9888), (420, 0.9887),
(421, 0.9905), (422, 0.9904), (423, 0.9903), (424, 0.9902), (425, 0.9901),
(426, 0.99), (427, 0.9899), (428, 0.9898), (429, 0.9897), (430, 0.9896),
(431, 0.9895), (432, 0.9894), (433, 0.9892), (434, 0.9891), (435, 0.989),
(436, 0.9889), (437, 0.9888), (438, 0.9886), (439, 0.9885), (440, 0.9884),
(441, 0.9882), (442, 0.9881), (443, 0.988), (444, 0.9878), (445, 0.9877),
(446, 0.9875), (447, 0.9873), (448, 0.9872), (449, 0.987), (450, 0.9868),
(451, 0.9867), (452, 0.9865), (453, 0.9863), (454, 0.9861), (455, 0.9859),
(456, 0.9857), (457, 0.9855), (458, 0.9853), (459, 0.9851), (460, 0.9848),
(461, 0.9846), (462, 0.9844), (463, 0.9873), (464, 0.9871), (465, 0.987),
(466, 0.9868), (467, 0.9866), (468, 0.9864), (469, 0.9863), (470, 0.9826),
(471, 0.9823), (472, 0.9819), (473, 0.9816), (474, 0.9813), (475, 0.9809),
(476, 0.9805), (477, 0.9802), (478, 0.9798), (479, 0.9793), (480, 0.9789),
(481, 0.9784), (482, 0.978), (483, 0.9775), (484, 0.977), (485, 0.9764),
(486, 0.9758), (487, 0.9752), (488, 0.9746), (489, 0.974), (490, 0.9733),
(491, 0.978), (492, 0.9775), (493, 0.977), (494, 0.9765), (495, 0.9759),
(496, 0.9753), (497, 0.9747), (498, 0.9675), (499, 0.9664), (500, 0.9653),
(501, 0.964), (502, 0.9627), (503, 0.9612), (504, 0.9597), (505, 0.9664),
(506, 0.9652), (507, 0.964), (508, 0.9626), (509, 0.9612), (510, 0.9596),
(511, 0.9579), (512, 0.9451), (513, 0.9419), (514, 0.9383), (515, 0.9342),
(516, 0.9296), (517, 0.9242), (518, 0.918), (519, 0.9286), (520, 0.9231),
(521, 0.9167), (522, 0.9091), (523, 0.9), (524, 0.8889), (525, 0.875),
(526, 0.8571), (527, 0.8333), (528, 0.8), (529, 0.75), (530, 0.6667),
(531, 0.5), (532, 0)
) AS v(day, mult)
JOIN LATERAL (
SELECT DISTINCT ON (day) house_type, day, standard_week
FROM house_depreciation_standards
WHERE project_flock_ids IS NULL
AND house_type = 'close_house'::house_type_enum
AND day = v.day
ORDER BY day, effective_date DESC NULLS LAST
) g ON TRUE;
-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (26);
@@ -0,0 +1,8 @@
-- Hapus baris kurva custom dari house_depreciation_standards.
-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[20]::bigint[]
AND effective_date = DATE '2026-06-03';
-- Recompute snapshot depresiasi.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (20);
@@ -0,0 +1,139 @@
-- Kurva depresiasi khusus flock 20 (house_types=open_house, effective_date=2026-06-03).
-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.
-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.
-- Hapus custom curve untuk array ini agar INSERT idempoten.
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[20]::bigint[]
AND house_type = 'open_house'::house_type_enum
AND effective_date = DATE '2026-06-03';
-- house_type: open_house
INSERT INTO house_depreciation_standards
(project_flock_ids, house_type, day, effective_date,
multiplication_percentage, depreciation_percent, standard_week, name)
SELECT
ARRAY[20]::bigint[], g.house_type, g.day, DATE '2026-06-03',
v.mult, (1 - v.mult) * 100, g.standard_week,
'Custom flocks 20 (eff 2026-06-03)'
FROM (VALUES
(1, 0.9977::numeric), (2, 0.9977), (3, 0.9977), (4, 0.9977), (5, 0.9977),
(6, 0.9977), (7, 0.9977), (8, 0.9977), (9, 0.9977), (10, 0.9977),
(11, 0.9977), (12, 0.9977), (13, 0.9977), (14, 0.9977), (15, 0.998),
(16, 0.998), (17, 0.998), (18, 0.998), (19, 0.998), (20, 0.998),
(21, 0.998), (22, 0.9976), (23, 0.9976), (24, 0.9976), (25, 0.9976),
(26, 0.9976), (27, 0.9976), (28, 0.9976), (29, 0.9976), (30, 0.9976),
(31, 0.9976), (32, 0.9976), (33, 0.9976), (34, 0.9976), (35, 0.9976),
(36, 0.9976), (37, 0.9975), (38, 0.9975), (39, 0.9975), (40, 0.9975),
(41, 0.9975), (42, 0.9975), (43, 0.9979), (44, 0.9979), (45, 0.9979),
(46, 0.9979), (47, 0.9978), (48, 0.9978), (49, 0.9978), (50, 0.9975),
(51, 0.9975), (52, 0.9975), (53, 0.9975), (54, 0.9974), (55, 0.9974),
(56, 0.9974), (57, 0.9974), (58, 0.9974), (59, 0.9974), (60, 0.9974),
(61, 0.9974), (62, 0.9974), (63, 0.9974), (64, 0.9978), (65, 0.9978),
(66, 0.9977), (67, 0.9977), (68, 0.9977), (69, 0.9977), (70, 0.9977),
(71, 0.9973), (72, 0.9973), (73, 0.9973), (74, 0.9973), (75, 0.9973),
(76, 0.9973), (77, 0.9973), (78, 0.9977), (79, 0.9977), (80, 0.9977),
(81, 0.9977), (82, 0.9977), (83, 0.9976), (84, 0.9976), (85, 0.9972),
(86, 0.9972), (87, 0.9972), (88, 0.9972), (89, 0.9972), (90, 0.9972),
(91, 0.9972), (92, 0.9972), (93, 0.9972), (94, 0.9972), (95, 0.9972),
(96, 0.9972), (97, 0.9972), (98, 0.9971), (99, 0.9975), (100, 0.9975),
(101, 0.9975), (102, 0.9975), (103, 0.9975), (104, 0.9975), (105, 0.9975),
(106, 0.9971), (107, 0.9971), (108, 0.9971), (109, 0.9971), (110, 0.9971),
(111, 0.997), (112, 0.997), (113, 0.9974), (114, 0.9974), (115, 0.9974),
(116, 0.9974), (117, 0.9974), (118, 0.9974), (119, 0.9974), (120, 0.997),
(121, 0.997), (122, 0.997), (123, 0.9969), (124, 0.9969), (125, 0.9969),
(126, 0.9969), (127, 0.9973), (128, 0.9973), (129, 0.9973), (130, 0.9973),
(131, 0.9973), (132, 0.9973), (133, 0.9973), (134, 0.9968), (135, 0.9968),
(136, 0.9968), (137, 0.9968), (138, 0.9968), (139, 0.9968), (140, 0.9968),
(141, 0.9972), (142, 0.9972), (143, 0.9972), (144, 0.9972), (145, 0.9972),
(146, 0.9972), (147, 0.9972), (148, 0.9967), (149, 0.9967), (150, 0.9967),
(151, 0.9967), (152, 0.9967), (153, 0.9967), (154, 0.9966), (155, 0.9971),
(156, 0.9971), (157, 0.9971), (158, 0.9971), (159, 0.9971), (160, 0.9971),
(161, 0.9971), (162, 0.9971), (163, 0.997), (164, 0.997), (165, 0.997),
(166, 0.997), (167, 0.997), (168, 0.997), (169, 0.9965), (170, 0.9965),
(171, 0.9965), (172, 0.9965), (173, 0.9964), (174, 0.9964), (175, 0.9964),
(176, 0.9969), (177, 0.9969), (178, 0.9969), (179, 0.9969), (180, 0.9969),
(181, 0.9969), (182, 0.9969), (183, 0.9968), (184, 0.9968), (185, 0.9968),
(186, 0.9968), (187, 0.9968), (188, 0.9968), (189, 0.9968), (190, 0.9962),
(191, 0.9962), (192, 0.9962), (193, 0.9962), (194, 0.9962), (195, 0.9962),
(196, 0.9962), (197, 0.9967), (198, 0.9967), (199, 0.9967), (200, 0.9967),
(201, 0.9966), (202, 0.9966), (203, 0.9966), (204, 0.9966), (205, 0.9966),
(206, 0.9966), (207, 0.9966), (208, 0.9966), (209, 0.9966), (210, 0.9965),
(211, 0.9965), (212, 0.9965), (213, 0.9965), (214, 0.9965), (215, 0.9965),
(216, 0.9965), (217, 0.9965), (218, 0.9964), (219, 0.9964), (220, 0.9964),
(221, 0.9964), (222, 0.9964), (223, 0.9964), (224, 0.9964), (225, 0.9957),
(226, 0.9957), (227, 0.9957), (228, 0.9957), (229, 0.9957), (230, 0.9957),
(231, 0.9956), (232, 0.9962), (233, 0.9962), (234, 0.9962), (235, 0.9962),
(236, 0.9962), (237, 0.9962), (238, 0.9962), (239, 0.9961), (240, 0.9961),
(241, 0.9961), (242, 0.9961), (243, 0.9961), (244, 0.9961), (245, 0.996),
(246, 0.996), (247, 0.996), (248, 0.996), (249, 0.996), (250, 0.996),
(251, 0.996), (252, 0.9959), (253, 0.9959), (254, 0.9959), (255, 0.9959),
(256, 0.9959), (257, 0.9959), (258, 0.9958), (259, 0.9958), (260, 0.9958),
(261, 0.9958), (262, 0.9958), (263, 0.9957), (264, 0.9957), (265, 0.9957),
(266, 0.9957), (267, 0.9957), (268, 0.9957), (269, 0.9956), (270, 0.9956),
(271, 0.9956), (272, 0.9956), (273, 0.9956), (274, 0.9955), (275, 0.9955),
(276, 0.9955), (277, 0.9955), (278, 0.9955), (279, 0.9954), (280, 0.9954),
(281, 0.9954), (282, 0.9954), (283, 0.9953), (284, 0.9953), (285, 0.9953),
(286, 0.9953), (287, 0.9953), (288, 0.996), (289, 0.996), (290, 0.996),
(291, 0.996), (292, 0.996), (293, 0.996), (294, 0.9959), (295, 0.9951),
(296, 0.9951), (297, 0.9951), (298, 0.995), (299, 0.995), (300, 0.995),
(301, 0.995), (302, 0.9949), (303, 0.9949), (304, 0.9949), (305, 0.9948),
(306, 0.9948), (307, 0.9948), (308, 0.9948), (309, 0.9947), (310, 0.9947),
(311, 0.9947), (312, 0.9947), (313, 0.9946), (314, 0.9946), (315, 0.9946),
(316, 0.9945), (317, 0.9945), (318, 0.9945), (319, 0.9944), (320, 0.9944),
(321, 0.9944), (322, 0.9944), (323, 0.9953), (324, 0.9952), (325, 0.9952),
(326, 0.9952), (327, 0.9952), (328, 0.9952), (329, 0.9951), (330, 0.9941),
(331, 0.9941), (332, 0.9941), (333, 0.994), (334, 0.994), (335, 0.994),
(336, 0.9939), (337, 0.9949), (338, 0.9949), (339, 0.9948), (340, 0.9948),
(341, 0.9948), (342, 0.9948), (343, 0.9947), (344, 0.9937), (345, 0.9936),
(346, 0.9936), (347, 0.9935), (348, 0.9935), (349, 0.9934), (350, 0.9934),
(351, 0.9934), (352, 0.9933), (353, 0.9933), (354, 0.9932), (355, 0.9932),
(356, 0.9931), (357, 0.9931), (358, 0.9942), (359, 0.9942), (360, 0.9941),
(361, 0.9941), (362, 0.9941), (363, 0.994), (364, 0.994), (365, 0.9927),
(366, 0.9927), (367, 0.9926), (368, 0.9926), (369, 0.9925), (370, 0.9925),
(371, 0.9924), (372, 0.9936), (373, 0.9936), (374, 0.9935), (375, 0.9935),
(376, 0.9935), (377, 0.9934), (378, 0.9934), (379, 0.9933), (380, 0.9933),
(381, 0.9932), (382, 0.9932), (383, 0.9931), (384, 0.9931), (385, 0.993),
(386, 0.9916), (387, 0.9915), (388, 0.9915), (389, 0.9914), (390, 0.9913),
(391, 0.9912), (392, 0.9912), (393, 0.9926), (394, 0.9925), (395, 0.9924),
(396, 0.9924), (397, 0.9923), (398, 0.9923), (399, 0.9922), (400, 0.9922),
(401, 0.9921), (402, 0.992), (403, 0.992), (404, 0.9919), (405, 0.9918),
(406, 0.9918), (407, 0.9917), (408, 0.9916), (409, 0.9916), (410, 0.9915),
(411, 0.9914), (412, 0.9913), (413, 0.9913), (414, 0.9894), (415, 0.9893),
(416, 0.9892), (417, 0.9891), (418, 0.989), (419, 0.9888), (420, 0.9887),
(421, 0.9905), (422, 0.9904), (423, 0.9903), (424, 0.9902), (425, 0.9901),
(426, 0.99), (427, 0.9899), (428, 0.9898), (429, 0.9897), (430, 0.9896),
(431, 0.9895), (432, 0.9894), (433, 0.9892), (434, 0.9891), (435, 0.989),
(436, 0.9889), (437, 0.9888), (438, 0.9886), (439, 0.9885), (440, 0.9884),
(441, 0.9882), (442, 0.9881), (443, 0.988), (444, 0.9878), (445, 0.9877),
(446, 0.9875), (447, 0.9873), (448, 0.9872), (449, 0.987), (450, 0.9868),
(451, 0.9867), (452, 0.9865), (453, 0.9863), (454, 0.9861), (455, 0.9859),
(456, 0.9857), (457, 0.9855), (458, 0.9853), (459, 0.9851), (460, 0.9848),
(461, 0.9846), (462, 0.9844), (463, 0.9873), (464, 0.9871), (465, 0.987),
(466, 0.9868), (467, 0.9866), (468, 0.9864), (469, 0.9863), (470, 0.9826),
(471, 0.9823), (472, 0.9819), (473, 0.9816), (474, 0.9813), (475, 0.9809),
(476, 0.9805), (477, 0.9802), (478, 0.9798), (479, 0.9793), (480, 0.9789),
(481, 0.9784), (482, 0.978), (483, 0.9775), (484, 0.977), (485, 0.9764),
(486, 0.9758), (487, 0.9752), (488, 0.9746), (489, 0.974), (490, 0.9733),
(491, 0.978), (492, 0.9775), (493, 0.977), (494, 0.9765), (495, 0.9759),
(496, 0.9753), (497, 0.9747), (498, 0.9675), (499, 0.9664), (500, 0.9653),
(501, 0.964), (502, 0.9627), (503, 0.9612), (504, 0.9597), (505, 0.9664),
(506, 0.9652), (507, 0.964), (508, 0.9626), (509, 0.9612), (510, 0.9596),
(511, 0.9579), (512, 0.9451), (513, 0.9419), (514, 0.9383), (515, 0.9342),
(516, 0.9296), (517, 0.9242), (518, 0.918), (519, 0.9286), (520, 0.9231),
(521, 0.9167), (522, 0.9091), (523, 0.9), (524, 0.8889), (525, 0.875),
(526, 0.8571), (527, 0.8333), (528, 0.8), (529, 0.75), (530, 0.6667),
(531, 0.5), (532, 0)
) AS v(day, mult)
JOIN LATERAL (
SELECT DISTINCT ON (day) house_type, day, standard_week
FROM house_depreciation_standards
WHERE project_flock_ids IS NULL
AND house_type = 'open_house'::house_type_enum
AND day = v.day
ORDER BY day, effective_date DESC NULLS LAST
) g ON TRUE;
-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (20);
@@ -0,0 +1,8 @@
-- Hapus baris kurva custom dari house_depreciation_standards.
-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[4]::bigint[]
AND effective_date = DATE '2026-06-03';
-- Recompute snapshot depresiasi.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (4);
@@ -0,0 +1,85 @@
-- Kurva depresiasi khusus flock 4 (house_types=open_house, effective_date=2026-06-03).
-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.
-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.
-- Hapus custom curve untuk array ini agar INSERT idempoten.
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[4]::bigint[]
AND house_type = 'open_house'::house_type_enum
AND effective_date = DATE '2026-06-03';
-- house_type: open_house
INSERT INTO house_depreciation_standards
(project_flock_ids, house_type, day, effective_date,
multiplication_percentage, depreciation_percent, standard_week, name)
SELECT
ARRAY[4]::bigint[], g.house_type, g.day, DATE '2026-06-03',
v.mult, (1 - v.mult) * 100, g.standard_week,
'Custom flocks 4 (eff 2026-06-03)'
FROM (VALUES
(1, 0.9958::numeric), (2, 0.9958), (3, 0.9958), (4, 0.9958), (5, 0.9958),
(6, 0.9957), (7, 0.9957), (8, 0.9957), (9, 0.9957), (10, 0.9957),
(11, 0.9956), (12, 0.9956), (13, 0.9956), (14, 0.9956), (15, 0.9956),
(16, 0.9955), (17, 0.9955), (18, 0.9955), (19, 0.9955), (20, 0.9955),
(21, 0.9954), (22, 0.9954), (23, 0.9954), (24, 0.9954), (25, 0.9954),
(26, 0.9953), (27, 0.9953), (28, 0.9953), (29, 0.9953), (30, 0.9952),
(31, 0.9952), (32, 0.9952), (33, 0.9952), (34, 0.9952), (35, 0.9951),
(36, 0.9951), (37, 0.9951), (38, 0.9951), (39, 0.995), (40, 0.995),
(41, 0.995), (42, 0.995), (43, 0.9949), (44, 0.9949), (45, 0.9949),
(46, 0.9949), (47, 0.9948), (48, 0.9948), (49, 0.9948), (50, 0.9947),
(51, 0.9947), (52, 0.9947), (53, 0.9947), (54, 0.9946), (55, 0.9946),
(56, 0.9955), (57, 0.9955), (58, 0.9954), (59, 0.9954), (60, 0.9954),
(61, 0.9954), (62, 0.9954), (63, 0.9944), (64, 0.9944), (65, 0.9943),
(66, 0.9943), (67, 0.9943), (68, 0.9942), (69, 0.9942), (70, 0.9942),
(71, 0.9941), (72, 0.9941), (73, 0.9941), (74, 0.994), (75, 0.994),
(76, 0.994), (77, 0.9939), (78, 0.9939), (79, 0.9938), (80, 0.9938),
(81, 0.9938), (82, 0.9937), (83, 0.9937), (84, 0.9937), (85, 0.9936),
(86, 0.9936), (87, 0.9935), (88, 0.9935), (89, 0.9934), (90, 0.9934),
(91, 0.9945), (92, 0.9944), (93, 0.9944), (94, 0.9944), (95, 0.9943),
(96, 0.9943), (97, 0.9943), (98, 0.9931), (99, 0.993), (100, 0.993),
(101, 0.9929), (102, 0.9929), (103, 0.9928), (104, 0.9928), (105, 0.9939),
(106, 0.9939), (107, 0.9939), (108, 0.9938), (109, 0.9938), (110, 0.9938),
(111, 0.9937), (112, 0.9924), (113, 0.9924), (114, 0.9923), (115, 0.9922),
(116, 0.9922), (117, 0.9921), (118, 0.9921), (119, 0.992), (120, 0.9919),
(121, 0.9919), (122, 0.9918), (123, 0.9917), (124, 0.9917), (125, 0.9916),
(126, 0.9929), (127, 0.9929), (128, 0.9928), (129, 0.9928), (130, 0.9927),
(131, 0.9927), (132, 0.9926), (133, 0.9911), (134, 0.991), (135, 0.9909),
(136, 0.9908), (137, 0.9907), (138, 0.9907), (139, 0.9906), (140, 0.9921),
(141, 0.992), (142, 0.9919), (143, 0.9919), (144, 0.9918), (145, 0.9917),
(146, 0.9917), (147, 0.9916), (148, 0.9915), (149, 0.9915), (150, 0.9914),
(151, 0.9913), (152, 0.9912), (153, 0.9912), (154, 0.9893), (155, 0.9892),
(156, 0.9891), (157, 0.9889), (158, 0.9888), (159, 0.9887), (160, 0.9885),
(161, 0.9903), (162, 0.9903), (163, 0.9902), (164, 0.9901), (165, 0.99),
(166, 0.9899), (167, 0.9898), (168, 0.9896), (169, 0.9895), (170, 0.9894),
(171, 0.9893), (172, 0.9892), (173, 0.9891), (174, 0.989), (175, 0.9888),
(176, 0.9887), (177, 0.9886), (178, 0.9885), (179, 0.9883), (180, 0.9882),
(181, 0.988), (182, 0.9855), (183, 0.9853), (184, 0.985), (185, 0.9848),
(186, 0.9846), (187, 0.9843), (188, 0.9841), (189, 0.9865), (190, 0.9863),
(191, 0.9861), (192, 0.986), (193, 0.9858), (194, 0.9855), (195, 0.9853),
(196, 0.9851), (197, 0.9849), (198, 0.9847), (199, 0.9844), (200, 0.9842),
(201, 0.9839), (202, 0.9837), (203, 0.9834), (204, 0.9831), (205, 0.9828),
(206, 0.9825), (207, 0.9822), (208, 0.9819), (209, 0.9815), (210, 0.9812),
(211, 0.9808), (212, 0.9805), (213, 0.9801), (214, 0.9797), (215, 0.9793),
(216, 0.9788), (217, 0.9784), (218, 0.9779), (219, 0.9774), (220, 0.9769),
(221, 0.9763), (222, 0.9757), (223, 0.9751), (224, 0.9745), (225, 0.9738),
(226, 0.9731), (227, 0.9724), (228, 0.9716), (229, 0.9708), (230, 0.9699),
(231, 0.9752), (232, 0.9745), (233, 0.9739), (234, 0.9732), (235, 0.9724),
(236, 0.9716), (237, 0.9708), (238, 0.9624), (239, 0.9609), (240, 0.9593),
(241, 0.9576), (242, 0.9558), (243, 0.9537), (244, 0.9515), (245, 0.949),
(246, 0.9462), (247, 0.9432), (248, 0.9398), (249, 0.9359), (250, 0.9315),
(251, 0.9265), (252, 0.9206), (253, 0.9138), (254, 0.9057), (255, 0.8958),
(256, 0.8837), (257, 0.8684), (258, 0.8485), (259, 0.8571), (260, 0.8333),
(261, 0.8), (262, 0.75), (263, 0.6667), (264, 0.5), (265, 0)
) AS v(day, mult)
JOIN LATERAL (
SELECT DISTINCT ON (day) house_type, day, standard_week
FROM house_depreciation_standards
WHERE project_flock_ids IS NULL
AND house_type = 'open_house'::house_type_enum
AND day = v.day
ORDER BY day, effective_date DESC NULLS LAST
) g ON TRUE;
-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (4);
@@ -0,0 +1,8 @@
-- Hapus baris kurva custom dari house_depreciation_standards.
-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[5]::bigint[]
AND effective_date = DATE '2026-06-03';
-- Recompute snapshot depresiasi.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (5);
@@ -0,0 +1,118 @@
-- Kurva depresiasi khusus flock 5 (house_types=open_house, effective_date=2026-06-03).
-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.
-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.
-- Hapus custom curve untuk array ini agar INSERT idempoten.
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[5]::bigint[]
AND house_type = 'open_house'::house_type_enum
AND effective_date = DATE '2026-06-03';
-- house_type: open_house
INSERT INTO house_depreciation_standards
(project_flock_ids, house_type, day, effective_date,
multiplication_percentage, depreciation_percent, standard_week, name)
SELECT
ARRAY[5]::bigint[], g.house_type, g.day, DATE '2026-06-03',
v.mult, (1 - v.mult) * 100, g.standard_week,
'Custom flocks 5 (eff 2026-06-03)'
FROM (VALUES
(1, 0.9976::numeric), (2, 0.9972), (3, 0.9972), (4, 0.9972), (5, 0.9972),
(6, 0.9971), (7, 0.9971), (8, 0.9971), (9, 0.9975), (10, 0.9975),
(11, 0.9975), (12, 0.9975), (13, 0.9975), (14, 0.9975), (15, 0.9975),
(16, 0.9971), (17, 0.9971), (18, 0.9971), (19, 0.997), (20, 0.997),
(21, 0.997), (22, 0.997), (23, 0.997), (24, 0.997), (25, 0.997),
(26, 0.997), (27, 0.997), (28, 0.997), (29, 0.997), (30, 0.9974),
(31, 0.9974), (32, 0.9974), (33, 0.9974), (34, 0.9974), (35, 0.9973),
(36, 0.9973), (37, 0.9969), (38, 0.9969), (39, 0.9969), (40, 0.9969),
(41, 0.9968), (42, 0.9968), (43, 0.9968), (44, 0.9973), (45, 0.9973),
(46, 0.9973), (47, 0.9972), (48, 0.9972), (49, 0.9972), (50, 0.9972),
(51, 0.9968), (52, 0.9967), (53, 0.9967), (54, 0.9967), (55, 0.9967),
(56, 0.9967), (57, 0.9967), (58, 0.9972), (59, 0.9971), (60, 0.9971),
(61, 0.9971), (62, 0.9971), (63, 0.9971), (64, 0.9971), (65, 0.9966),
(66, 0.9966), (67, 0.9966), (68, 0.9966), (69, 0.9966), (70, 0.9966),
(71, 0.9965), (72, 0.997), (73, 0.997), (74, 0.997), (75, 0.997),
(76, 0.997), (77, 0.997), (78, 0.997), (79, 0.9965), (80, 0.9964),
(81, 0.9964), (82, 0.9964), (83, 0.9964), (84, 0.9964), (85, 0.9964),
(86, 0.9969), (87, 0.9969), (88, 0.9969), (89, 0.9969), (90, 0.9968),
(91, 0.9968), (92, 0.9968), (93, 0.9968), (94, 0.9968), (95, 0.9968),
(96, 0.9968), (97, 0.9968), (98, 0.9968), (99, 0.9968), (100, 0.9962),
(101, 0.9962), (102, 0.9962), (103, 0.9962), (104, 0.9961), (105, 0.9961),
(106, 0.9961), (107, 0.9967), (108, 0.9966), (109, 0.9966), (110, 0.9966),
(111, 0.9966), (112, 0.9966), (113, 0.9966), (114, 0.9966), (115, 0.9966),
(116, 0.9965), (117, 0.9965), (118, 0.9965), (119, 0.9965), (120, 0.9965),
(121, 0.9959), (122, 0.9959), (123, 0.9959), (124, 0.9959), (125, 0.9958),
(126, 0.9958), (127, 0.9958), (128, 0.9964), (129, 0.9964), (130, 0.9964),
(131, 0.9963), (132, 0.9963), (133, 0.9963), (134, 0.9963), (135, 0.9963),
(136, 0.9963), (137, 0.9963), (138, 0.9962), (139, 0.9962), (140, 0.9962),
(141, 0.9962), (142, 0.9962), (143, 0.9962), (144, 0.9962), (145, 0.9961),
(146, 0.9961), (147, 0.9961), (148, 0.9961), (149, 0.9961), (150, 0.9961),
(151, 0.9961), (152, 0.996), (153, 0.996), (154, 0.996), (155, 0.996),
(156, 0.9953), (157, 0.9953), (158, 0.9953), (159, 0.9952), (160, 0.9952),
(161, 0.9952), (162, 0.9952), (163, 0.9958), (164, 0.9958), (165, 0.9958),
(166, 0.9958), (167, 0.9958), (168, 0.9958), (169, 0.9957), (170, 0.9957),
(171, 0.9957), (172, 0.9957), (173, 0.9957), (174, 0.9956), (175, 0.9956),
(176, 0.9956), (177, 0.9956), (178, 0.9956), (179, 0.9955), (180, 0.9955),
(181, 0.9955), (182, 0.9955), (183, 0.9955), (184, 0.9954), (185, 0.9954),
(186, 0.9954), (187, 0.9954), (188, 0.9954), (189, 0.9953), (190, 0.9953),
(191, 0.9953), (192, 0.9953), (193, 0.9952), (194, 0.9952), (195, 0.9952),
(196, 0.9952), (197, 0.9952), (198, 0.9951), (199, 0.9951), (200, 0.9951),
(201, 0.9951), (202, 0.995), (203, 0.995), (204, 0.995), (205, 0.995),
(206, 0.9949), (207, 0.9949), (208, 0.9949), (209, 0.9949), (210, 0.9948),
(211, 0.9948), (212, 0.9948), (213, 0.9947), (214, 0.9947), (215, 0.9947),
(216, 0.9947), (217, 0.9946), (218, 0.9946), (219, 0.9955), (220, 0.9955),
(221, 0.9954), (222, 0.9954), (223, 0.9954), (224, 0.9954), (225, 0.9954),
(226, 0.9944), (227, 0.9944), (228, 0.9943), (229, 0.9943), (230, 0.9943),
(231, 0.9942), (232, 0.9942), (233, 0.9942), (234, 0.9941), (235, 0.9941),
(236, 0.9941), (237, 0.994), (238, 0.994), (239, 0.994), (240, 0.9939),
(241, 0.9939), (242, 0.9938), (243, 0.9938), (244, 0.9938), (245, 0.9937),
(246, 0.9937), (247, 0.9937), (248, 0.9936), (249, 0.9936), (250, 0.9935),
(251, 0.9935), (252, 0.9934), (253, 0.9934), (254, 0.9945), (255, 0.9944),
(256, 0.9944), (257, 0.9944), (258, 0.9943), (259, 0.9943), (260, 0.9943),
(261, 0.9931), (262, 0.993), (263, 0.993), (264, 0.9929), (265, 0.9929),
(266, 0.9928), (267, 0.9928), (268, 0.9939), (269, 0.9939), (270, 0.9939),
(271, 0.9938), (272, 0.9938), (273, 0.9938), (274, 0.9937), (275, 0.9924),
(276, 0.9924), (277, 0.9923), (278, 0.9922), (279, 0.9922), (280, 0.9921),
(281, 0.9921), (282, 0.992), (283, 0.9919), (284, 0.9919), (285, 0.9918),
(286, 0.9917), (287, 0.9917), (288, 0.9916), (289, 0.9929), (290, 0.9929),
(291, 0.9928), (292, 0.9928), (293, 0.9927), (294, 0.9927), (295, 0.9926),
(296, 0.9911), (297, 0.991), (298, 0.9909), (299, 0.9908), (300, 0.9907),
(301, 0.9907), (302, 0.9906), (303, 0.9921), (304, 0.992), (305, 0.9919),
(306, 0.9919), (307, 0.9918), (308, 0.9917), (309, 0.9917), (310, 0.9916),
(311, 0.9915), (312, 0.9915), (313, 0.9914), (314, 0.9913), (315, 0.9912),
(316, 0.9912), (317, 0.9893), (318, 0.9892), (319, 0.9891), (320, 0.9889),
(321, 0.9888), (322, 0.9887), (323, 0.9885), (324, 0.9903), (325, 0.9903),
(326, 0.9902), (327, 0.9901), (328, 0.99), (329, 0.9899), (330, 0.9898),
(331, 0.9896), (332, 0.9895), (333, 0.9894), (334, 0.9893), (335, 0.9892),
(336, 0.9891), (337, 0.989), (338, 0.9888), (339, 0.9887), (340, 0.9886),
(341, 0.9885), (342, 0.9883), (343, 0.9882), (344, 0.988), (345, 0.9855),
(346, 0.9853), (347, 0.985), (348, 0.9848), (349, 0.9846), (350, 0.9843),
(351, 0.9841), (352, 0.9865), (353, 0.9863), (354, 0.9861), (355, 0.986),
(356, 0.9858), (357, 0.9855), (358, 0.9853), (359, 0.9851), (360, 0.9849),
(361, 0.9847), (362, 0.9844), (363, 0.9842), (364, 0.9839), (365, 0.9837),
(366, 0.9834), (367, 0.9831), (368, 0.9828), (369, 0.9825), (370, 0.9822),
(371, 0.9819), (372, 0.9815), (373, 0.9812), (374, 0.9808), (375, 0.9805),
(376, 0.9801), (377, 0.9797), (378, 0.9793), (379, 0.9788), (380, 0.9784),
(381, 0.9779), (382, 0.9774), (383, 0.9769), (384, 0.9763), (385, 0.9757),
(386, 0.9751), (387, 0.9745), (388, 0.9738), (389, 0.9731), (390, 0.9724),
(391, 0.9716), (392, 0.9708), (393, 0.9699), (394, 0.9752), (395, 0.9745),
(396, 0.9739), (397, 0.9732), (398, 0.9724), (399, 0.9716), (400, 0.9708),
(401, 0.9624), (402, 0.9609), (403, 0.9593), (404, 0.9576), (405, 0.9558),
(406, 0.9537), (407, 0.9515), (408, 0.949), (409, 0.9462), (410, 0.9432),
(411, 0.9398), (412, 0.9359), (413, 0.9315), (414, 0.9265), (415, 0.9206),
(416, 0.9138), (417, 0.9057), (418, 0.8958), (419, 0.8837), (420, 0.8684),
(421, 0.8485), (422, 0.8571), (423, 0.8333), (424, 0.8), (425, 0.75),
(426, 0.6667), (427, 0.5), (428, 0)
) AS v(day, mult)
JOIN LATERAL (
SELECT DISTINCT ON (day) house_type, day, standard_week
FROM house_depreciation_standards
WHERE project_flock_ids IS NULL
AND house_type = 'open_house'::house_type_enum
AND day = v.day
ORDER BY day, effective_date DESC NULLS LAST
) g ON TRUE;
-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (5);
@@ -0,0 +1,8 @@
-- Hapus baris kurva custom dari house_depreciation_standards.
-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[6]::bigint[]
AND effective_date = DATE '2026-06-03';
-- Recompute snapshot depresiasi.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (6);
@@ -0,0 +1,129 @@
-- Kurva depresiasi khusus flock 6 (house_types=open_house, effective_date=2026-06-03).
-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.
-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.
-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.
-- Hapus custom curve untuk array ini agar INSERT idempoten.
DELETE FROM house_depreciation_standards
WHERE project_flock_ids = ARRAY[6]::bigint[]
AND house_type = 'open_house'::house_type_enum
AND effective_date = DATE '2026-06-03';
-- house_type: open_house
INSERT INTO house_depreciation_standards
(project_flock_ids, house_type, day, effective_date,
multiplication_percentage, depreciation_percent, standard_week, name)
SELECT
ARRAY[6]::bigint[], g.house_type, g.day, DATE '2026-06-03',
v.mult, (1 - v.mult) * 100, g.standard_week,
'Custom flocks 6 (eff 2026-06-03)'
FROM (VALUES
(1, 0.9979::numeric), (2, 0.9979), (3, 0.9979), (4, 0.9979), (5, 0.9979),
(6, 0.9975), (7, 0.9975), (8, 0.9975), (9, 0.9975), (10, 0.9975),
(11, 0.9975), (12, 0.9975), (13, 0.9975), (14, 0.9975), (15, 0.9974),
(16, 0.9974), (17, 0.9974), (18, 0.9974), (19, 0.9974), (20, 0.9974),
(21, 0.9974), (22, 0.9974), (23, 0.9974), (24, 0.9974), (25, 0.9974),
(26, 0.9974), (27, 0.9977), (28, 0.9977), (29, 0.9977), (30, 0.9977),
(31, 0.9977), (32, 0.9977), (33, 0.9977), (34, 0.9973), (35, 0.9973),
(36, 0.9973), (37, 0.9973), (38, 0.9973), (39, 0.9973), (40, 0.9973),
(41, 0.9973), (42, 0.9973), (43, 0.9973), (44, 0.9973), (45, 0.9972),
(46, 0.9972), (47, 0.9972), (48, 0.9976), (49, 0.9976), (50, 0.9976),
(51, 0.9976), (52, 0.9976), (53, 0.9976), (54, 0.9976), (55, 0.9972),
(56, 0.9972), (57, 0.9972), (58, 0.9972), (59, 0.9971), (60, 0.9971),
(61, 0.9971), (62, 0.9975), (63, 0.9975), (64, 0.9975), (65, 0.9975),
(66, 0.9975), (67, 0.9975), (68, 0.9975), (69, 0.9971), (70, 0.9971),
(71, 0.9971), (72, 0.997), (73, 0.997), (74, 0.997), (75, 0.997),
(76, 0.997), (77, 0.997), (78, 0.997), (79, 0.997), (80, 0.997),
(81, 0.997), (82, 0.997), (83, 0.9974), (84, 0.9974), (85, 0.9974),
(86, 0.9974), (87, 0.9974), (88, 0.9973), (89, 0.9973), (90, 0.9969),
(91, 0.9969), (92, 0.9969), (93, 0.9969), (94, 0.9968), (95, 0.9968),
(96, 0.9968), (97, 0.9973), (98, 0.9973), (99, 0.9973), (100, 0.9972),
(101, 0.9972), (102, 0.9972), (103, 0.9972), (104, 0.9968), (105, 0.9967),
(106, 0.9967), (107, 0.9967), (108, 0.9967), (109, 0.9967), (110, 0.9967),
(111, 0.9972), (112, 0.9971), (113, 0.9971), (114, 0.9971), (115, 0.9971),
(116, 0.9971), (117, 0.9971), (118, 0.9966), (119, 0.9966), (120, 0.9966),
(121, 0.9966), (122, 0.9966), (123, 0.9966), (124, 0.9965), (125, 0.997),
(126, 0.997), (127, 0.997), (128, 0.997), (129, 0.997), (130, 0.997),
(131, 0.997), (132, 0.9965), (133, 0.9964), (134, 0.9964), (135, 0.9964),
(136, 0.9964), (137, 0.9964), (138, 0.9964), (139, 0.9969), (140, 0.9969),
(141, 0.9969), (142, 0.9969), (143, 0.9968), (144, 0.9968), (145, 0.9968),
(146, 0.9968), (147, 0.9968), (148, 0.9968), (149, 0.9968), (150, 0.9968),
(151, 0.9968), (152, 0.9968), (153, 0.9962), (154, 0.9962), (155, 0.9962),
(156, 0.9962), (157, 0.9961), (158, 0.9961), (159, 0.9961), (160, 0.9967),
(161, 0.9966), (162, 0.9966), (163, 0.9966), (164, 0.9966), (165, 0.9966),
(166, 0.9966), (167, 0.9966), (168, 0.9966), (169, 0.9965), (170, 0.9965),
(171, 0.9965), (172, 0.9965), (173, 0.9965), (174, 0.9959), (175, 0.9959),
(176, 0.9959), (177, 0.9959), (178, 0.9958), (179, 0.9958), (180, 0.9958),
(181, 0.9964), (182, 0.9964), (183, 0.9964), (184, 0.9963), (185, 0.9963),
(186, 0.9963), (187, 0.9963), (188, 0.9963), (189, 0.9963), (190, 0.9963),
(191, 0.9962), (192, 0.9962), (193, 0.9962), (194, 0.9962), (195, 0.9962),
(196, 0.9962), (197, 0.9962), (198, 0.9961), (199, 0.9961), (200, 0.9961),
(201, 0.9961), (202, 0.9961), (203, 0.9961), (204, 0.9961), (205, 0.996),
(206, 0.996), (207, 0.996), (208, 0.996), (209, 0.9953), (210, 0.9953),
(211, 0.9953), (212, 0.9952), (213, 0.9952), (214, 0.9952), (215, 0.9952),
(216, 0.9958), (217, 0.9958), (218, 0.9958), (219, 0.9958), (220, 0.9958),
(221, 0.9958), (222, 0.9957), (223, 0.9957), (224, 0.9957), (225, 0.9957),
(226, 0.9957), (227, 0.9956), (228, 0.9956), (229, 0.9956), (230, 0.9956),
(231, 0.9956), (232, 0.9955), (233, 0.9955), (234, 0.9955), (235, 0.9955),
(236, 0.9955), (237, 0.9954), (238, 0.9954), (239, 0.9954), (240, 0.9954),
(241, 0.9954), (242, 0.9953), (243, 0.9953), (244, 0.9953), (245, 0.9953),
(246, 0.9952), (247, 0.9952), (248, 0.9952), (249, 0.9952), (250, 0.9952),
(251, 0.9951), (252, 0.9951), (253, 0.9951), (254, 0.9951), (255, 0.995),
(256, 0.995), (257, 0.995), (258, 0.995), (259, 0.9949), (260, 0.9949),
(261, 0.9949), (262, 0.9949), (263, 0.9948), (264, 0.9948), (265, 0.9948),
(266, 0.9947), (267, 0.9947), (268, 0.9947), (269, 0.9947), (270, 0.9946),
(271, 0.9946), (272, 0.9955), (273, 0.9955), (274, 0.9954), (275, 0.9954),
(276, 0.9954), (277, 0.9954), (278, 0.9954), (279, 0.9944), (280, 0.9944),
(281, 0.9943), (282, 0.9943), (283, 0.9943), (284, 0.9942), (285, 0.9942),
(286, 0.9942), (287, 0.9941), (288, 0.9941), (289, 0.9941), (290, 0.994),
(291, 0.994), (292, 0.994), (293, 0.9939), (294, 0.9939), (295, 0.9938),
(296, 0.9938), (297, 0.9938), (298, 0.9937), (299, 0.9937), (300, 0.9937),
(301, 0.9936), (302, 0.9936), (303, 0.9935), (304, 0.9935), (305, 0.9934),
(306, 0.9934), (307, 0.9945), (308, 0.9944), (309, 0.9944), (310, 0.9944),
(311, 0.9943), (312, 0.9943), (313, 0.9943), (314, 0.9931), (315, 0.993),
(316, 0.993), (317, 0.9929), (318, 0.9929), (319, 0.9928), (320, 0.9928),
(321, 0.9939), (322, 0.9939), (323, 0.9939), (324, 0.9938), (325, 0.9938),
(326, 0.9938), (327, 0.9937), (328, 0.9924), (329, 0.9924), (330, 0.9923),
(331, 0.9922), (332, 0.9922), (333, 0.9921), (334, 0.9921), (335, 0.992),
(336, 0.9919), (337, 0.9919), (338, 0.9918), (339, 0.9917), (340, 0.9917),
(341, 0.9916), (342, 0.9929), (343, 0.9929), (344, 0.9928), (345, 0.9928),
(346, 0.9927), (347, 0.9927), (348, 0.9926), (349, 0.9911), (350, 0.991),
(351, 0.9909), (352, 0.9908), (353, 0.9907), (354, 0.9907), (355, 0.9906),
(356, 0.9921), (357, 0.992), (358, 0.9919), (359, 0.9919), (360, 0.9918),
(361, 0.9917), (362, 0.9917), (363, 0.9916), (364, 0.9915), (365, 0.9915),
(366, 0.9914), (367, 0.9913), (368, 0.9912), (369, 0.9912), (370, 0.9893),
(371, 0.9892), (372, 0.9891), (373, 0.9889), (374, 0.9888), (375, 0.9887),
(376, 0.9885), (377, 0.9903), (378, 0.9903), (379, 0.9902), (380, 0.9901),
(381, 0.99), (382, 0.9899), (383, 0.9898), (384, 0.9896), (385, 0.9895),
(386, 0.9894), (387, 0.9893), (388, 0.9892), (389, 0.9891), (390, 0.989),
(391, 0.9888), (392, 0.9887), (393, 0.9886), (394, 0.9885), (395, 0.9883),
(396, 0.9882), (397, 0.988), (398, 0.9855), (399, 0.9853), (400, 0.985),
(401, 0.9848), (402, 0.9846), (403, 0.9843), (404, 0.9841), (405, 0.9865),
(406, 0.9863), (407, 0.9861), (408, 0.986), (409, 0.9858), (410, 0.9855),
(411, 0.9853), (412, 0.9851), (413, 0.9849), (414, 0.9847), (415, 0.9844),
(416, 0.9842), (417, 0.9839), (418, 0.9837), (419, 0.9834), (420, 0.9831),
(421, 0.9828), (422, 0.9825), (423, 0.9822), (424, 0.9819), (425, 0.9815),
(426, 0.9812), (427, 0.9808), (428, 0.9805), (429, 0.9801), (430, 0.9797),
(431, 0.9793), (432, 0.9788), (433, 0.9784), (434, 0.9779), (435, 0.9774),
(436, 0.9769), (437, 0.9763), (438, 0.9757), (439, 0.9751), (440, 0.9745),
(441, 0.9738), (442, 0.9731), (443, 0.9724), (444, 0.9716), (445, 0.9708),
(446, 0.9699), (447, 0.9752), (448, 0.9745), (449, 0.9739), (450, 0.9732),
(451, 0.9724), (452, 0.9716), (453, 0.9708), (454, 0.9624), (455, 0.9609),
(456, 0.9593), (457, 0.9576), (458, 0.9558), (459, 0.9537), (460, 0.9515),
(461, 0.949), (462, 0.9462), (463, 0.9432), (464, 0.9398), (465, 0.9359),
(466, 0.9315), (467, 0.9265), (468, 0.9206), (469, 0.9138), (470, 0.9057),
(471, 0.8958), (472, 0.8837), (473, 0.8684), (474, 0.8485), (475, 0.8571),
(476, 0.8333), (477, 0.8), (478, 0.75), (479, 0.6667), (480, 0.5),
(481, 0)
) AS v(day, mult)
JOIN LATERAL (
SELECT DISTINCT ON (day) house_type, day, standard_week
FROM house_depreciation_standards
WHERE project_flock_ids IS NULL
AND house_type = 'open_house'::house_type_enum
AND day = v.day
ORDER BY day, effective_date DESC NULLS LAST
) g ON TRUE;
-- Recompute snapshot depresiasi untuk semua flock yang dipetakan.
DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (6);
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE daily_checklist_empty_kandangs
DROP CONSTRAINT IF EXISTS fk_dcek_kandang;
ALTER TABLE daily_checklist_empty_kandangs
ADD CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs (id) ON DELETE CASCADE;
COMMIT;
@@ -0,0 +1,23 @@
BEGIN;
ALTER TABLE daily_checklist_empty_kandangs
DROP CONSTRAINT IF EXISTS fk_dcek_kandang;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM daily_checklist_empty_kandangs dcek
LEFT JOIN kandang_groups kg ON kg.id = dcek.kandang_id
WHERE kg.id IS NULL
AND dcek.deleted_at IS NULL
) THEN
RAISE EXCEPTION 'Cannot fix FK: some kandang_id values do not exist in kandang_groups';
END IF;
END $$;
ALTER TABLE daily_checklist_empty_kandangs
ADD CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandang_groups (id) ON DELETE CASCADE;
COMMIT;
@@ -0,0 +1,25 @@
BEGIN;
-- Rollback: re-insert TELUR/TELUR_GRADE block rules yang dihapus oleh migration ini.
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'TELUR', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR'
AND reason = 'fifo_v2_exception_marketing_block_telur'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'TELUR_GRADE', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur_grade', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR_GRADE'
AND reason = 'fifo_v2_exception_marketing_block_telur_grade'
);
COMMIT;
@@ -0,0 +1,17 @@
BEGIN;
-- Revert rules yang ditambahkan oleh migration 20260603031237_block_marketing_overconsume_telur.
-- TELUR/TELUR_GRADE kembali fallback ke default allow rule (allow_overconsume=TRUE)
-- karena validasi stok sekarang ditangani di service layer (code validation) bukan lewat
-- config overconsume FIFO v2.
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IN ('TELUR', 'TELUR_GRADE')
AND reason IN (
'fifo_v2_exception_marketing_block_telur',
'fifo_v2_exception_marketing_block_telur_grade'
);
COMMIT;
@@ -72,8 +72,12 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
MarketingId: uint(c.QueryInt("marketing_id", 0)), MarketingId: uint(c.QueryInt("marketing_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)), ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)), ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
WarehouseID: uint(c.QueryInt("warehouse_id", 0)),
SortBy: sortBy, SortBy: sortBy,
SortOrder: sortOrder, SortOrder: sortOrder,
StartDate: strings.TrimSpace(c.Query("start_date", "")),
EndDate: strings.TrimSpace(c.Query("end_date", "")),
FilterBy: strings.TrimSpace(c.Query("filter_by", "")),
} }
if isAllExcelExportRequest(c) { if isAllExcelExportRequest(c) {
@@ -70,23 +70,26 @@ func buildMarketingExportWorkbook(items []dto.MarketingListDTO) ([]byte, error)
} }
func setMarketingExportColumns(file *excelize.File, sheet string) error { func setMarketingExportColumns(file *excelize.File, sheet string) error {
// AQ = 17 columns
// E = Sales (new), H = Gudang (new), Satuan (old I) removed
columnWidths := map[string]float64{ columnWidths := map[string]float64{
"A": 16, "A": 16, // No. Order
"B": 14, "B": 14, // Tanggal
"C": 18, "C": 18, // Status
"D": 20, "D": 20, // Customer
"E": 14, "E": 20, // Sales (new)
"F": 40, "F": 14, // Tipe
"G": 10, "G": 40, // Nama Produk
"H": 12, "H": 20, // Gudang (new)
"I": 12, "I": 10, // Week
"J": 12, "J": 12, // Jumlah
"K": 16, "K": 12, // Qty Peti
"L": 16, "L": 16, // Berat Rata-rata (kg)
"M": 18, "M": 16, // Total Berat (kg)
"N": 18, "N": 18, // Harga Satuan
"O": 18, "O": 18, // Total Harga
"P": 24, "P": 18, // Grand Total
"Q": 24, // Catatan
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -108,18 +111,19 @@ func setMarketingExportHeaders(file *excelize.File, sheet string) error {
"Tanggal", // B "Tanggal", // B
"Status", // C "Status", // C
"Customer", // D "Customer", // D
"Tipe", // E "Sales", // E (new)
"Nama Produk", // F "Tipe", // F
"Week", // G "Nama Produk", // G
"Jumlah", // H "Gudang", // H (new)
"Satuan", // I "Week", // I
"Qty Peti", // J "Jumlah Butir", // J
"Berat Rata-rata (kg)", // K "Qty Peti", // K
"Total Berat (kg)", // L "Berat Rata-rata (kg)", // L
"Harga Satuan", // M "Total Berat (kg)", // M
"Total Harga", // N "Harga Satuan", // N
"Grand Total", // O "Total Harga", // O
"Catatan", // P "Grand Total", // P
"Catatan", // Q
} }
for i, header := range headers { for i, header := range headers {
@@ -148,7 +152,7 @@ func setMarketingExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "P1", headerStyle) return file.SetCellStyle(sheet, "A1", "Q1", headerStyle)
} }
func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error { func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error {
@@ -162,17 +166,156 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
soDate := formatMarketingExportDate(item.SoDate) soDate := formatMarketingExportDate(item.SoDate)
status := formatMarketingExportStatus(item) status := formatMarketingExportStatus(item)
customer := safeMarketingExportText(item.Customer.Name) customer := safeMarketingExportText(item.Customer.Name)
grandTotal := sumMarketingGrandTotal(item.SalesOrder)
notes := safeMarketingExportText(item.Notes) notes := safeMarketingExportText(item.Notes)
salesPerson := safeMarketingExportText(item.SalesPerson.Name)
isDeliveryOrder := strings.EqualFold(strings.TrimSpace(status), "delivery order")
// ── Delivery Order branch ──────────────────────────────────────────────
if isDeliveryOrder {
grandTotal := sumDeliveryGrandTotal(item.DeliveryOrder)
if len(item.DeliveryOrder) == 0 {
row++
r := strconv.Itoa(row)
vals := map[string]interface{}{
"A": soNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson,
"F": "-", "G": "-", "H": "-", "I": "-", "J": "-", "K": "-",
"L": "-", "M": "-", "N": "-", "O": "-",
"P": grandTotal, "Q": notes,
}
for col, val := range vals {
if err := file.SetCellValue(sheet, col+r, val); err != nil {
return err
}
}
continue
}
// Build lookup map: MarketingProductId → SO product (for Week & MarketingType)
soProductMap := make(map[uint]*dto.DeliveryMarketingProductDTO, len(item.SalesOrder))
for i := range item.SalesOrder {
soProductMap[item.SalesOrder[i].Id] = &item.SalesOrder[i]
}
for _, group := range item.DeliveryOrder {
doNumber := safeMarketingExportText(group.DoNumber)
gudang := "-"
if group.Warehouse != nil {
gudang = safeMarketingExportText(group.Warehouse.Name)
}
if len(group.Deliveries) == 0 {
row++
r := strconv.Itoa(row)
vals := map[string]interface{}{
"A": doNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson,
"F": "-", "G": "-", "H": gudang, "I": "-", "J": "-", "K": "-",
"L": "-", "M": "-", "N": "-", "O": "-",
"P": grandTotal, "Q": notes,
}
for col, val := range vals {
if err := file.SetCellValue(sheet, col+r, val); err != nil {
return err
}
}
continue
}
for _, delivery := range group.Deliveries {
row++
r := strconv.Itoa(row)
productName := "-"
if delivery.ProductWarehouse != nil && delivery.ProductWarehouse.Product != nil {
if n := strings.TrimSpace(delivery.ProductWarehouse.Product.Name); n != "" {
productName = n
}
}
week := "-"
marketingType := "-"
if soProduct, ok := soProductMap[delivery.MarketingProductId]; ok {
if soProduct.Week != nil {
week = strconv.Itoa(*soProduct.Week)
}
marketingType = safeMarketingExportText(soProduct.MarketingType)
}
if err := file.SetCellValue(sheet, "A"+r, doNumber); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+r, soDate); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+r, status); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+r, customer); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+r, salesPerson); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+r, marketingType); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+r, productName); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+r, gudang); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+r, week); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+r, delivery.Qty); err != nil {
return err
}
if delivery.TotalPeti != nil {
if err := file.SetCellValue(sheet, "K"+r, *delivery.TotalPeti); err != nil {
return err
}
} else {
if err := file.SetCellValue(sheet, "K"+r, "-"); err != nil {
return err
}
}
if err := file.SetCellValue(sheet, "L"+r, delivery.AvgWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+r, delivery.TotalWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+r, delivery.UnitPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+r, delivery.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+r, grandTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+r, notes); err != nil {
return err
}
}
}
continue
}
// ── Sales Order branch (all other statuses) ───────────────────────────
grandTotal := sumMarketingGrandTotal(item.SalesOrder)
if len(item.SalesOrder) == 0 { if len(item.SalesOrder) == 0 {
row++ row++
r := strconv.Itoa(row) r := strconv.Itoa(row)
vals := map[string]interface{}{ vals := map[string]interface{}{
"A": soNumber, "B": soDate, "C": status, "D": customer, "A": soNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson,
"E": "-", "F": "-", "G": "-", "H": "-", "I": "-", "J": "-", "F": "-", "G": "-", "H": "-", "I": "-", "J": "-", "K": "-",
"K": "-", "L": "-", "M": "-", "N": "-", "L": "-", "M": "-", "N": "-", "O": "-",
"O": grandTotal, "P": notes, "P": grandTotal, "Q": notes,
} }
for col, val := range vals { for col, val := range vals {
if err := file.SetCellValue(sheet, col+r, val); err != nil { if err := file.SetCellValue(sheet, col+r, val); err != nil {
@@ -198,9 +341,9 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
week = strconv.Itoa(*prod.Week) week = strconv.Itoa(*prod.Week)
} }
satuan := "-" gudang := "-"
if prod.ConvertionUnit != nil && strings.TrimSpace(*prod.ConvertionUnit) != "" { if prod.ProductWarehouse != nil && prod.ProductWarehouse.Warehouse != nil {
satuan = *prod.ConvertionUnit gudang = safeMarketingExportText(prod.ProductWarehouse.Warehouse.Name)
} }
if err := file.SetCellValue(sheet, "A"+r, soNumber); err != nil { if err := file.SetCellValue(sheet, "A"+r, soNumber); err != nil {
@@ -215,46 +358,49 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err := file.SetCellValue(sheet, "D"+r, customer); err != nil { if err := file.SetCellValue(sheet, "D"+r, customer); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "E"+r, safeMarketingExportText(prod.MarketingType)); err != nil { if err := file.SetCellValue(sheet, "E"+r, salesPerson); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "F"+r, productName); err != nil { if err := file.SetCellValue(sheet, "F"+r, safeMarketingExportText(prod.MarketingType)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "G"+r, week); err != nil { if err := file.SetCellValue(sheet, "G"+r, productName); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "H"+r, prod.Qty); err != nil { if err := file.SetCellValue(sheet, "H"+r, gudang); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "I"+r, satuan); err != nil { if err := file.SetCellValue(sheet, "I"+r, week); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+r, prod.Qty); err != nil {
return err return err
} }
if prod.TotalPeti != nil { if prod.TotalPeti != nil {
if err := file.SetCellValue(sheet, "J"+r, *prod.TotalPeti); err != nil { if err := file.SetCellValue(sheet, "K"+r, *prod.TotalPeti); err != nil {
return err return err
} }
} else { } else {
if err := file.SetCellValue(sheet, "J"+r, "-"); err != nil { if err := file.SetCellValue(sheet, "K"+r, "-"); err != nil {
return err return err
} }
} }
if err := file.SetCellValue(sheet, "K"+r, prod.AvgWeight); err != nil { if err := file.SetCellValue(sheet, "L"+r, prod.AvgWeight); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "L"+r, prod.TotalWeight); err != nil { if err := file.SetCellValue(sheet, "M"+r, prod.TotalWeight); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "M"+r, prod.UnitPrice); err != nil { if err := file.SetCellValue(sheet, "N"+r, prod.UnitPrice); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "N"+r, prod.TotalPrice); err != nil { if err := file.SetCellValue(sheet, "O"+r, prod.TotalPrice); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "O"+r, grandTotal); err != nil { if err := file.SetCellValue(sheet, "P"+r, grandTotal); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "P"+r, notes); err != nil { if err := file.SetCellValue(sheet, "Q"+r, notes); err != nil {
return err return err
} }
} }
@@ -276,7 +422,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A2", "P"+lastRowStr, dataStyle); err != nil { if err := file.SetCellStyle(sheet, "A2", "Q"+lastRowStr, dataStyle); err != nil {
return err return err
} }
@@ -287,7 +433,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "K2", "O"+lastRowStr, numberStyle); err != nil { if err := file.SetCellStyle(sheet, "L2", "P"+lastRowStr, numberStyle); err != nil {
return err return err
} }
@@ -298,7 +444,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err != nil { if err != nil {
return err return err
} }
for _, col := range []string{"G", "H", "J"} { for _, col := range []string{"I", "J", "K"} {
if err := file.SetCellStyle(sheet, col+"2", col+lastRowStr, centerStyle); err != nil { if err := file.SetCellStyle(sheet, col+"2", col+lastRowStr, centerStyle); err != nil {
return err return err
} }
@@ -327,16 +473,23 @@ func formatMarketingExportStatus(item dto.MarketingListDTO) string {
return safeMarketingExportText(item.LatestApproval.StepName) return safeMarketingExportText(item.LatestApproval.StepName)
} }
func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 { func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
total := 0.0 total := 0.0
for _, item := range items { for _, item := range items {
total += item.TotalPrice total += item.TotalPrice
} }
return total return total
} }
func sumDeliveryGrandTotal(groups []dto.DeliveryGroupDTO) float64 {
total := 0.0
for _, g := range groups {
for _, d := range g.Deliveries {
total += d.TotalPrice
}
}
return total
}
func safeMarketingExportText(value string) string { func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
@@ -15,6 +15,10 @@ import (
) )
func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) { func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
// DO item has soDate=2026-05-31 and deliveryDate=2026-06-01 to verify
// the export uses soDate (not deliveryDate) in column B.
deliveryDate := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
items := []dto.MarketingListDTO{ items := []dto.MarketingListDTO{
{ {
MarketingRelationDTO: dto.MarketingRelationDTO{ MarketingRelationDTO: dto.MarketingRelationDTO{
@@ -51,6 +55,22 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
Action: strPtr("REJECTED"), Action: strPtr("REJECTED"),
}, },
}, },
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00760",
SoDate: time.Date(2026, time.May, 31, 0, 0, 0, 0, time.UTC),
},
Customer: customerDTO.CustomerRelationDTO{Name: "CORDELA"},
DeliveryOrder: []dto.DeliveryGroupDTO{
{
DoNumber: "DO-01954",
DeliveryDate: &deliveryDate,
},
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Delivery Order",
},
},
} }
content, err := buildMarketingExportWorkbook(items) content, err := buildMarketingExportWorkbook(items)
@@ -69,9 +89,10 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
"B1": "Tanggal", "B1": "Tanggal",
"C1": "Status", "C1": "Status",
"D1": "Customer", "D1": "Customer",
"E1": "Grand Total", "E1": "Sales",
"F1": "Products", "G1": "Nama Produk",
"G1": "Notes", "P1": "Grand Total",
"Q1": "Catatan",
} }
for cell, expected := range expectedHeaders { for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(marketingExportSheetName, cell) got, err := file.GetCellValue(marketingExportSheetName, cell)
@@ -83,19 +104,25 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
} }
} }
// SO-00762: 3 products → rows 2, 3, 4
assertCellEquals(t, file, "A2", "SO-00762") assertCellEquals(t, file, "A2", "SO-00762")
assertCellEquals(t, file, "B2", "22-04-2026") assertCellEquals(t, file, "B2", "22-04-2026")
assertCellEquals(t, file, "C2", "Pengajuan") assertCellEquals(t, file, "C2", "Pengajuan")
assertCellEquals(t, file, "D2", "AJAT") assertCellEquals(t, file, "D2", "AJAT")
assertCellEquals(t, file, "E2", "Rp 5.206.200.000") assertCellEquals(t, file, "G2", "PAKAN GROWING CRUMBLE 8603 MALINDO")
assertCellEquals(t, file, "F2", "PAKAN GROWING CRUMBLE 8603 MALINDO, 295 GOLD PELLET") assertCellEquals(t, file, "Q2", "tes")
assertCellEquals(t, file, "G2", "tes")
assertCellEquals(t, file, "A3", "SO-00761") // SO-00761 (rejected): 1 product → row 5
assertCellEquals(t, file, "C3", "Ditolak") assertCellEquals(t, file, "A5", "SO-00761")
assertCellEquals(t, file, "E3", "Rp 75.000") assertCellEquals(t, file, "C5", "Ditolak")
assertCellEquals(t, file, "F3", "HS30 FOAM @20 LITER") assertCellEquals(t, file, "G5", "HS30 FOAM @20 LITER")
assertCellEquals(t, file, "G3", "-") assertCellEquals(t, file, "Q5", "-")
// DO-01954: column B must use soDate (31-05-2026), not deliveryDate (01-06-2026)
assertCellEquals(t, file, "A6", "DO-01954")
assertCellEquals(t, file, "B6", "31-05-2026")
assertCellEquals(t, file, "C6", "Delivery Order")
assertCellEquals(t, file, "D6", "CORDELA")
} }
func assertCellEquals(t *testing.T, file *excelize.File, cell, expected string) { func assertCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -28,6 +28,8 @@ type MarketingListDTO struct {
Customer customerDTO.CustomerRelationDTO `json:"customer"` Customer customerDTO.CustomerRelationDTO `json:"customer"`
SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"`
SoDocs string `json:"so_docs"` SoDocs string `json:"so_docs"`
GrandTotalSO float64 `json:"grand_total_so"`
GrandTotalDO float64 `json:"grand_total_do"`
SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"`
DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"` DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedUser userDTO.UserRelationDTO `json:"created_user"`
@@ -198,11 +200,21 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
} }
} }
var grandTotalSO, grandTotalDO float64
for _, p := range marketing.Products {
grandTotalSO += p.TotalPrice
if p.DeliveryProduct != nil && p.DeliveryProduct.DeliveryDate != nil {
grandTotalDO += p.DeliveryProduct.TotalPrice
}
}
return MarketingListDTO{ return MarketingListDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing), MarketingRelationDTO: ToMarketingRelationDTO(marketing),
Customer: customer, Customer: customer,
SalesPerson: salesPerson, SalesPerson: salesPerson,
SoDocs: marketing.SoDocs, SoDocs: marketing.SoDocs,
GrandTotalSO: grandTotalSO,
GrandTotalDO: grandTotalDO,
SalesOrder: salesOrderProducts, SalesOrder: salesOrderProducts,
DeliveryOrder: extractDeliveryGroupsFromProducts(marketing), DeliveryOrder: extractDeliveryGroupsFromProducts(marketing),
CreatedUser: createdUser, CreatedUser: createdUser,
@@ -287,6 +287,16 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
db = db.Where("marketings.customer_id = ?", params.CustomerId) db = db.Where("marketings.customer_id = ?", params.CustomerId)
} }
if params.WarehouseID != 0 {
db = db.Where(`EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
WHERE mp.marketing_id = marketings.id
AND pw.warehouse_id = ?
)`, params.WarehouseID)
}
db = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID) db = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID)
db = s.applyMarketingSearchFilter(c.Context(), db, params.Search) db = s.applyMarketingSearchFilter(c.Context(), db, params.Search)
@@ -311,6 +321,21 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return db.Where("id = ?", params.MarketingId) return db.Where("id = ?", params.MarketingId)
} }
dateStart, dateEnd, dateErr := utils.ParseDateRangeForQuery(params.StartDate, params.EndDate)
if dateErr != nil {
return db.Where("1 = 0")
}
dateCol := "marketings.so_date"
if strings.TrimSpace(params.FilterBy) == "created_at" {
dateCol = "marketings.created_at"
}
if dateStart != nil {
db = db.Where(dateCol+" >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where(dateCol+" < ?", *dateEnd)
}
orderDir := "DESC" orderDir := "DESC"
if params.SortOrder != "" { if params.SortOrder != "" {
orderDir = strings.ToUpper(params.SortOrder) orderDir = strings.ToUpper(params.SortOrder)
@@ -947,6 +972,19 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
} }
if requestedQty > 0 {
available, err := s.checkAvailableStockQty(ctx, tx, marketingProduct.ProductWarehouseId)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa ketersediaan stok")
}
if requestedQty > available {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf(
"Stok tidak mencukupi: dibutuhkan %g, tersedia %g",
requestedQty, available,
))
}
}
if err := reflowMarketingScope( if err := reflowMarketingScope(
ctx, ctx,
s.FifoStockV2Svc, s.FifoStockV2Svc,
@@ -954,7 +992,10 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
marketingProduct.ProductWarehouseId, marketingProduct.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil { ); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) if errors.Is(err, fifoV2.ErrInsufficientStock) {
return fiber.NewError(fiber.StatusBadRequest, "Stok tidak mencukupi untuk memenuhi permintaan delivery order ini")
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal mengalokasikan stok: %v", err))
} }
refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil) refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil)
@@ -1477,3 +1518,28 @@ func uniqueUintIDs(ids []uint) []uint {
} }
return result return result
} }
// checkAvailableStockQty returns the net available qty for a product warehouse:
// gross qty (product_warehouses.qty) minus the sum of active CONSUME allocations
// in stock_allocations. This gives the true available stock accounting for all
// other delivery orders that have already consumed from the same warehouse.
func (s deliveryOrdersService) checkAvailableStockQty(ctx context.Context, tx *gorm.DB, productWarehouseId uint) (float64, error) {
var pw entity.ProductWarehouse
if err := tx.WithContext(ctx).Select("qty").First(&pw, productWarehouseId).Error; err != nil {
return 0, err
}
var usedQty float64
if err := tx.WithContext(ctx).Raw(`
SELECT COALESCE(SUM(qty), 0)
FROM stock_allocations
WHERE stockable_type = 'product_warehouses'
AND stockable_id = ?
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
`, productWarehouseId).Scan(&usedQty).Error; err != nil {
return 0, err
}
return pw.Quantity - usedQty, nil
}
@@ -31,8 +31,12 @@ type DeliveryOrderQuery struct {
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
WarehouseID uint `query:"warehouse_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date created_at"`
} }
type DeliveryOrderApprove struct { type DeliveryOrderApprove struct {
@@ -172,6 +172,23 @@ func (u *ChickinController) DeleteOne(c *fiber.Ctx) error {
}) })
} }
func (u *ChickinController) UpdateChickInDate(c *fiber.Ctx) error {
req := new(validation.UpdateChickInDate)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if err := u.ChickinService.UpdateChickInDate(c, req); err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Chick in date berhasil diperbarui",
})
}
func (u *ChickinController) Approval(c *fiber.Ctx) error { func (u *ChickinController) Approval(c *fiber.Ctx) error {
req := new(validation.Approve) req := new(validation.Approve)
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -18,6 +19,7 @@ type ProjectChickinRepository interface {
GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error)
GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error)
UpdateUsageFields(ctx context.Context, tx *gorm.DB, chickinID uint, usageQty, pendingUsageQty float64) error UpdateUsageFields(ctx context.Context, tx *gorm.DB, chickinID uint, usageQty, pendingUsageQty float64) error
UpdateChickInDateByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, pfkID uint, newDate time.Time) error
} }
type ChickinRepositoryImpl struct { type ChickinRepositoryImpl struct {
@@ -134,3 +136,10 @@ func (r *ChickinRepositoryImpl) UpdateUsageFields(ctx context.Context, tx *gorm.
"pending_usage_qty": pendingUsageQty, "pending_usage_qty": pendingUsageQty,
}).Error }).Error
} }
func (r *ChickinRepositoryImpl) UpdateChickInDateByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, pfkID uint, newDate time.Time) error {
return tx.WithContext(ctx).
Model(&entity.ProjectChickin{}).
Where("project_flock_kandang_id = ? AND deleted_at IS NULL", pfkID).
Update("chick_in_date", newDate).Error
}
@@ -17,6 +17,7 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService
route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne)
route.Patch("/chick-in-date", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.UpdateChickInDate)
route.Get("/:id", m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne)
// route.Patch("/:id", ctrl.UpdateOne) // route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", ctrl.DeleteOne)
@@ -48,6 +48,7 @@ type ChickinService interface {
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error)
EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error
UpdateChickInDate(ctx *fiber.Ctx, req *validation.UpdateChickInDate) error
} }
type chickinService struct { type chickinService struct {
@@ -2110,3 +2111,38 @@ func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKan
return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording")
} }
func (s chickinService) UpdateChickInDate(ctx *fiber.Ctx, req *validation.UpdateChickInDate) error {
if err := s.Validate.Struct(req); err != nil {
return err
}
newDate, err := time.Parse("2006-01-02", req.ChickInDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Format tanggal tidak valid, gunakan YYYY-MM-DD")
}
_, err = s.ProjectflockKandangRepo.GetByID(ctx.Context(), req.ProjectFlockKandangId)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, "Project flock kandang tidak ditemukan")
}
if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.Repository.UpdateChickInDateByProjectFlockKandangID(ctx.Context(), tx, req.ProjectFlockKandangId, newDate); err != nil {
return err
}
return tx.Exec(`
UPDATE recordings
SET day = GREATEST(0, (record_datetime::date - ?::date)::int),
updated_at = NOW()
WHERE project_flock_kandangs_id = ?
AND deleted_at IS NULL
`, req.ChickInDate, req.ProjectFlockKandangId).Error
}); err != nil {
return err
}
s.invalidateDepreciationSnapshots(ctx.Context(), nil, []uint{req.ProjectFlockKandangId}, newDate)
return nil
}
@@ -27,3 +27,8 @@ type Approve struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
} }
type UpdateChickInDate struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,gt=0"`
ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"`
}
@@ -8,10 +8,12 @@ import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
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/response" "gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -75,6 +77,43 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
} }
listDTO := dto.ToRecordingListDTOs(result) listDTO := dto.ToRecordingListDTOs(result)
recordingIDs := make([]uint, 0, len(result))
for i := range result {
if result[i].Id != 0 {
recordingIDs = append(recordingIDs, result[i].Id)
}
}
if len(recordingIDs) > 0 {
eggs, err := u.RecordingService.GetEggsWithFlagsByRecordingIDs(c.Context(), recordingIDs)
if err != nil {
return err
}
eggByRecording := make(map[uint][]entity.RecordingEgg, len(recordingIDs))
for _, egg := range eggs {
eggByRecording[egg.RecordingId] = append(eggByRecording[egg.RecordingId], egg)
}
for i := range listDTO {
id := listDTO[i].Id
if eggList, ok := eggByRecording[id]; ok {
breakdown := make(map[string]dto.EggExportBreakdownDTO)
for _, egg := range eggList {
flagName := eggTypeFromProductName(egg.ProductWarehouse.Product.Name)
if flagName == "" {
continue
}
entry := breakdown[flagName]
entry.Qty += egg.Qty
if egg.Weight != nil {
entry.Kg += *egg.Weight
}
breakdown[flagName] = entry
}
listDTO[i].EggExportBreakdown = breakdown
}
}
}
if strings.EqualFold(exportType, "excel") { if strings.EqualFold(exportType, "excel") {
return exportRecordingListExcel(c, listDTO) return exportRecordingListExcel(c, listDTO)
} }
@@ -94,6 +133,33 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
}) })
} }
// eggTypeFromProductName maps product name to egg type flag name by keyword matching.
// Falls back to empty string if no keyword matches.
func eggTypeFromProductName(name string) string {
normalized := strings.ToLower(strings.TrimSpace(name))
if normalized == "" {
return ""
}
// Ordered longest-first to prefer "papacal" over partial match of "pacal", etc.
keywords := []struct {
keyword string
flag string
}{
{"papacal", string(utils.FlagTelurPapacal)},
{"jumbo", string(utils.FlagTelurJumbo)},
{"retak", string(utils.FlagTelurRetak)},
{"putih", string(utils.FlagTelurPutih)},
{"pecah", string(utils.FlagTelurPecah)},
{"utuh", string(utils.FlagTelurUtuh)},
}
for _, k := range keywords {
if strings.Contains(normalized, k.keyword) {
return k.flag
}
}
return ""
}
func (u *RecordingController) GetOne(c *fiber.Ctx) error { func (u *RecordingController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -8,6 +8,7 @@ import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
@@ -79,6 +80,18 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error {
"AB": 18, "AB": 18,
"AC": 24, "AC": 24,
"AD": 18, "AD": 18,
"AE": 12,
"AF": 10,
"AG": 12,
"AH": 10,
"AI": 12,
"AJ": 10,
"AK": 12,
"AL": 10,
"AM": 12,
"AN": 10,
"AO": 12,
"AP": 10,
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -208,6 +221,31 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
eggTypes := []struct {
col1, col2, label string
}{
{"AE", "AF", "Telur Utuh"},
{"AG", "AH", "Telur Pecah"},
{"AI", "AJ", "Telur Putih"},
{"AK", "AL", "Telur Retak"},
{"AM", "AN", "Telur Papacal"},
{"AO", "AP", "Telur Jumbo"},
}
for _, et := range eggTypes {
if err := file.MergeCell(sheet, et.col1+"1", et.col2+"1"); err != nil {
return err
}
if err := file.SetCellValue(sheet, et.col1+"1", et.label); err != nil {
return err
}
if err := file.SetCellValue(sheet, et.col1+"2", "Butir"); err != nil {
return err
}
if err := file.SetCellValue(sheet, et.col2+"2", "Kg"); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{ headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{ Font: &excelize.Font{
Bold: true, Bold: true,
@@ -234,7 +272,7 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "AD2", headerStyle) return file.SetCellStyle(sheet, "A1", "AP2", headerStyle)
} }
func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error { func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error {
@@ -245,7 +283,8 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
columns := []string{ columns := []string{
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
"AC", "AD", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN",
"AO", "AP",
} }
currentRow := 3 currentRow := 3
@@ -293,14 +332,14 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
// Expand recordings into one row per sapronak // Expand recordings into one row per sapronak
type sapronakRow struct { type sapronakRow struct {
name string name string
input string input interface{} // float64 for numeric, string "-" for placeholder
} }
sapronaks := make([]sapronakRow, 0) sapronaks := make([]sapronakRow, 0)
if len(item.FeedUsage) > 0 { if len(item.FeedUsage) > 0 {
for _, fu := range item.FeedUsage { for _, fu := range item.FeedUsage {
sapronaks = append(sapronaks, sapronakRow{ sapronaks = append(sapronaks, sapronakRow{
name: safeExportText(fu.ProductName), name: safeExportText(fu.ProductName),
input: formatNumberID(fu.UsageAmount+fu.PendingQty, 2, true), input: fu.UsageAmount + fu.PendingQty,
}) })
} }
} else { } else {
@@ -311,6 +350,23 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
for sIdx, s := range sapronaks { for sIdx, s := range sapronaks {
if sIdx == 0 { if sIdx == 0 {
eggQty := func(flagName string) int {
if item.EggExportBreakdown != nil {
if bd, ok := item.EggExportBreakdown[flagName]; ok {
return bd.Qty
}
}
return 0
}
eggKg := func(flagName string) float64 {
if item.EggExportBreakdown != nil {
if bd, ok := item.EggExportBreakdown[flagName]; ok {
return bd.Kg
}
}
return 0
}
rowValues := []interface{}{ rowValues := []interface{}{
i + 1, // A i + 1, // A
locationName, // B locationName, // B
@@ -320,28 +376,40 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F
formatAgeLabel(item), // G formatAgeLabel(item), // G
formatDateIndonesian(item.RecordDatetime), // H formatDateIndonesian(item.RecordDatetime), // H
formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), // I item.ProjectFlock.TotalChickQty, // I
formatNumberID(item.FcrValue, 2, true), // J item.FcrValue, // J
formatNumberID(fcrStd, 2, true), // K fcrStd, // K
formatNumberID(item.FeedIntake, 2, true), // L item.FeedIntake, // L
formatNumberID(feedIntakeStd, 2, true), // M feedIntakeStd, // M
formatPercentID(item.CumDepletionRate, 2), // N item.CumDepletionRate, // N
formatPercentID(maxDepletionStd, 2), // O maxDepletionStd, // O
formatNumberID(item.TotalDepletionQty, 2, true), // P item.TotalDepletionQty, // P
formatNumberID(item.EggMass, 2, true), // Q item.EggMass, // Q
formatNumberID(eggMassStd, 2, true), // R eggMassStd, // R
formatNumberID(item.EggWeight, 2, true), // S item.EggWeight, // S
formatNumberID(eggWeightStd, 2, true), // T eggWeightStd, // T
formatPercentID(item.HenDay, 2), // U item.HenDay, // U
formatPercentID(henDayStd, 2), // V henDayStd, // V
formatPercentID(item.HenHouse, 2), // W item.HenHouse, // W
formatPercentID(henHouseStd, 2), // X henHouseStd, // X
formatApprovalStatus(item), // Y formatApprovalStatus(item), // Y
safeExportText(pointerString(item.Approval.Notes)), // Z safeExportText(pointerString(item.Approval.Notes)), // Z
createdBy, // AA createdBy, // AA
formatDateIndonesian(item.CreatedAt), // AB formatDateIndonesian(item.CreatedAt), // AB
s.name, // AC s.name, // AC
s.input, // AD s.input, // AD
eggQty(string(utils.FlagTelurUtuh)), // AE
eggKg(string(utils.FlagTelurUtuh)), // AF
eggQty(string(utils.FlagTelurPecah)), // AG
eggKg(string(utils.FlagTelurPecah)), // AH
eggQty(string(utils.FlagTelurPutih)), // AI
eggKg(string(utils.FlagTelurPutih)), // AJ
eggQty(string(utils.FlagTelurRetak)), // AK
eggKg(string(utils.FlagTelurRetak)), // AL
eggQty(string(utils.FlagTelurPapacal)), // AM
eggKg(string(utils.FlagTelurPapacal)), // AN
eggQty(string(utils.FlagTelurJumbo)), // AO
eggKg(string(utils.FlagTelurJumbo)), // AP
} }
for idx, col := range columns { for idx, col := range columns {
@@ -379,7 +447,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AD%d", lastRow), dataCenterStyle); err != nil { if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AP%d", lastRow), dataCenterStyle); err != nil {
return err return err
} }
@@ -445,6 +513,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
"AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP",
} }
for _, rng := range itemRanges { for _, rng := range itemRanges {
if rng.end > rng.start { if rng.end > rng.start {
@@ -454,6 +523,53 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
} }
file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle) file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle)
file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle) file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle)
// Egg columns use center + thick bottom border
for _, col := range []string{"AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP"} {
file.SetCellStyle(sheet, fmt.Sprintf("%s%d", col, rng.end), fmt.Sprintf("%s%d", col, rng.end), borderBottomCenterStyle)
}
}
numFmtInt := "0"
numberIntStyle, err := file.NewStyle(&excelize.Style{
CustomNumFmt: &numFmtInt,
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "E6E6E6", Style: 1},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
numFmtFloat := "0.00"
numberFloatStyle, err := file.NewStyle(&excelize.Style{
CustomNumFmt: &numFmtFloat,
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "E6E6E6", Style: 1},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
intCols := []string{"E", "I", "AE", "AG", "AI", "AK", "AM", "AO"}
for _, col := range intCols {
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), numberIntStyle); err != nil {
return err
}
}
floatCols := []string{"J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "AD", "AF", "AH", "AJ", "AL", "AN", "AP"}
for _, col := range floatCols {
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), numberFloatStyle); err != nil {
return err
}
} }
return nil return nil
@@ -100,6 +100,11 @@ type RecordingFeedUsageDTO struct {
PendingQty float64 `json:"pending_qty"` PendingQty float64 `json:"pending_qty"`
} }
type EggExportBreakdownDTO struct {
Qty int `json:"qty"`
Kg float64 `json:"kg"`
}
type RecordingListDTO struct { type RecordingListDTO struct {
RecordingRelationDTO RecordingRelationDTO
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -108,6 +113,7 @@ type RecordingListDTO struct {
Kandang *RecordingKandangDTO `json:"kandang,omitempty"` Kandang *RecordingKandangDTO `json:"kandang,omitempty"`
Location *RecordingLocationDTO `json:"location,omitempty"` Location *RecordingLocationDTO `json:"location,omitempty"`
FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"` FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"`
EggExportBreakdown map[string]EggExportBreakdownDTO `json:"egg_breakdown,omitempty"`
} }
type RecordingDetailDTO struct { type RecordingDetailDTO struct {
@@ -51,6 +51,7 @@ type RecordingRepository interface {
UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error
UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error
GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error)
GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error)
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
@@ -581,6 +582,22 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID(
return &egg, nil return &egg, nil
} }
func (r *RecordingRepositoryImpl) GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) {
if len(recordingIDs) == 0 {
return nil, nil
}
var eggs []entity.RecordingEgg
err := r.DB().WithContext(ctx).
Preload("ProductWarehouse.Product").
Where("recording_eggs.recording_id IN ?", recordingIDs).
Find(&eggs).Error
if err != nil {
return nil, err
}
return eggs, nil
}
func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) {
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return false, nil return false, nil
@@ -46,6 +46,7 @@ type RecordingService interface {
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error)
} }
type recordingService struct { type recordingService struct {
@@ -259,6 +260,10 @@ func (s recordingService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Qu
return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict) return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
} }
func (s recordingService) GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) {
return s.Repository.GetEggsWithFlagsByRecordingIDs(ctx, recordingIDs)
}
func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) {
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
return nil, err return nil, err
@@ -404,8 +409,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return nil, err return nil, err
} }
if err := s.ChickinSvc.EnsureChickInExists(ctx, pfk.Id); err != nil { if err := s.ChickinSvc.EnsureChickInExists(ctx, pfk.Id); err != nil {
if !isLaying {
return nil, err return nil, err
} }
// LAYING fallback: kandang laying tidak punya project_chickins sendiri —
// populasinya dari laying transfer. Cek apakah ada executed laying transfer.
if fallbackErr := s.ensureLayingTransferExecutedForKandang(ctx, pfk.Id); fallbackErr != nil {
return nil, err
}
}
if s.ProductionStandardSvc != nil { if s.ProductionStandardSvc != nil {
if err := s.ProductionStandardSvc.EnsureWeekStart(ctx, pfk.ProjectFlock.ProductionStandardId, category); err != nil { if err := s.ProductionStandardSvc.EnsureWeekStart(ctx, pfk.ProjectFlock.ProductionStandardId, category); err != nil {
return nil, err return nil, err
@@ -3993,6 +4005,27 @@ func (s *recordingService) reflowRollbackRecordingInventory(ctx context.Context,
return nil return nil
} }
func (s *recordingService) ensureLayingTransferExecutedForKandang(ctx context.Context, pfkID uint) error {
var count int64
err := s.Repository.DB().WithContext(ctx).
Table("laying_transfer_targets ltt").
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Where("ltt.target_project_flock_kandang_id = ?", pfkID).
Where("ltt.deleted_at IS NULL").
Where("lt.deleted_at IS NULL").
Where("lt.executed_at IS NOT NULL").
Where("ltt.total_qty > 0").
Count(&count).Error
if err != nil {
s.Log.Errorf("Failed to check executed laying transfer for pfk_id=%d: %+v", pfkID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa transfer laying")
}
if count == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Kandang laying belum memiliki transfer laying yang telah dieksekusi sehingga belum dapat membuat recording")
}
return nil
}
func (s *recordingService) requireFIFO() error { func (s *recordingService) requireFIFO() error {
if s.FifoStockV2Svc == nil { if s.FifoStockV2Svc == nil {
s.Log.Errorf("FIFO v2 service is not available for recording operations") s.Log.Errorf("FIFO v2 service is not available for recording operations")
@@ -152,6 +152,29 @@ func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error {
return ctx.Status(fiber.StatusOK).JSON(resp) return ctx.Status(fiber.StatusOK).JSON(resp)
} }
func (c *RepportController) GetExpenseDepreciationV2(ctx *fiber.Ctx) error {
rows, meta, err := c.RepportService.GetExpenseDepreciationV2(ctx)
if err != nil {
return err
}
resp := struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Meta dto.ExpenseDepreciationV2MetaDTO `json:"meta"`
Data []dto.ExpenseDepreciationV2RowDTO `json:"data"`
}{
Code: fiber.StatusOK,
Status: "success",
Message: "Get expense depreciation report v2 successfully",
Meta: *meta,
Data: rows,
}
return ctx.Status(fiber.StatusOK).JSON(resp)
}
func (c *RepportController) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) error { func (c *RepportController) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) error {
rows, meta, err := c.RepportService.GetExpenseDepreciationManualInputs(ctx) rows, meta, err := c.RepportService.GetExpenseDepreciationManualInputs(ctx)
if err != nil { if err != nil {
@@ -457,6 +480,29 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error {
return ctx.Status(fiber.StatusOK).JSON(resp) return ctx.Status(fiber.StatusOK).JSON(resp)
} }
func (c *RepportController) GetHppPerFarm(ctx *fiber.Ctx) error {
data, meta, err := c.RepportService.GetHppPerFarm(ctx)
if err != nil {
return err
}
resp := struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Meta dto.HppPerFarmMetaDTO `json:"meta"`
Data dto.HppPerFarmResponseData `json:"data"`
}{
Code: fiber.StatusOK,
Status: "success",
Message: "Get HPP per farm successfully",
Meta: *meta,
Data: *data,
}
return ctx.Status(fiber.StatusOK).JSON(resp)
}
func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
var customerIDs []uint var customerIDs []uint
if customerIDsStr := ctx.Query("customer_ids"); customerIDsStr != "" { if customerIDsStr := ctx.Query("customer_ids"); customerIDsStr != "" {
@@ -40,6 +40,29 @@ type ExpenseDepreciationManualInputRowDTO struct {
Note *string `json:"note"` Note *string `json:"note"`
} }
type ExpenseDepreciationV2MetaDTO struct {
ProjectFlockID int64 `json:"project_flock_id"`
FarmName string `json:"farm_name"`
LocationID int64 `json:"location_id"`
Period string `json:"period"`
Limit int `json:"limit"`
TotalDays int `json:"total_days"`
}
type ExpenseDepreciationV2RowDTO struct {
Date string `json:"date"`
DepreciationPercentEffective float64 `json:"depreciation_percent_effective"`
DepreciationValue float64 `json:"depreciation_value"`
PulletCostDayNTotal float64 `json:"pullet_cost_day_n_total"`
MultiplicationPercentage float64 `json:"multiplication_percentage"`
DayN int `json:"day_n"`
ChickinDate string `json:"chickin_date"`
TotalValuePulletAfterDepreciation float64 `json:"total_value_pullet_after_depreciation"`
StandardEffectiveDate string `json:"standard_effective_date,omitempty"`
TotalPopulation float64 `json:"total_population"`
Components any `json:"components"`
}
func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO { func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO {
return ExpenseDepreciationFiltersDTO{ return ExpenseDepreciationFiltersDTO{
AreaID: area, AreaID: area,
@@ -0,0 +1,79 @@
package dto
type HppPerFarmFiltersDTO struct {
AreaID string `json:"area_id"`
LocationID string `json:"location_id"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
}
type HppPerFarmMetaDTO struct {
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int64 `json:"total_pages"`
TotalResults int64 `json:"total_results"`
Filters HppPerFarmFiltersDTO `json:"filters"`
}
type HppPerFarmResponseData struct {
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Rows []HppPerFarmRowDTO `json:"rows"`
Summary HppPerFarmSummaryDTO `json:"summary"`
}
// HppPerFarmRowDTO is one farm (location) row, aggregating all LAYING project
// flocks within the same location over the selected date range.
type HppPerFarmRowDTO struct {
Location HppPerKandangLocationDTO `json:"location"`
// total_cost_rp = depreciation + pakan + ovk + bop (+ other production cost).
// DOC/pullet is NOT included here (it is expensed through depreciation);
// average_doc_price_rp is provided for information only.
TotalCostRp float64 `json:"total_cost_rp"`
FeedCostRp float64 `json:"feed_cost_rp"`
OvkCostRp float64 `json:"ovk_cost_rp"`
BopCostRp float64 `json:"bop_cost_rp"`
DepreciationRp float64 `json:"depreciation_rp"`
OtherCostRp float64 `json:"other_cost_rp"`
EggWeightRecordingKg float64 `json:"egg_weight_recording_kg"`
EggWeightDoKg float64 `json:"egg_weight_do_kg"`
HppPerKgProduction float64 `json:"hpp_per_kg_production"`
HppPerKgSales float64 `json:"hpp_per_kg_sales"`
AverageDocPriceRp int64 `json:"average_doc_price_rp"`
Flocks []HppPerFarmFlockDTO `json:"flocks"`
}
// HppPerFarmFlockDTO is the per-project-flock breakdown inside a farm row.
type HppPerFarmFlockDTO struct {
ProjectFlockID int64 `json:"project_flock_id"`
FlockName string `json:"flock_name"`
TotalCostRp float64 `json:"total_cost_rp"`
FeedCostRp float64 `json:"feed_cost_rp"`
OvkCostRp float64 `json:"ovk_cost_rp"`
BopCostRp float64 `json:"bop_cost_rp"`
DepreciationRp float64 `json:"depreciation_rp"`
OtherCostRp float64 `json:"other_cost_rp"`
EggWeightRecordingKg float64 `json:"egg_weight_recording_kg"`
EggWeightDoKg float64 `json:"egg_weight_do_kg"`
HppPerKgProduction float64 `json:"hpp_per_kg_production"`
HppPerKgSales float64 `json:"hpp_per_kg_sales"`
AverageDocPriceRp int64 `json:"average_doc_price_rp"`
}
type HppPerFarmSummaryDTO struct {
TotalCostRp float64 `json:"total_cost_rp"`
TotalEggWeightRecordingKg float64 `json:"total_egg_weight_recording_kg"`
TotalEggWeightDoKg float64 `json:"total_egg_weight_do_kg"`
AverageHppPerKgProduction float64 `json:"average_hpp_per_kg_production"`
AverageHppPerKgSales float64 `json:"average_hpp_per_kg_sales"`
}
func NewHppPerFarmFiltersDTO(area, location, startDate, endDate string) HppPerFarmFiltersDTO {
return HppPerFarmFiltersDTO{
AreaID: area,
LocationID: location,
StartDate: startDate,
EndDate: endDate,
}
}
+2
View File
@@ -37,6 +37,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db)
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
hppPerFarmRepository := repportRepo.NewHppPerFarmRepository(db)
expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db) expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db)
productionResultRepository := repportRepo.NewProductionResultRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db)
customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db)
@@ -65,6 +66,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
purchaseSupplierRepository, purchaseSupplierRepository,
debtSupplierRepository, debtSupplierRepository,
hppPerKandangRepository, hppPerKandangRepository,
hppPerFarmRepository,
productionResultRepository, productionResultRepository,
customerPaymentRepository, customerPaymentRepository,
balanceMonitoringRepository, balanceMonitoringRepository,
@@ -51,7 +51,7 @@ type ExpenseDepreciationRepository interface {
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
DeleteSnapshotsByFarmIDs(ctx context.Context, farmIDs []uint) error DeleteSnapshotsByFarmIDs(ctx context.Context, farmIDs []uint) error
GetLatestTransferInputsByFarms(ctx context.Context, period time.Time, farmIDs []uint) ([]FarmDepreciationLatestTransferRow, error) GetLatestTransferInputsByFarms(ctx context.Context, period time.Time, farmIDs []uint) ([]FarmDepreciationLatestTransferRow, error)
GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, map[string]*time.Time, error) GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, projectFlockID uint) (map[string]map[int]float64, map[string]*time.Time, error)
GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error) GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error)
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
DB() *gorm.DB DB() *gorm.DB
@@ -245,6 +245,7 @@ func (r *expenseDepreciationRepository) GetMultiplicationPercentages(
ctx context.Context, ctx context.Context,
houseTypes []string, houseTypes []string,
maxDay int, maxDay int,
projectFlockID uint,
) (map[string]map[int]float64, map[string]*time.Time, error) { ) (map[string]map[int]float64, map[string]*time.Time, error) {
result := make(map[string]map[int]float64) result := make(map[string]map[int]float64)
effectiveDates := make(map[string]*time.Time) effectiveDates := make(map[string]*time.Time)
@@ -258,8 +259,9 @@ func (r *expenseDepreciationRepository) GetMultiplicationPercentages(
house_type::text AS house_type, day, multiplication_percentage, effective_date house_type::text AS house_type, day, multiplication_percentage, effective_date
FROM house_depreciation_standards FROM house_depreciation_standards
WHERE house_type::text IN ? AND day <= ? WHERE house_type::text IN ? AND day <= ?
ORDER BY house_type, day, effective_date DESC NULLS LAST AND (project_flock_ids IS NULL OR ? = ANY(project_flock_ids))
`, houseTypes, maxDay).Scan(&rows).Error; err != nil { ORDER BY house_type, day, (project_flock_ids IS NOT NULL) DESC, effective_date DESC NULLS LAST
`, houseTypes, maxDay, projectFlockID).Scan(&rows).Error; err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -0,0 +1,233 @@
package repositories
import (
"context"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
// HppPerFarmFlockMetaRow describes a LAYING project flock and the farm
// (location) it belongs to. Farm identity is project_flocks.location_id.
type HppPerFarmFlockMetaRow struct {
ProjectFlockID uint
FlockName string
LocationID uint
LocationName string
AreaID uint
}
// HppPerFarmDocRow holds the DOC/pullet acquisition cost trace per flock.
// Used only as an informational field (average_doc_price_rp); it is NOT part
// of total_cost because the pullet cost is expensed through depreciation.
type HppPerFarmDocRow struct {
ProjectFlockID uint
DocCost float64
DocQty float64
}
type HppPerFarmRepository interface {
GetCandidateFlocks(ctx context.Context, start time.Time, areaIDs, locationIDs []int64) ([]HppPerFarmFlockMetaRow, error)
SumRecordingEggWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error)
SumMarketingDoTelurWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error)
GetDocCostByFlock(ctx context.Context, projectFlockIDs []uint) (map[uint]HppPerFarmDocRow, error)
DB() *gorm.DB
}
type hppPerFarmRepository struct {
db *gorm.DB
}
func NewHppPerFarmRepository(db *gorm.DB) HppPerFarmRepository {
return &hppPerFarmRepository{db: db}
}
func (r *hppPerFarmRepository) DB() *gorm.DB {
return r.db
}
// GetCandidateFlocks returns the LAYING project flocks (with their farm/location
// metadata) that are still active on or after the range start, scoped by area
// and location. Mirrors ExpenseDepreciationRepository.GetCandidateFarms but adds
// location info so flocks can be grouped per farm.
func (r *hppPerFarmRepository) GetCandidateFlocks(ctx context.Context, start time.Time, areaIDs, locationIDs []int64) ([]HppPerFarmFlockMetaRow, error) {
rows := make([]HppPerFarmFlockMetaRow, 0)
query := r.db.WithContext(ctx).
Table("project_flocks AS pf").
Select(`
DISTINCT pf.id AS project_flock_id,
pf.flock_name AS flock_name,
pf.location_id AS location_id,
loc.name AS location_name,
pf.area_id AS area_id`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id").
Joins("JOIN locations AS loc ON loc.id = pf.location_id").
Where("pf.deleted_at IS NULL").
Where("pf.category = ?", utils.ProjectFlockCategoryLaying).
Where("(pfk.closed_at IS NULL OR DATE(pfk.closed_at) >= DATE(?))", start)
if len(areaIDs) > 0 {
query = query.Where("pf.area_id IN ?", areaIDs)
}
if len(locationIDs) > 0 {
query = query.Where("pf.location_id IN ?", locationIDs)
}
if err := query.Order("pf.location_id ASC, pf.id ASC").Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// SumRecordingEggWeightByFlock sums recording_eggs.weight (kg) per project flock
// for non-rejected recordings whose record_datetime falls inside [start, endExclusive).
func (r *hppPerFarmRepository) SumRecordingEggWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error) {
result := make(map[uint]float64)
if len(projectFlockIDs) == 0 {
return result, nil
}
latestApproval := r.db.WithContext(ctx).
Table("approvals AS a").
Select("a.approvable_id, a.action").
Joins(`
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = ?
GROUP BY approvable_id
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
string(utils.ApprovalWorkflowRecording),
)
type eggRow struct {
ProjectFlockID uint
Weight float64
}
rows := make([]eggRow, 0)
query := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
pfk.project_flock_id AS project_flock_id,
COALESCE(SUM(re.weight), 0) AS weight`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval).
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("pfk.project_flock_id IN ?", projectFlockIDs).
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, endExclusive).
Where("r.deleted_at IS NULL").
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Group("pfk.project_flock_id")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ProjectFlockID] = row.Weight
}
return result, nil
}
// SumMarketingDoTelurWeightByFlock sums delivered TELUR weight (marketing_delivery_products.total_weight)
// per project flock, for delivery_date inside [start, endExclusive). A delivery product that is
// attributed to multiple flocks is prorated by each flock's allocated qty share, so that
// the farm total equals the sum of its flocks.
func (r *hppPerFarmRepository) SumMarketingDoTelurWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error) {
result := make(map[uint]float64)
if len(projectFlockIDs) == 0 {
return result, nil
}
telurFlags := []string{
string(utils.FlagTelur),
string(utils.FlagTelurUtuh),
string(utils.FlagTelurPecah),
string(utils.FlagTelurPutih),
string(utils.FlagTelurRetak),
}
// allocated qty per (marketing_delivery_product, project_flock)
attrByFlock := r.db.WithContext(ctx).
Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.db.WithContext(ctx))).
Select(`
mda.marketing_delivery_product_id AS mdp_id,
mda.project_flock_id AS project_flock_id,
SUM(mda.allocated_qty) AS flock_qty`).
Group("mda.marketing_delivery_product_id, mda.project_flock_id")
// prorate each delivery product's total_weight across its attributed flocks.
// Use EXISTS for the TELUR flag filter (not a JOIN) so a product carrying
// multiple egg flags does not fan out and double-count the weight share.
shareQuery := r.db.WithContext(ctx).
Table("(?) AS a", attrByFlock).
Select(`
a.project_flock_id AS project_flock_id,
mdp.total_weight * a.flock_qty / NULLIF(SUM(a.flock_qty) OVER (PARTITION BY a.mdp_id), 0) AS weight_share`).
Joins("JOIN marketing_delivery_products AS mdp ON mdp.id = a.mdp_id").
Joins("JOIN marketing_products AS mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN product_warehouses AS pw ON pw.id = mp.product_warehouse_id").
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, telurFlags).
Where("mdp.delivery_date >= ? AND mdp.delivery_date < ?", start, endExclusive)
type doRow struct {
ProjectFlockID uint
Weight float64
}
rows := make([]doRow, 0)
query := r.db.WithContext(ctx).
Table("(?) AS s", shareQuery).
Select(`
s.project_flock_id AS project_flock_id,
COALESCE(SUM(s.weight_share), 0) AS weight`).
Where("s.project_flock_id IN ?", projectFlockIDs).
Group("s.project_flock_id")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ProjectFlockID] = row.Weight
}
return result, nil
}
// GetDocCostByFlock returns the DOC acquisition cost (qty * purchase price) and qty
// traced to chick-in per project flock. Informational only.
func (r *hppPerFarmRepository) GetDocCostByFlock(ctx context.Context, projectFlockIDs []uint) (map[uint]HppPerFarmDocRow, error) {
result := make(map[uint]HppPerFarmDocRow)
if len(projectFlockIDs) == 0 {
return result, nil
}
rows := make([]HppPerFarmDocRow, 0)
query := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
pfk.project_flock_id AS project_flock_id,
COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS doc_cost,
COALESCE(SUM(sa.qty), 0) AS doc_qty`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pfk.project_flock_id IN ?", projectFlockIDs).
Group("pfk.project_flock_id")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ProjectFlockID] = row
}
return result, nil
}
+2
View File
@@ -17,12 +17,14 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense)
route.Get("/expense/depreciation", ctrl.GetExpenseDepreciation) route.Get("/expense/depreciation", ctrl.GetExpenseDepreciation)
route.Get("/expense/v2/depreciation", ctrl.GetExpenseDepreciationV2)
route.Get("/expense/depreciation/manual-inputs", ctrl.GetExpenseDepreciationManualInputs) route.Get("/expense/depreciation/manual-inputs", ctrl.GetExpenseDepreciationManualInputs)
route.Put("/expense/depreciation/manual-inputs", ctrl.UpsertExpenseDepreciationManualInput) route.Put("/expense/depreciation/manual-inputs", ctrl.UpsertExpenseDepreciationManualInput)
route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing)
route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier)
route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier)
route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang)
route.Get("/hpp-per-farm", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerFarm)
route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown) route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown)
route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult)
route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment)
@@ -0,0 +1,93 @@
package service
import (
"math"
"testing"
approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
)
// production-scope total should sum only parts tagged production_cost (a part
// tagged with both scopes still counts once).
func TestHppPerFarmProductionScopeTotalPartLevelScopes(t *testing.T) {
comp := &approvalService.HppV2Component{
Code: "PAKAN",
Parts: []approvalService.HppV2ComponentPart{
{Total: 100, Scopes: []string{"production_cost"}},
{Total: 50, Scopes: []string{"pullet_cost"}},
{Total: 25, Scopes: []string{"production_cost", "pullet_cost"}},
},
}
if got := hppPerFarmProductionScopeTotal(comp); got != 125 {
t.Fatalf("expected 125, got %v", got)
}
}
// when parts carry no scopes, fall back to the component-level scope.
func TestHppPerFarmProductionScopeTotalComponentLevelFallback(t *testing.T) {
prod := &approvalService.HppV2Component{
Code: "DIRECT_PULLET_PURCHASE",
Scopes: []string{"production_cost"},
Total: 300,
Parts: []approvalService.HppV2ComponentPart{{Total: 300}},
}
if got := hppPerFarmProductionScopeTotal(prod); got != 300 {
t.Fatalf("expected 300 component fallback, got %v", got)
}
// DOC/pullet is pullet-scope only -> contributes 0 to production cost,
// which is exactly why it must not be added to total_cost (depreciation
// already expenses the pullet).
pulletOnly := &approvalService.HppV2Component{
Code: "DOC_CHICKIN",
Scopes: []string{"pullet_cost"},
Total: 999,
Parts: []approvalService.HppV2ComponentPart{{Total: 999}},
}
if got := hppPerFarmProductionScopeTotal(pulletOnly); got != 0 {
t.Fatalf("expected 0 for pullet-only component, got %v", got)
}
}
func TestHppPerFarmProductionScopeTotalsByCode(t *testing.T) {
b := &approvalService.HppV2Breakdown{
Components: []approvalService.HppV2Component{
{Code: "PAKAN", Parts: []approvalService.HppV2ComponentPart{{Total: 100, Scopes: []string{"production_cost"}}}},
{Code: "OVK", Parts: []approvalService.HppV2ComponentPart{{Total: 40, Scopes: []string{"production_cost"}}}},
{Code: "DOC_CHICKIN", Scopes: []string{"pullet_cost"}, Total: 500, Parts: []approvalService.HppV2ComponentPart{{Total: 500}}},
{Code: "DEPRECIATION", Scopes: []string{"production_cost"}, Total: 30, Parts: []approvalService.HppV2ComponentPart{{Total: 30, Scopes: []string{"production_cost"}}}},
},
}
got := hppPerFarmProductionScopeTotalsByCode(b)
if got["PAKAN"] != 100 {
t.Fatalf("expected PAKAN 100, got %v", got["PAKAN"])
}
if got["OVK"] != 40 {
t.Fatalf("expected OVK 40, got %v", got["OVK"])
}
if got["DOC_CHICKIN"] != 0 {
t.Fatalf("expected DOC_CHICKIN production scope 0, got %v", got["DOC_CHICKIN"])
}
if got["DEPRECIATION"] != 30 {
t.Fatalf("expected DEPRECIATION 30, got %v", got["DEPRECIATION"])
}
}
func TestHppPerFarmSafeDiv(t *testing.T) {
cases := []struct {
num, den, want float64
}{
{100, 4, 25},
{100, 0, 0},
{100, -5, 0},
{0, 0, 0},
}
for _, c := range cases {
if got := hppPerFarmSafeDiv(c.num, c.den); got != c.want {
t.Fatalf("safeDiv(%v,%v)=%v want %v", c.num, c.den, got, c.want)
}
}
if got := hppPerFarmSafeDiv(math.Inf(1), 1); got != 0 {
t.Fatalf("expected 0 for inf numerator, got %v", got)
}
}
@@ -43,12 +43,14 @@ import (
type RepportService interface { type RepportService interface {
GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error)
GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error)
GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationV2RowDTO, *dto.ExpenseDepreciationV2MetaDTO, error)
GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error)
UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error)
GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error)
GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error)
GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error)
GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error)
GetHppPerFarm(ctx *fiber.Ctx) (*dto.HppPerFarmResponseData, *dto.HppPerFarmMetaDTO, error)
GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error)
GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error)
GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error)
@@ -73,6 +75,7 @@ type repportService struct {
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
DebtSupplierRepo repportRepo.DebtSupplierRepository DebtSupplierRepo repportRepo.DebtSupplierRepository
HppPerKandangRepo repportRepo.HppPerKandangRepository HppPerKandangRepo repportRepo.HppPerKandangRepository
HppPerFarmRepo repportRepo.HppPerFarmRepository
ProductionResultRepo repportRepo.ProductionResultRepository ProductionResultRepo repportRepo.ProductionResultRepository
CustomerPaymentRepo repportRepo.CustomerPaymentRepository CustomerPaymentRepo repportRepo.CustomerPaymentRepository
BalanceMonitoringRepo repportRepo.BalanceMonitoringRepository BalanceMonitoringRepo repportRepo.BalanceMonitoringRepository
@@ -106,6 +109,7 @@ func NewRepportService(
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
debtSupplierRepo repportRepo.DebtSupplierRepository, debtSupplierRepo repportRepo.DebtSupplierRepository,
hppPerKandangRepo repportRepo.HppPerKandangRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository,
hppPerFarmRepo repportRepo.HppPerFarmRepository,
productionResultRepo repportRepo.ProductionResultRepository, productionResultRepo repportRepo.ProductionResultRepository,
customerPaymentRepo repportRepo.CustomerPaymentRepository, customerPaymentRepo repportRepo.CustomerPaymentRepository,
balanceMonitoringRepo repportRepo.BalanceMonitoringRepository, balanceMonitoringRepo repportRepo.BalanceMonitoringRepository,
@@ -130,6 +134,7 @@ func NewRepportService(
PurchaseSupplierRepo: purchaseSupplierRepo, PurchaseSupplierRepo: purchaseSupplierRepo,
DebtSupplierRepo: debtSupplierRepo, DebtSupplierRepo: debtSupplierRepo,
HppPerKandangRepo: hppPerKandangRepo, HppPerKandangRepo: hppPerKandangRepo,
HppPerFarmRepo: hppPerFarmRepo,
ProductionResultRepo: productionResultRepo, ProductionResultRepo: productionResultRepo,
CustomerPaymentRepo: customerPaymentRepo, CustomerPaymentRepo: customerPaymentRepo,
BalanceMonitoringRepo: balanceMonitoringRepo, BalanceMonitoringRepo: balanceMonitoringRepo,
@@ -355,6 +360,182 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe
return rows[offset:end], meta, nil return rows[offset:end], meta, nil
} }
func (s *repportService) GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationV2RowDTO, *dto.ExpenseDepreciationV2MetaDTO, error) {
params, err := s.parseExpenseDepreciationV2Query(ctx)
if err != nil {
return nil, nil, err
}
if err := s.Validate.Struct(params); err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if s.ExpenseDepreciationRepo == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
}
if s.HppCostRepo == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "hpp cost repository is not configured")
}
if s.HppV2Svc == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "hpp v2 service is not configured")
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
}
periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD")
}
limit := params.Limit
if limit <= 0 {
limit = 10
}
farmID := uint(params.ProjectFlockID)
kandangIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx.Context(), farmID)
if err != nil {
return nil, nil, err
}
if len(kandangIDs) == 0 {
return nil, nil, fiber.NewError(fiber.StatusNotFound, "project flock has no kandangs")
}
var farmName string
if err := s.db.WithContext(ctx.Context()).
Table("project_flocks").
Select("flock_name").
Where("id = ? AND deleted_at IS NULL", farmID).
Scan(&farmName).Error; err != nil {
return nil, nil, err
}
if farmName == "" {
return nil, nil, fiber.NewError(fiber.StatusNotFound, "project flock not found")
}
rows := make([]dto.ExpenseDepreciationV2RowDTO, 0, limit)
actualDays := 0
for i := 0; i < limit; i++ {
dayDate := periodDate.AddDate(0, 0, i)
dayStr := dayDate.Format("2006-01-02")
var totalDepreciationValue float64
var totalPulletCostDayN float64
var totalPopulation float64
var allKandangComponents []depreciationKandangComponent
for _, kandangID := range kandangIDs {
breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &dayDate)
if err != nil {
return nil, nil, err
}
if breakdown == nil {
continue
}
depreciationComponent := hppV2FindDepreciationComponent(breakdown)
if depreciationComponent == nil {
continue
}
for _, part := range depreciationComponent.Parts {
if part.Total <= 0 {
continue
}
houseType := approvalService.NormalizeDepreciationHouseType(breakdown.HouseType)
component := depreciationKandangComponent{
ProjectFlockKandangID: breakdown.ProjectFlockKandangID,
KandangID: breakdown.KandangID,
KandangName: breakdown.KandangName,
SourceProjectFlockID: hppV2DetailUint(part.Details, "source_project_flock_id"),
HouseType: houseType,
DayN: hppV2DetailInt(part.Details, "schedule_day"),
DepreciationPercent: hppV2DetailFloat(part.Details, "depreciation_percent"),
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 == "" {
component.HouseType = approvalService.NormalizeDepreciationHouseType(hppV2DetailString(part.Details, "house_type"))
}
if ref := hppV2FindReference(part.References, "laying_transfer"); ref != nil {
component.TransferID = ref.ID
component.TransferDate = ref.Date
component.TransferQty = ref.Qty
}
if part.Code == "manual_cutover" {
if startDay := hppV2DetailInt(part.Details, "start_schedule_day"); startDay > 0 {
component.StartScheduleDay = &startDay
}
component.CutoverDate = hppV2DetailString(part.Details, "cutover_date")
if manualID := hppV2DetailUint(part.Details, "manual_input_id"); manualID > 0 {
component.ManualInputID = &manualID
}
if component.ManualInputID == nil {
if ref := hppV2FindReference(part.References, "farm_depreciation_manual_input"); ref != nil && ref.ID > 0 {
manualID := ref.ID
component.ManualInputID = &manualID
}
}
}
totalPulletCostDayN += component.PulletCostDayN
totalDepreciationValue += component.DepreciationValue
totalPopulation += component.Population
allKandangComponents = append(allKandangComponents, component)
}
}
effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN)
components := depreciationFarmComponents{
KandangCount: len(allKandangComponents),
TotalPopulation: totalPopulation,
Kandang: allKandangComponents,
}
componentsJSON, _ := json.Marshal(components)
multiplicationPercentage, dayN, chickinDate, standardEffectiveDate := depreciationSnapshotInfo(parseSnapshotComponents(componentsJSON))
rows = append(rows, dto.ExpenseDepreciationV2RowDTO{
Date: dayStr,
DepreciationPercentEffective: effectivePercent,
DepreciationValue: totalDepreciationValue,
PulletCostDayNTotal: totalPulletCostDayN,
MultiplicationPercentage: multiplicationPercentage,
DayN: dayN,
ChickinDate: chickinDate,
TotalValuePulletAfterDepreciation: totalPulletCostDayN - totalDepreciationValue,
StandardEffectiveDate: standardEffectiveDate,
TotalPopulation: totalPopulation,
Components: parseSnapshotComponents(componentsJSON),
})
actualDays++
}
meta := &dto.ExpenseDepreciationV2MetaDTO{
ProjectFlockID: params.ProjectFlockID,
FarmName: farmName,
LocationID: params.LocationID,
Period: params.Period,
Limit: limit,
TotalDays: actualDays,
}
return rows, meta, nil
}
func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) { func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) {
params, filters, err := s.parseExpenseDepreciationQuery(ctx) params, filters, err := s.parseExpenseDepreciationQuery(ctx)
if err != nil { if err != nil {
@@ -2945,6 +3126,534 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp
return params, filters, nil return params, filters, nil
} }
const (
hppPerFarmProductionScope = "production_cost"
hppPerFarmComponentDepreciation = "DEPRECIATION"
hppPerFarmComponentPakan = "PAKAN"
hppPerFarmComponentOvk = "OVK"
hppPerFarmComponentBopRegular = "BOP_REGULAR"
hppPerFarmComponentBopEkspedisi = "BOP_EKSPEDISI"
hppPerFarmMaxRangeDays = 366
)
// GetHppPerFarm builds the HPP-per-farm report: it groups all LAYING project
// flocks by location/farm over [start_date, end_date] and reports, per farm,
// the total cost (pakan + ovk + bop + depreciation) and two cost-per-kg figures
// — one against egg weight produced (recording_eggs) and one against egg weight
// sold/delivered (marketing delivery orders). DOC/pullet cost is informational
// only (it is expensed through depreciation, so it is NOT added to total cost).
func (s *repportService) GetHppPerFarm(ctx *fiber.Ctx) (*dto.HppPerFarmResponseData, *dto.HppPerFarmMetaDTO, error) {
params, filters, err := s.parseHppPerFarmQuery(ctx)
if err != nil {
return nil, nil, err
}
if err := s.Validate.Struct(params); err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if s.HppPerFarmRepo == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "hpp per farm repository is not configured")
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
}
startDate, err := time.ParseInLocation("2006-01-02", params.StartDate, location)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "start_date must follow format YYYY-MM-DD")
}
endDate, err := time.ParseInLocation("2006-01-02", params.EndDate, location)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must follow format YYYY-MM-DD")
}
if endDate.Before(startDate) {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date")
}
rangeDays := int(endDate.Sub(startDate).Hours()/24) + 1
if rangeDays > hppPerFarmMaxRangeDays {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "date range must not exceed 366 days")
}
startOfRange := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, location)
endBreakdownDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location)
endExclusive := endBreakdownDate.Add(24 * time.Hour)
startBreakdownDate := startOfRange.AddDate(0, 0, -1)
limit := params.Limit
if limit <= 0 {
limit = 10
}
flockRows, err := s.HppPerFarmRepo.GetCandidateFlocks(ctx.Context(), startOfRange, params.AreaIDs, params.LocationIDs)
if err != nil {
return nil, nil, err
}
if len(flockRows) == 0 {
meta := &dto.HppPerFarmMetaDTO{
Page: params.Page,
Limit: limit,
TotalPages: 1,
TotalResults: 0,
Filters: filters,
}
data := &dto.HppPerFarmResponseData{
StartDate: params.StartDate,
EndDate: params.EndDate,
Rows: []dto.HppPerFarmRowDTO{},
Summary: dto.HppPerFarmSummaryDTO{},
}
return data, meta, nil
}
flockIDs := make([]uint, 0, len(flockRows))
for _, row := range flockRows {
flockIDs = append(flockIDs, row.ProjectFlockID)
}
depByFlock, err := s.sumHppPerFarmDepreciationOverRange(ctx.Context(), startOfRange, endBreakdownDate, flockIDs)
if err != nil {
return nil, nil, err
}
recWeightByFlock, err := s.HppPerFarmRepo.SumRecordingEggWeightByFlock(ctx.Context(), startOfRange, endExclusive, flockIDs)
if err != nil {
return nil, nil, err
}
doWeightByFlock, err := s.HppPerFarmRepo.SumMarketingDoTelurWeightByFlock(ctx.Context(), startOfRange, endExclusive, flockIDs)
if err != nil {
return nil, nil, err
}
docByFlock, err := s.HppPerFarmRepo.GetDocCostByFlock(ctx.Context(), flockIDs)
if err != nil {
return nil, nil, err
}
type hppPerFarmAggregate struct {
locationID uint
locationName string
totalCost float64
feed float64
ovk float64
bop float64
depreciation float64
other float64
recWeight float64
doWeight float64
docCost float64
docQty float64
flocks []dto.HppPerFarmFlockDTO
}
farmOrder := make([]uint, 0)
farms := make(map[uint]*hppPerFarmAggregate)
for _, flock := range flockRows {
flockID := flock.ProjectFlockID
codeTotals, err := s.hppPerFarmFlockCostRange(ctx.Context(), flockID, startBreakdownDate, endBreakdownDate)
if err != nil {
return nil, nil, err
}
feed := codeTotals[hppPerFarmComponentPakan]
ovk := codeTotals[hppPerFarmComponentOvk]
// BOP dihitung range-correct via engine (hindari differential rasio egg-weight yang bisa
// negatif saat share antar kandang bergeser). Keluarkan kode BOP dari codeTotals agar tidak
// ikut terjumlah dua kali di akumulasi 'nonDepreciation'/'other'.
delete(codeTotals, hppPerFarmComponentBopRegular)
delete(codeTotals, hppPerFarmComponentBopEkspedisi)
bop, err := s.hppPerFarmFlockBopRange(ctx.Context(), flockID, startBreakdownDate, endBreakdownDate)
if err != nil {
return nil, nil, err
}
nonDepreciation := bop
for _, value := range codeTotals {
nonDepreciation += value
}
other := nonDepreciation - feed - ovk - bop
depreciation := depByFlock[flockID]
totalCost := nonDepreciation + depreciation
recWeight := recWeightByFlock[flockID]
doWeight := doWeightByFlock[flockID]
averageDocPrice := int64(0)
if doc, ok := docByFlock[flockID]; ok && doc.DocQty > 0 {
averageDocPrice = int64(math.Round(doc.DocCost / doc.DocQty))
}
flockDTO := dto.HppPerFarmFlockDTO{
ProjectFlockID: int64(flockID),
FlockName: flock.FlockName,
TotalCostRp: totalCost,
FeedCostRp: feed,
OvkCostRp: ovk,
BopCostRp: bop,
DepreciationRp: depreciation,
OtherCostRp: other,
EggWeightRecordingKg: recWeight,
EggWeightDoKg: doWeight,
HppPerKgProduction: hppPerFarmSafeDiv(totalCost, recWeight),
HppPerKgSales: hppPerFarmSafeDiv(totalCost, doWeight),
AverageDocPriceRp: averageDocPrice,
}
farm, ok := farms[flock.LocationID]
if !ok {
farm = &hppPerFarmAggregate{
locationID: flock.LocationID,
locationName: flock.LocationName,
flocks: make([]dto.HppPerFarmFlockDTO, 0, 1),
}
farms[flock.LocationID] = farm
farmOrder = append(farmOrder, flock.LocationID)
}
farm.flocks = append(farm.flocks, flockDTO)
farm.totalCost += totalCost
farm.feed += feed
farm.ovk += ovk
farm.bop += bop
farm.depreciation += depreciation
farm.other += other
farm.recWeight += recWeight
farm.doWeight += doWeight
if doc, ok := docByFlock[flockID]; ok {
farm.docCost += doc.DocCost
farm.docQty += doc.DocQty
}
}
rows := make([]dto.HppPerFarmRowDTO, 0, len(farmOrder))
summary := dto.HppPerFarmSummaryDTO{}
for _, locID := range farmOrder {
farm := farms[locID]
averageDocPrice := int64(0)
if farm.docQty > 0 {
averageDocPrice = int64(math.Round(farm.docCost / farm.docQty))
}
rows = append(rows, dto.HppPerFarmRowDTO{
Location: dto.HppPerKandangLocationDTO{ID: int64(farm.locationID), Name: farm.locationName},
TotalCostRp: farm.totalCost,
FeedCostRp: farm.feed,
OvkCostRp: farm.ovk,
BopCostRp: farm.bop,
DepreciationRp: farm.depreciation,
OtherCostRp: farm.other,
EggWeightRecordingKg: farm.recWeight,
EggWeightDoKg: farm.doWeight,
HppPerKgProduction: hppPerFarmSafeDiv(farm.totalCost, farm.recWeight),
HppPerKgSales: hppPerFarmSafeDiv(farm.totalCost, farm.doWeight),
AverageDocPriceRp: averageDocPrice,
Flocks: farm.flocks,
})
summary.TotalCostRp += farm.totalCost
summary.TotalEggWeightRecordingKg += farm.recWeight
summary.TotalEggWeightDoKg += farm.doWeight
}
summary.AverageHppPerKgProduction = hppPerFarmSafeDiv(summary.TotalCostRp, summary.TotalEggWeightRecordingKg)
summary.AverageHppPerKgSales = hppPerFarmSafeDiv(summary.TotalCostRp, summary.TotalEggWeightDoKg)
totalResults := int64(len(rows))
totalPages := int64(1)
if totalResults > 0 {
totalPages = int64(math.Ceil(float64(totalResults) / float64(limit)))
}
offset := (params.Page - 1) * limit
if offset < 0 {
offset = 0
}
if offset > len(rows) {
offset = len(rows)
}
end := offset + limit
if end > len(rows) {
end = len(rows)
}
meta := &dto.HppPerFarmMetaDTO{
Page: params.Page,
Limit: limit,
TotalPages: totalPages,
TotalResults: totalResults,
Filters: filters,
}
data := &dto.HppPerFarmResponseData{
StartDate: params.StartDate,
EndDate: params.EndDate,
Rows: rows[offset:end],
Summary: summary,
}
return data, meta, nil
}
// hppPerFarmFlockCostRange returns the range-scoped production cost per component
// code for a project flock, EXCLUDING depreciation (which is summed separately
// from daily snapshots). Each non-depreciation production component is cumulative
// up to a date in the HPP v2 engine, so the range value is the difference between
// the cumulative breakdown at end and at the day before the range start.
func (s *repportService) hppPerFarmFlockCostRange(ctx context.Context, projectFlockID uint, startBreakdownDate, endBreakdownDate time.Time) (map[string]float64, error) {
if s.HppCostRepo == nil {
return nil, errors.New("hpp cost repository is not configured")
}
if s.HppV2Svc == nil {
return nil, errors.New("hpp v2 service is not configured")
}
codeTotals := make(map[string]float64)
pfkIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, projectFlockID)
if err != nil {
return nil, err
}
for _, pfkID := range pfkIDs {
endBreakdown, err := s.HppV2Svc.CalculateHppBreakdown(pfkID, &endBreakdownDate)
if err != nil {
return nil, err
}
startBreakdown, err := s.HppV2Svc.CalculateHppBreakdown(pfkID, &startBreakdownDate)
if err != nil {
return nil, err
}
endMap := hppPerFarmProductionScopeTotalsByCode(endBreakdown)
startMap := hppPerFarmProductionScopeTotalsByCode(startBreakdown)
seen := make(map[string]bool, len(endMap)+len(startMap))
for code := range endMap {
seen[code] = true
}
for code := range startMap {
seen[code] = true
}
for code := range seen {
if code == hppPerFarmComponentDepreciation {
continue
}
codeTotals[code] += endMap[code] - startMap[code]
}
}
return codeTotals, nil
}
// hppPerFarmFlockBopRange menjumlah BOP production_cost range-correct (BOP_REGULAR + BOP_EKSPEDISI)
// untuk seluruh PFK dalam flock, memakai GetBop*ProductionScopeRange di engine. Pendekatan ini
// menghitung delta expense kumulatif lalu memproratanya dengan rasio akhir-range — bukan
// men-differensiasi dua angka yang sudah diprorata berbeda — sehingga tidak pernah negatif.
func (s *repportService) hppPerFarmFlockBopRange(ctx context.Context, projectFlockID uint, startBreakdownDate, endBreakdownDate time.Time) (float64, error) {
if s.HppCostRepo == nil {
return 0, errors.New("hpp cost repository is not configured")
}
if s.HppV2Svc == nil {
return 0, errors.New("hpp v2 service is not configured")
}
pfkIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, projectFlockID)
if err != nil {
return 0, err
}
total := 0.0
for _, pfkID := range pfkIDs {
reg, err := s.HppV2Svc.GetBopRegularProductionScopeRange(pfkID, &startBreakdownDate, &endBreakdownDate)
if err != nil {
return 0, err
}
eksp, err := s.HppV2Svc.GetBopEkspedisiProductionScopeRange(pfkID, &startBreakdownDate, &endBreakdownDate)
if err != nil {
return 0, err
}
total += reg + eksp
}
return total, nil
}
// sumHppPerFarmDepreciationOverRange sums the daily depreciation_value from
// farm_depreciation_snapshots across [startDate, endDate] per project flock,
// computing (and persisting) any missing daily snapshot on demand — same lazy
// compute path the single-day depreciation report uses.
func (s *repportService) sumHppPerFarmDepreciationOverRange(ctx context.Context, startDate, endDate time.Time, projectFlockIDs []uint) (map[uint]float64, error) {
acc := make(map[uint]float64, len(projectFlockIDs))
if len(projectFlockIDs) == 0 {
return acc, nil
}
if s.ExpenseDepreciationRepo == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
}
for day := startDate; !day.After(endDate); day = day.AddDate(0, 0, 1) {
snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx, day, projectFlockIDs)
if err != nil {
return nil, err
}
byID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots))
for _, snapshot := range snapshots {
byID[snapshot.ProjectFlockId] = snapshot
}
missing := make([]uint, 0)
for _, id := range projectFlockIDs {
if _, ok := byID[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 {
computed, err := s.computeExpenseDepreciationSnapshots(ctx, day, missing, nil)
if err != nil {
return nil, err
}
if len(computed) > 0 {
if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx, computed); err != nil {
return nil, err
}
for _, snapshot := range computed {
byID[snapshot.ProjectFlockId] = snapshot
}
}
}
for id, snapshot := range byID {
acc[id] += snapshot.DepreciationValue
}
}
return acc, nil
}
func hppPerFarmProductionScopeTotalsByCode(breakdown *approvalService.HppV2Breakdown) map[string]float64 {
out := make(map[string]float64)
if breakdown == nil {
return out
}
for i := range breakdown.Components {
comp := &breakdown.Components[i]
out[comp.Code] += hppPerFarmProductionScopeTotal(comp)
}
return out
}
// hppPerFarmProductionScopeTotal mirrors the engine's componentScopeTotal for the
// production_cost scope (that helper is unexported in the common service package).
func hppPerFarmProductionScopeTotal(component *approvalService.HppV2Component) float64 {
if component == nil {
return 0
}
total := 0.0
hasPartScopes := false
for i := range component.Parts {
part := &component.Parts[i]
if len(part.Scopes) == 0 {
continue
}
hasPartScopes = true
for _, scope := range part.Scopes {
if scope == hppPerFarmProductionScope {
total += part.Total
break
}
}
}
if hasPartScopes {
return total
}
for _, scope := range component.Scopes {
if scope == hppPerFarmProductionScope {
return component.Total
}
}
return 0
}
func hppPerFarmSafeDiv(numerator, denominator float64) float64 {
if denominator <= 0 {
return 0
}
value := numerator / denominator
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return value
}
func (s *repportService) parseHppPerFarmQuery(ctx *fiber.Ctx) (*validation.HppPerFarmQuery, dto.HppPerFarmFiltersDTO, error) {
page := ctx.QueryInt("page", 1)
if page < 1 {
page = 1
}
limit := ctx.QueryInt("limit", 10)
if limit < 1 {
limit = 10
}
rawArea := ctx.Query("area_id", "")
rawLocation := ctx.Query("location_id", "")
startDate := ctx.Query("start_date", "")
endDate := ctx.Query("end_date", "")
if strings.TrimSpace(startDate) == "" {
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "start_date is required")
}
if strings.TrimSpace(endDate) == "" {
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "end_date is required")
}
if strings.TrimSpace(rawLocation) == "" {
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "location_id is required")
}
areaIDs, err := parseCommaSeparatedInt64s(rawArea)
if err != nil {
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
locationIDs, err := parseCommaSeparatedInt64s(rawLocation)
if err != nil {
return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB())
if err != nil {
return nil, dto.HppPerFarmFiltersDTO{}, err
}
areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB())
if err != nil {
return nil, dto.HppPerFarmFiltersDTO{}, err
}
if locationScope.Restrict {
allowed := toInt64Slice(locationScope.IDs)
if len(allowed) == 0 {
locationIDs = []int64{-1}
} else if len(locationIDs) > 0 {
locationIDs = intersectInt64(locationIDs, allowed)
} else {
locationIDs = allowed
}
}
if areaScope.Restrict {
allowed := toInt64Slice(areaScope.IDs)
if len(allowed) == 0 {
areaIDs = []int64{-1}
} else if len(areaIDs) > 0 {
areaIDs = intersectInt64(areaIDs, allowed)
} else {
areaIDs = allowed
}
}
params := &validation.HppPerFarmQuery{
Page: page,
Limit: limit,
StartDate: startDate,
EndDate: endDate,
AreaIDs: areaIDs,
LocationIDs: locationIDs,
}
filters := dto.NewHppPerFarmFiltersDTO(rawArea, rawLocation, startDate, endDate)
return params, filters, nil
}
func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validation.ExpenseDepreciationQuery, dto.ExpenseDepreciationFiltersDTO, error) { func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validation.ExpenseDepreciationQuery, dto.ExpenseDepreciationFiltersDTO, error) {
page := ctx.QueryInt("page", 1) page := ctx.QueryInt("page", 1)
if page < 1 { if page < 1 {
@@ -3025,6 +3734,45 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat
return params, filters, nil return params, filters, nil
} }
func (s *repportService) parseExpenseDepreciationV2Query(ctx *fiber.Ctx) (*validation.ExpenseDepreciationV2Query, error) {
limit := ctx.QueryInt("limit", 10)
if limit < 1 {
limit = 10
}
period := strings.TrimSpace(ctx.Query("period", ""))
locationID := ctx.QueryInt("location_id", 0)
projectFlockID := ctx.QueryInt("project_flock_id", 0)
locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB())
if err != nil {
return nil, err
}
if locationScope.Restrict && locationID > 0 {
allowed := toInt64Slice(locationScope.IDs)
if len(allowed) == 0 {
return nil, fiber.NewError(fiber.StatusForbidden, "no location access")
}
found := false
for _, id := range allowed {
if id == int64(locationID) {
found = true
break
}
}
if !found {
return nil, fiber.NewError(fiber.StatusForbidden, "location not in scope")
}
}
return &validation.ExpenseDepreciationV2Query{
Limit: limit,
Period: period,
LocationID: int64(locationID),
ProjectFlockID: int64(projectFlockID),
}, nil
}
func parseCommaSeparatedInt64s(raw string) ([]int64, error) { func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -78,6 +78,15 @@ type HppPerKandangQuery struct {
WeightMax *float64 `query:"-"` WeightMax *float64 `query:"-"`
} }
type HppPerFarmQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
StartDate string `query:"start_date" validate:"required,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"required,datetime=2006-01-02"`
AreaIDs []int64 `query:"-"`
LocationIDs []int64 `query:"-"`
}
type HppV2BreakdownQuery struct { type HppV2BreakdownQuery struct {
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"required,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"required,gt=0"`
Period string `query:"period" validate:"required,datetime=2006-01-02"` Period string `query:"period" validate:"required,datetime=2006-01-02"`
@@ -93,6 +102,13 @@ type ExpenseDepreciationQuery struct {
LocationIDs []int64 `query:"-"` LocationIDs []int64 `query:"-"`
} }
type ExpenseDepreciationV2Query struct {
Limit int `query:"limit" validate:"omitempty,min=1,max=90"`
Period string `query:"period" validate:"required,datetime=2006-01-02"`
LocationID int64 `query:"location_id" validate:"omitempty,gt=0"`
ProjectFlockID int64 `query:"project_flock_id" validate:"required,gt=0"`
}
type ExpenseDepreciationManualInputUpsert struct { type ExpenseDepreciationManualInputUpsert struct {
ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"` ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"`
TotalCost float64 `json:"total_cost" validate:"required,gte=0"` TotalCost float64 `json:"total_cost" validate:"required,gte=0"`