Compare commits

..

86 Commits

Author SHA1 Message Date
giovanni 59d72f20b4 non active phase daily checklist 2026-06-08 21:01:20 +07:00
Giovanni Gabriel Septriadi 540434e33b Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!612
2026-06-08 05:51:32 +00:00
Giovanni Gabriel Septriadi 0ebad48348 Merge branch 'fix/depretiatio-response' into 'development'
adjust total bobot laporan keuangan

See merge request mbugroup/lti-api!611
2026-06-08 05:39:09 +00:00
giovanni 9ab4e1a6ef adjust total bobot laporan keuangan 2026-06-08 12:37:39 +07:00
Giovanni Gabriel Septriadi 0a900986e7 Merge branch 'fix/depretiatio-response' into 'development'
adjust response depretitation v2

See merge request mbugroup/lti-api!610
2026-06-08 05:32:28 +00:00
giovanni 217f35b250 adjust response depretitation v2 2026-06-08 12:30:51 +07:00
Giovanni Gabriel Septriadi b3887b8d08 Merge branch 'hotfix/manual-inputs' into 'production'
fix data manual input; remove update manual input from crud recording

See merge request mbugroup/lti-api!608
2026-06-07 15:20:01 +00:00
Giovanni Gabriel Septriadi 2ddfa57aed Merge branch 'hotfix/manual-inputs' into 'development'
Hotfix/manual inputs

See merge request mbugroup/lti-api!609
2026-06-07 15:01:22 +00:00
giovanni 085d2f9bfe fix data manual input; remove update manual input from crud recording 2026-06-07 21:59:23 +07:00
Giovanni Gabriel Septriadi 61d375a59a Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!607
2026-06-07 12:18:00 +00:00
Giovanni Gabriel Septriadi 09242a6998 Merge branch 'fix/hpp-per-farm' into 'development'
adjust hpp per farm query to take feed and ovk

See merge request mbugroup/lti-api!606
2026-06-07 11:58:01 +00:00
Giovanni Gabriel Septriadi 7639e30326 Merge branch 'fix/recording-population' into 'development'
Fix/recording population

See merge request mbugroup/lti-api!605
2026-06-07 11:56:47 +00:00
giovanni 2216f572c2 fix recording standar prod laying 2026-06-07 18:55:24 +07:00
giovanni edfd6ac95c add command for normalize data recording population not match; adjust closing overhead and keuangan 2026-06-07 16:34:22 +07:00
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 0f12c706b0 fix calculate week create recording 2026-05-30 10:01:22 +07:00
Giovanni Gabriel Septriadi d26c4e9e1a Merge branch 'rc/01' into 'production'
Rc/01

See merge request mbugroup/lti-api!573
2026-05-29 16:28:37 +00:00
Giovanni Gabriel Septriadi be7f3ac82a Merge branch 'feat/trf-dep' into 'rc/01'
Feat/trf dep

See merge request mbugroup/lti-api!575
2026-05-29 15:45:33 +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 a46edc4498 add adjustment depresiasi calculation and percentage depresiasi 2026-05-29 21:48:20 +07:00
Giovanni Gabriel Septriadi 254ce509fb Merge branch 'feat/db' into 'rc/01'
add command for cleanup relesed stock allocations

See merge request mbugroup/lti-api!572
2026-05-29 11:48:36 +00:00
Giovanni Gabriel Septriadi 8624030b39 Merge branch 'fix/nomor-po' into 'rc/01'
normalize data po number to pr number and fix logic to fill field PO number

See merge request mbugroup/lti-api!571
2026-05-29 11:47:51 +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 b4fbef702a Merge branch 'production' into feat/transfer-laying 2026-05-29 16:04:46 +07:00
giovanni 0410169746 normalize data po number to pr number and fix logic to fill field PO number 2026-05-29 16:01:44 +07:00
giovanni bbc7f0f6e9 add command for cleanup relesed stock allocations 2026-05-29 15:02:32 +07:00
Giovanni Gabriel Septriadi 6264b0f08d Merge branch 'feat/fifo-ar' into 'production'
Feat/fifo ar

See merge request mbugroup/lti-api!567
2026-05-29 02:38:43 +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 e6fe4d77eb Merge branch 'fix/jamali' into feat/fifo-ar 2026-05-29 01:54:22 +07:00
giovanni 8ee87a73b7 fix 2026-05-29 01:49:58 +07:00
Giovanni Gabriel Septriadi 8fc41ee8e9 Merge branch 'fix/jamali' into 'production'
Fix/jamali

See merge request mbugroup/lti-api!565
2026-05-28 18:04:30 +00:00
giovanni 8da2b7a3ab ini ar fifo 2026-05-29 00:59:42 +07: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
giovanni fecbcab48d initial refactori trasnfer to laying, and depretitation to 25 week 2026-05-27 15:00:13 +07:00
117 changed files with 8638 additions and 817 deletions
@@ -0,0 +1,304 @@
// Command cleanup-released-stock-allocations menghapus baris stock_allocations
// dengan status='RELEASED' yang sudah lewat masa retensi.
//
// Baris RELEASED muncul dari operasi Rollback / Reflow FIFO v2. Closing reports
// dan flow bisnis hanya membaca status='ACTIVE', sehingga RELEASED rows aman
// dihapus setelah masa retensi tertentu (default 90 hari).
//
// Cara pakai:
//
// go run ./cmd/cleanup-released-stock-allocations/ # dry-run
// go run ./cmd/cleanup-released-stock-allocations/ -apply # apply (90 hari)
// go run ./cmd/cleanup-released-stock-allocations/ -apply -retention-days=30
// go run ./cmd/cleanup-released-stock-allocations/ -apply -batch-size=5000
// go run ./cmd/cleanup-released-stock-allocations/ -apply -skip-vacuum
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
const (
outputTable = "table"
outputJSON = "json"
)
type options struct {
Apply bool
Output string
DBSSLMode string
RetentionDays int
BatchSize int
SkipVacuum bool
}
type sizeStat struct {
TableSize string `json:"table_size" gorm:"column:table_size"`
TotalSize string `json:"total_size" gorm:"column:total_size"`
RowCount int64 `json:"row_count" gorm:"column:row_count"`
}
type runSummary struct {
Mode string `json:"mode"`
RetentionDays int `json:"retention_days"`
CutoffTime string `json:"cutoff_time"`
BatchSize int `json:"batch_size"`
CandidateRows int64 `json:"candidate_rows"`
DeletedRows int64 `json:"deleted_rows,omitempty"`
BatchesExecuted int `json:"batches_executed,omitempty"`
BeforeSize sizeStat `json:"before_size"`
AfterSize sizeStat `json:"after_size,omitempty"`
DurationSeconds float64 `json:"duration_seconds"`
VacuumExecuted bool `json:"vacuum_executed"`
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)
start := time.Now()
cutoff := time.Now().Add(-time.Duration(opts.RetentionDays) * 24 * time.Hour)
summary := runSummary{
RetentionDays: opts.RetentionDays,
CutoffTime: cutoff.UTC().Format(time.RFC3339),
BatchSize: opts.BatchSize,
OverallStatus: "PASS",
}
// Ambil ukuran tabel sebelum cleanup
before, err := fetchSizeStat(ctx, db)
if err != nil {
log.Fatalf("failed to fetch initial size: %v", err)
}
summary.BeforeSize = before
// Hitung kandidat row
candidate, err := countCandidates(ctx, db, cutoff)
if err != nil {
log.Fatalf("failed to count candidates: %v", err)
}
summary.CandidateRows = candidate
if candidate == 0 {
summary.Mode = modeLabel(opts.Apply)
summary.DurationSeconds = time.Since(start).Seconds()
fmt.Printf("No RELEASED rows older than %d days found. Nothing to do.\n", opts.RetentionDays)
render(opts.Output, summary)
return
}
if !opts.Apply {
summary.Mode = "DRY_RUN"
summary.DurationSeconds = time.Since(start).Seconds()
render(opts.Output, summary)
fmt.Println()
fmt.Println("Re-run with -apply to actually delete the rows above.")
return
}
summary.Mode = "APPLY"
deleted, batches, err := applyCleanup(ctx, db, cutoff, opts.BatchSize)
summary.DeletedRows = deleted
summary.BatchesExecuted = batches
if err != nil {
summary.OverallStatus = "FAIL"
render(opts.Output, summary)
log.Fatalf("apply failed after %d batches (%d rows deleted): %v", batches, deleted, err)
}
// VACUUM ANALYZE supaya space benar-benar dibebaskan ke OS
if !opts.SkipVacuum {
if err := runVacuum(ctx, db); err != nil {
// VACUUM gagal jangan-mengaborkan, log saja
log.Printf("WARN: VACUUM ANALYZE gagal: %v", err)
} else {
summary.VacuumExecuted = true
}
}
after, err := fetchSizeStat(ctx, db)
if err != nil {
log.Printf("WARN: gagal ambil ukuran tabel setelah cleanup: %v", err)
} else {
summary.AfterSize = after
}
summary.DurationSeconds = time.Since(start).Seconds()
render(opts.Output, summary)
}
func parseFlags() (*options, error) {
var opts options
flag.BoolVar(&opts.Apply, "apply", false, "Apply deletion (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.IntVar(&opts.RetentionDays, "retention-days", 90, "Keep RELEASED rows newer than N days")
flag.IntVar(&opts.BatchSize, "batch-size", 10000, "Rows deleted per transaction")
flag.BoolVar(&opts.SkipVacuum, "skip-vacuum", false, "Skip VACUUM ANALYZE after cleanup")
flag.Parse()
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
if opts.Output == "" {
opts.Output = outputTable
}
if opts.Output != outputTable && opts.Output != outputJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.RetentionDays < 0 {
return nil, fmt.Errorf("retention-days must be >= 0, got %d", opts.RetentionDays)
}
if opts.BatchSize <= 0 {
return nil, fmt.Errorf("batch-size must be > 0, got %d", opts.BatchSize)
}
return &opts, nil
}
func countCandidates(ctx context.Context, db *gorm.DB, cutoff time.Time) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("status = ?", entities.StockAllocationStatusReleased).
Where("released_at IS NOT NULL AND released_at < ?", cutoff).
Count(&count).Error
return count, err
}
func applyCleanup(ctx context.Context, db *gorm.DB, cutoff time.Time, batchSize int) (int64, int, error) {
var totalDeleted int64
batches := 0
for {
var affected int64
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Pakai CTE supaya LIMIT bisa dipakai bersama DELETE di PostgreSQL.
// `released_at IS NOT NULL` defensif — rows lama dari migrasi mungkin
// NULL meski status=RELEASED.
res := tx.Exec(`
DELETE FROM stock_allocations
WHERE id IN (
SELECT id FROM stock_allocations
WHERE status = ?
AND released_at IS NOT NULL
AND released_at < ?
ORDER BY id ASC
LIMIT ?
)
`, entities.StockAllocationStatusReleased, cutoff, batchSize)
if res.Error != nil {
return res.Error
}
affected = res.RowsAffected
return nil
})
if err != nil {
return totalDeleted, batches, err
}
if affected == 0 {
break
}
totalDeleted += affected
batches++
log.Printf("batch %d: deleted %d rows (running total: %d)", batches, affected, totalDeleted)
if affected < int64(batchSize) {
break
}
}
return totalDeleted, batches, nil
}
func runVacuum(ctx context.Context, db *gorm.DB) error {
// VACUUM tidak bisa di-jalankan dalam transaksi.
// gorm SkipDefaultTransaction sudah true, tapi tetap aman menggunakan raw DB.
sqlDB, err := db.DB()
if err != nil {
return err
}
_, err = sqlDB.ExecContext(ctx, "VACUUM ANALYZE stock_allocations")
return err
}
func fetchSizeStat(ctx context.Context, db *gorm.DB) (sizeStat, error) {
var stat sizeStat
err := db.WithContext(ctx).Raw(`
SELECT
pg_size_pretty(pg_relation_size('stock_allocations')) AS table_size,
pg_size_pretty(pg_total_relation_size('stock_allocations')) AS total_size,
(SELECT COUNT(*) FROM stock_allocations)::bigint AS row_count
`).Scan(&stat).Error
return stat, err
}
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("=== Cleanup RELEASED stock_allocations ===\n")
fmt.Printf("Mode : %s\n", summary.Mode)
fmt.Printf("Retention days : %d (cutoff < %s)\n", summary.RetentionDays, summary.CutoffTime)
fmt.Printf("Batch size : %d\n", summary.BatchSize)
fmt.Printf("Candidate rows : %d\n", summary.CandidateRows)
fmt.Printf("\n--- Before ---\n")
fmt.Printf("Total rows : %d\n", summary.BeforeSize.RowCount)
fmt.Printf("Table size : %s\n", summary.BeforeSize.TableSize)
fmt.Printf("Total size (idx) : %s\n", summary.BeforeSize.TotalSize)
if summary.Mode == "APPLY" {
fmt.Printf("\n--- Apply ---\n")
fmt.Printf("Deleted rows : %d\n", summary.DeletedRows)
fmt.Printf("Batches executed : %d\n", summary.BatchesExecuted)
fmt.Printf("VACUUM executed : %v\n", summary.VacuumExecuted)
if summary.AfterSize.RowCount > 0 || summary.AfterSize.TableSize != "" {
fmt.Printf("\n--- After ---\n")
fmt.Printf("Total rows : %d\n", summary.AfterSize.RowCount)
fmt.Printf("Table size : %s\n", summary.AfterSize.TableSize)
fmt.Printf("Total size (idx) : %s\n", summary.AfterSize.TotalSize)
}
}
fmt.Printf("\nDuration : %.2fs\n", summary.DurationSeconds)
fmt.Printf("Overall status : %s\n", summary.OverallStatus)
}
@@ -0,0 +1,266 @@
// Command normalize-recording-cutover-depletion
//
// Data-only normalization of recording population metrics for a cut-over flock
// where pre-cutover mortality (culling + dead) was booked via stock adjustments
// (which do NOT feed the recording population). It applies an "opening depletion"
// offset to the CUMULATIVE depletion of every recording in a project_flock_kandang,
// recomputing the population-dependent metric columns DIRECTLY on the `recordings`
// table.
//
// It does NOT touch recording_depletions, stock_allocations, product_warehouses,
// project_flock_populations, or adjustment_stocks — so inventory/FIFO stay intact
// (the existing adjustments keep owning the stock movement).
//
// Recomputed columns (per recording, ordered by record_datetime,id):
//
// cumDepByDate = running SUM(recording_depletions.qty) up to that recording (INVARIANT)
// new_tcq = initialChickin - cumDepByDate - opening
// cum_depletion_rate = (cumDepByDate + opening) / initialChickin * 100
// feed_intake = feed_intake_old * (old_total_chick_qty / new_tcq) [null->null]
// fcr_value = fcr_value_old * (old_total_chick_qty / new_tcq) [null->null]
//
// cum_intake and egg-based metrics are left untouched (see plan).
//
// Idempotent: only rows where total_chick_qty IS DISTINCT FROM new_tcq are updated.
// Self-check: run with -opening=0; consistent rows are no-ops, any row that changes
// was already inconsistent (stale) and gets reconciled to follow the depletion data.
//
// Usage:
//
// DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=0 # self-check dry-run
// DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=3126 # dry-run
// DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=3126 -apply # apply
package main
import (
"flag"
"fmt"
"log"
"math"
"os"
"text/tabwriter"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
type recRow struct {
ID uint `gorm:"column:id"`
Day *int `gorm:"column:day"`
RecordDate string `gorm:"column:record_date"`
TotalChickQty *float64 `gorm:"column:total_chick_qty"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
FeedIntake *float64 `gorm:"column:feed_intake"`
FcrValue *float64 `gorm:"column:fcr_value"`
CumDepByDate float64 `gorm:"column:cum_dep_by_date"`
}
const eps = 1e-6
func main() {
var (
pfk uint
opening float64
apply bool
chickinOverride float64
)
flag.UintVar(&pfk, "pfk", 0, "project_flock_kandangs_id (required)")
flag.Float64Var(&opening, "opening", 0, "opening depletion qty added to cumulative depletion of every recording")
flag.BoolVar(&apply, "apply", false, "apply changes (default: dry-run)")
flag.Float64Var(&chickinOverride, "chickin", 0, "override initial chickin base (0 = auto SUM project_chickins.usage_qty)")
flag.Parse()
if pfk == 0 {
log.Fatal("-pfk is required")
}
db := database.Connect(config.DBHost, config.DBName)
// 1) initial chickin base
var initialChickin float64
if chickinOverride > 0 {
initialChickin = chickinOverride
} else {
if err := db.Raw(
`SELECT COALESCE(SUM(usage_qty),0) FROM project_chickins WHERE project_flock_kandang_id = ?`, pfk,
).Scan(&initialChickin).Error; err != nil {
log.Fatalf("query initial chickin: %v", err)
}
}
if initialChickin <= 0 {
log.Fatalf("initial chickin <= 0 for pfk %d (got %.3f)", pfk, initialChickin)
}
// 2) sanity: duplicate record_datetime would make cumulative-by-date ambiguous
var dupDatetimes int64
if err := db.Raw(
`SELECT COUNT(*) FROM (
SELECT record_datetime FROM recordings
WHERE project_flock_kandangs_id = ? AND deleted_at IS NULL
GROUP BY record_datetime HAVING COUNT(*) > 1
) t`, pfk,
).Scan(&dupDatetimes).Error; err != nil {
log.Fatalf("check duplicate datetimes: %v", err)
}
if dupDatetimes > 0 {
fmt.Printf("WARNING: %d duplicate record_datetime group(s) for pfk %d — cumulative-by-date ordering may be ambiguous; review carefully.\n\n", dupDatetimes, pfk)
}
// 3) load recordings + running cumulative depletion (by record_datetime, id)
var rows []recRow
q := `
WITH dep AS (
SELECT r.id, r.day, r.record_datetime,
r.total_chick_qty, r.cum_depletion_rate, r.feed_intake, r.fcr_value,
COALESCE((SELECT SUM(rd.qty) FROM recording_depletions rd WHERE rd.recording_id = r.id), 0) AS daily_dep
FROM recordings r
WHERE r.project_flock_kandangs_id = ? AND r.deleted_at IS NULL
)
SELECT id, day,
to_char(record_datetime, 'YYYY-MM-DD') AS record_date,
total_chick_qty, cum_depletion_rate, feed_intake, fcr_value,
SUM(daily_dep) OVER (ORDER BY record_datetime, id) AS cum_dep_by_date
FROM dep
ORDER BY record_datetime, id`
if err := db.Raw(q, pfk).Scan(&rows).Error; err != nil {
log.Fatalf("query recordings: %v", err)
}
if len(rows) == 0 {
log.Fatalf("no recordings found for pfk %d", pfk)
}
mode := "DRY-RUN"
if apply {
mode = "APPLY"
}
fmt.Printf("=== normalize-recording-cutover-depletion ===\n")
fmt.Printf("Mode: %s | pfk=%d | initialChickin=%.3f | opening=%.3f | recordings=%d\n\n", mode, pfk, initialChickin, opening, len(rows))
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(tw, "id\tday\tdate\ttcq_old->new\tcumRate_old->new\tfeed_old->new\tfcr_old->new\tstatus")
var willChange, anomalies, skipped int
var negTcq int
for _, r := range rows {
newTcq := initialChickin - r.CumDepByDate - opening
newRate := (r.CumDepByDate + opening) / initialChickin * 100
status := ""
// detect pre-existing inconsistency (stale row): old tcq != invariant base (opening=0 expectation)
expectedBase := initialChickin - r.CumDepByDate
if r.TotalChickQty == nil || math.Abs(*r.TotalChickQty-expectedBase) > 1e-3 {
status = "ANOMALY"
anomalies++
}
if newTcq < -eps {
status = "NEG_TCQ!"
negTcq++
}
// idempotent guard
if r.TotalChickQty != nil && math.Abs(*r.TotalChickQty-newTcq) < 1e-6 {
if status == "" {
status = "noop"
}
skipped++
} else {
willChange++
}
var newFeed, newFcr *float64
if r.FeedIntake != nil && r.TotalChickQty != nil && math.Abs(newTcq) > eps {
v := *r.FeedIntake * (*r.TotalChickQty / newTcq)
newFeed = &v
} else {
newFeed = r.FeedIntake
}
if r.FcrValue != nil && r.TotalChickQty != nil && math.Abs(newTcq) > eps {
v := *r.FcrValue * (*r.TotalChickQty / newTcq)
newFcr = &v
} else {
newFcr = r.FcrValue
}
fmt.Fprintf(tw, "%d\t%s\t%s\t%s -> %.3f\t%s -> %.3f\t%s -> %s\t%s -> %s\t%s\n",
r.ID, iptr(r.Day), r.RecordDate,
fptr(r.TotalChickQty), newTcq,
fptr(r.CumDepletionRate), newRate,
fptr(r.FeedIntake), fptrV(newFeed),
fptr(r.FcrValue), fptrV(newFcr),
status,
)
}
tw.Flush()
fmt.Printf("\nSummary: will_change=%d skipped(noop)=%d anomalies=%d neg_tcq=%d\n", willChange, skipped, anomalies, negTcq)
if negTcq > 0 {
log.Fatalf("ABORT: %d recording(s) would get negative total_chick_qty — opening too large or data issue", negTcq)
}
if !apply {
fmt.Println("\nDry-run only. Re-run with -apply to persist.")
return
}
// 4) APPLY — single set-based UPDATE in a transaction (RHS uses pre-update column values)
err := db.Transaction(func(tx *gorm.DB) error {
res := tx.Exec(`
WITH dep AS (
SELECT r.id, r.record_datetime,
COALESCE((SELECT SUM(rd.qty) FROM recording_depletions rd WHERE rd.recording_id = r.id), 0) AS daily_dep
FROM recordings r
WHERE r.project_flock_kandangs_id = ? AND r.deleted_at IS NULL
),
calc AS (
SELECT id,
(? - cum_dep - ?) AS new_tcq,
((cum_dep + ?) / ? * 100) AS new_rate
FROM (
SELECT id, SUM(daily_dep) OVER (ORDER BY record_datetime, id) AS cum_dep
FROM dep
) s
)
UPDATE recordings r SET
total_chick_qty = c.new_tcq,
cum_depletion_rate = c.new_rate,
feed_intake = CASE WHEN r.feed_intake IS NULL OR r.total_chick_qty IS NULL OR c.new_tcq = 0
THEN r.feed_intake ELSE r.feed_intake * (r.total_chick_qty / c.new_tcq) END,
fcr_value = CASE WHEN r.fcr_value IS NULL OR r.total_chick_qty IS NULL OR c.new_tcq = 0
THEN r.fcr_value ELSE r.fcr_value * (r.total_chick_qty / c.new_tcq) END,
updated_at = NOW()
FROM calc c
WHERE r.id = c.id
AND r.total_chick_qty IS DISTINCT FROM c.new_tcq`,
pfk,
initialChickin, opening,
opening, initialChickin,
)
if res.Error != nil {
return res.Error
}
fmt.Printf("\nAPPLIED: %d recording row(s) updated.\n", res.RowsAffected)
return nil
})
if err != nil {
log.Fatalf("apply failed: %v", err)
}
fmt.Println("Done. Verify with the queries in tmp/pfk91-cutover-fix.md.")
}
func fptr(p *float64) string {
if p == nil {
return "null"
}
return fmt.Sprintf("%.3f", *p)
}
func fptrV(p *float64) string { return fptr(p) }
func iptr(p *int) string {
if p == nil {
return "-"
}
return fmt.Sprintf("%d", *p)
}
+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)
}
}
@@ -96,18 +96,31 @@ type HppV2FarmDepreciationSnapshotRow struct {
DepreciationPercentEffective float64 DepreciationPercentEffective float64
DepreciationValue float64 DepreciationValue float64
PulletCostDayNTotal float64 PulletCostDayNTotal float64
Components []byte
} }
type HppV2CostRepository interface { type HppV2CostRepository interface {
GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error)
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error) GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error)
// GetAllTransferInputsByProjectFlockKandangID return SEMUA approved transfer ke target kandang
// itu, untuk skenario multi-source di mana 1 target menerima dari multiple transfer terpisah.
// Setiap row = 1 transfer dengan cost basis & chick_in_date sendiri (per source). Order:
// effective_date ASC, id ASC (kronologis).
GetAllTransferInputsByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) ([]HppV2LatestTransferInputRow, error)
GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error) GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error)
GetRecordingStockRoutingAdjustmentCostByProjectFlockID(ctx context.Context, projectFlockID uint, periodDate time.Time) (float64, error) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(ctx context.Context, projectFlockID uint, periodDate time.Time) (float64, error)
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)
GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) GetChickinPopulationByPFKForFarm(ctx context.Context, projectFlockID uint) (map[uint]float64, error)
GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, projectFlockID uint) (map[string]map[int]float64, map[string]*time.Time, error)
ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error) 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)
@@ -230,6 +243,62 @@ LIMIT 1
return &row, nil return &row, nil
} }
func (r *HppV2RepositoryImpl) GetAllTransferInputsByProjectFlockKandangID(
ctx context.Context,
projectFlockKandangId uint,
period time.Time,
) ([]HppV2LatestTransferInputRow, error) {
var rows []HppV2LatestTransferInputRow
query := `
WITH latest_transfer_approval AS (
SELECT a.approvable_id, a.action
FROM approvals a
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = @approval_type
GROUP BY approvable_id
) la
ON la.approvable_id = a.approvable_id
AND la.latest_action_at = a.action_at
WHERE a.approvable_type = @approval_type
),
approved_transfers AS (
SELECT
lt.id,
lt.from_project_flock_id,
COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) AS effective_date
FROM laying_transfers lt
JOIN latest_transfer_approval lta ON lta.approvable_id = lt.id
WHERE lt.deleted_at IS NULL
AND lt.executed_at IS NOT NULL
AND lta.action = 'APPROVED'
)
SELECT
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
at.from_project_flock_id AS source_project_flock_id,
at.effective_date AS transfer_date,
ltt.total_qty AS transfer_qty,
at.id AS transfer_id
FROM laying_transfer_targets ltt
JOIN approved_transfers at ON at.id = ltt.laying_transfer_id
WHERE ltt.deleted_at IS NULL
AND ltt.target_project_flock_kandang_id = @project_flock_kandang_id
AND at.effective_date <= DATE(@period_date)
ORDER BY at.effective_date ASC, at.id ASC
`
err := r.db.WithContext(ctx).Raw(query, map[string]any{
"approval_type": utils.ApprovalWorkflowTransferToLaying.String(),
"project_flock_kandang_id": projectFlockKandangId,
"period_date": period,
}).Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID( func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID(
ctx context.Context, ctx context.Context,
projectFlockID uint, projectFlockID uint,
@@ -305,18 +374,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
@@ -335,7 +405,7 @@ func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeri
var row HppV2FarmDepreciationSnapshotRow var row HppV2FarmDepreciationSnapshotRow
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("farm_depreciation_snapshots"). Table("farm_depreciation_snapshots").
Select("id, project_flock_id, period_date, depreciation_percent_effective, depreciation_value, pullet_cost_day_n_total"). Select("id, project_flock_id, period_date, depreciation_percent_effective, depreciation_value, pullet_cost_day_n_total, components").
Where("project_flock_id = ?", projectFlockID). Where("project_flock_id = ?", projectFlockID).
Where("period_date = DATE(?)", periodDate). Where("period_date = DATE(?)", periodDate).
Limit(1). Limit(1).
@@ -373,42 +443,76 @@ func (r *HppV2RepositoryImpl) GetEarliestChickInDateByProjectFlockID(ctx context
return selected.ChickInDate, nil return selected.ChickInDate, nil
} }
func (r *HppV2RepositoryImpl) GetDepreciationPercents( func (r *HppV2RepositoryImpl) GetChickinPopulationByPFKForFarm(
ctx context.Context, ctx context.Context,
houseTypes []string, projectFlockID uint,
maxDay int, ) (map[uint]float64, error) {
) (map[string]map[int]float64, error) {
result := make(map[string]map[int]float64)
if len(houseTypes) == 0 || maxDay <= 0 {
return result, nil
}
type row struct { type row struct {
HouseType string ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
Day int TotalQty float64 `gorm:"column:total_qty"`
DepreciationPercent float64
} }
var rows []row
rows := make([]row, 0)
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("house_depreciation_standards"). Table("project_chickins AS pc").
Select("house_type::text AS house_type, day, depreciation_percent"). Select("pc.project_flock_kandang_id, SUM(pc.usage_qty) AS total_qty").
Where("house_type::text IN ?", houseTypes). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Where("day <= ?", maxDay). Where("pc.deleted_at IS NULL").
Order("house_type ASC, day ASC"). Where("pfk.project_flock_id = ?", projectFlockID).
Group("pc.project_flock_kandang_id").
Scan(&rows).Error Scan(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make(map[uint]float64, len(rows))
for _, x := range rows {
result[x.ProjectFlockKandangID] = x.TotalQty
}
return result, nil
}
func (r *HppV2RepositoryImpl) GetMultiplicationPercentages(
ctx context.Context,
houseTypes []string,
maxDay int,
projectFlockID uint,
) (map[string]map[int]float64, map[string]*time.Time, error) {
result := make(map[string]map[int]float64)
effectiveDates := make(map[string]*time.Time)
if len(houseTypes) == 0 || maxDay <= 0 {
return result, effectiveDates, nil
}
type row struct {
HouseType string
Day int
MultiplicationPercentage float64
EffectiveDate *time.Time
}
rows := make([]row, 0)
err := r.db.WithContext(ctx).Raw(`
SELECT DISTINCT ON (house_type::text, day)
house_type::text AS house_type, day, multiplication_percentage, effective_date
FROM house_depreciation_standards
WHERE house_type::text IN ? AND day <= ?
AND (project_flock_ids IS NULL OR ? = ANY(project_flock_ids))
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 {
return nil, nil, err
}
for _, item := range rows { for _, item := range rows {
if _, exists := result[item.HouseType]; !exists { if _, exists := result[item.HouseType]; !exists {
result[item.HouseType] = make(map[int]float64) result[item.HouseType] = make(map[int]float64)
} }
result[item.HouseType][item.Day] = item.DepreciationPercent result[item.HouseType][item.Day] = item.MultiplicationPercentage
if _, tracked := effectiveDates[item.HouseType]; !tracked {
effectiveDates[item.HouseType] = item.EffectiveDate
}
} }
return result, nil return result, effectiveDates, nil
} }
func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags( func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
@@ -489,6 +593,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 {
@@ -6,8 +6,8 @@ import (
) )
const ( const (
depreciationStartAgeDayCloseHouse = 155 depreciationStartAgeDayCloseHouse = 175
depreciationStartAgeDayOpenHouse = 176 depreciationStartAgeDayOpenHouse = 175
) )
func NormalizeDepreciationHouseType(raw string) string { func NormalizeDepreciationHouseType(raw string) string {
@@ -26,8 +26,8 @@ func DepreciationStartAgeDay(houseType string) int {
} }
func FlockAgeDay(originDate time.Time, periodDate time.Time) int { func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location()) origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, time.UTC)
period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location()) period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, time.UTC)
if period.Before(origin) { if period.Before(origin) {
return 0 return 0
} }
@@ -47,9 +47,9 @@ func CalculateDepreciationAtDayN(
initialPulletCost float64, initialPulletCost float64,
dayN int, dayN int,
houseType string, houseType string,
percentByHouseType map[string]map[int]float64, multiplicationByHouseType map[string]map[int]float64,
) (float64, float64, float64) { ) (float64, float64, float64) {
return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType) return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, multiplicationByHouseType)
} }
func CalculateDepreciationFromDayRange( func CalculateDepreciationFromDayRange(
@@ -57,8 +57,8 @@ func CalculateDepreciationFromDayRange(
startDay int, startDay int,
endDay int, endDay int,
houseType string, houseType string,
percentByHouseType map[string]map[int]float64, multiplicationByHouseType map[string]map[int]float64,
) (float64, float64, float64) { ) (pulletCostDayN, depreciationValue, multiplicationPercentage float64) {
if initialPulletCost <= 0 || endDay <= 0 { if initialPulletCost <= 0 || endDay <= 0 {
return 0, 0, 0 return 0, 0, 0
} }
@@ -70,30 +70,30 @@ func CalculateDepreciationFromDayRange(
} }
normalizedHouseType := NormalizeDepreciationHouseType(houseType) normalizedHouseType := NormalizeDepreciationHouseType(houseType)
housePercent, exists := percentByHouseType[normalizedHouseType] houseMult, exists := multiplicationByHouseType[normalizedHouseType]
if !exists { if !exists {
return 0, 0, 0 return 0, 0, 0
} }
current := initialPulletCost current := initialPulletCost
pulletCostDayN := 0.0
depreciationValue := 0.0
depreciationPercent := 0.0
for day := startDay; day <= endDay; day++ { for day := startDay; day <= endDay; day++ {
pct := housePercent[day] mult, ok := houseMult[day]
dep := current * (pct / 100) if !ok {
// No standard for this day → assume no depreciation (mult=1).
mult = 1.0
}
if day == endDay { if day == endDay {
pulletCostDayN = current pulletCostDayN = current
depreciationValue = dep multiplicationPercentage = mult
depreciationPercent = pct depreciationValue = current * (1.0 - mult)
} }
current -= dep current = current * mult
if current < 0 { if current < 0 {
current = 0 current = 0
} }
} }
return pulletCostDayN, depreciationValue, depreciationPercent return pulletCostDayN, depreciationValue, multiplicationPercentage
} }
func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 { func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 {
@@ -0,0 +1,393 @@
package service
import (
"context"
"fmt"
"math"
"strings"
"time"
"github.com/sirupsen/logrus"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
// ParentKind enumerasi parent yang punya grand_total dari SUM children.
type ParentKind string
const (
ParentKindPurchase ParentKind = "PURCHASE"
ParentKindMarketing ParentKind = "MARKETING"
ParentKindExpense ParentKind = "EXPENSE"
)
// AllocationKind enumerasi sub-row anak target FIFO allocation.
type AllocationKind string
const (
AllocKindPurchaseItem AllocationKind = "PURCHASE_ITEM"
AllocKindMarketingDeliveryProduct AllocationKind = "MDP"
AllocKindExpenseRealization AllocationKind = "EXPENSE_REALIZATION"
)
// fifoEpsilon untuk float comparison saat FIFO matching.
const fifoEpsilon = 0.001
// FifoPaymentService meng-orchestrate FIFO allocation antara payments dan
// sub-row anak (purchase_items / marketing_delivery_products / expense_realizations).
type FifoPaymentService interface {
// ReallocateForParty wipe allocations untuk semua payment party tsb,
// lalu re-FIFO dari history (sort children by date ASC, payments by payment_date ASC).
// Caller WAJIB pass tx untuk konsistensi dengan mutasi upstream.
ReallocateForParty(ctx context.Context, tx *gorm.DB, partyType string, partyID uint) error
// RecomputeGrandTotal refresh parent.grand_total = SUM children eligible amount.
RecomputeGrandTotal(ctx context.Context, tx *gorm.DB, kind ParentKind, parentID uint) error
}
type fifoPaymentService struct {
db *gorm.DB
logger *logrus.Logger
}
func NewFifoPaymentService(db *gorm.DB, logger *logrus.Logger) FifoPaymentService {
if logger == nil {
logger = logrus.StandardLogger()
}
return &fifoPaymentService{db: db, logger: logger}
}
func (s *fifoPaymentService) txOrDB(tx *gorm.DB) *gorm.DB {
if tx != nil {
return tx
}
return s.db
}
type childRow struct {
Kind AllocationKind
ChildID uint64
Amount float64
Remaining float64
}
type paymentRow struct {
ID uint
Nominal float64
Date time.Time
}
// ReallocateForParty acquire advisory lock then perform full re-FIFO.
// Jika tx nil, function buka transaction sendiri (advisory lock harus dalam TX).
func (s *fifoPaymentService) ReallocateForParty(ctx context.Context, tx *gorm.DB, partyType string, partyID uint) error {
if partyID == 0 {
return nil
}
party := strings.ToUpper(strings.TrimSpace(partyType))
if party != string(utils.PaymentPartyCustomer) && party != string(utils.PaymentPartySupplier) {
return fmt.Errorf("fifoPayment: invalid party_type %q", partyType)
}
if tx == nil {
return s.db.WithContext(ctx).Transaction(func(innerTx *gorm.DB) error {
return s.reallocateInTx(ctx, innerTx, party, partyID)
})
}
return s.reallocateInTx(ctx, tx, party, partyID)
}
func (s *fifoPaymentService) reallocateInTx(ctx context.Context, tx *gorm.DB, party string, partyID uint) error {
db := tx.WithContext(ctx)
// Advisory lock per (party_type, party_id) — 1-arg form (bigint).
// Postgres 2-arg form butuh kedua param int4, sedangkan party_id bisa lebih besar.
lockKey := fmt.Sprintf("payment_alloc:%s:%d", party, partyID)
if err := db.Exec("SELECT pg_advisory_xact_lock(hashtext(?)::bigint)", lockKey).Error; err != nil {
return fmt.Errorf("fifoPayment: advisory lock: %w", err)
}
// Wipe existing allocations untuk semua payment party tsb
if err := db.Exec(`
DELETE FROM payment_allocations
WHERE payment_id IN (
SELECT id FROM payments
WHERE party_type = ? AND party_id = ? AND deleted_at IS NULL
)
`, party, partyID).Error; err != nil {
return fmt.Errorf("fifoPayment: wipe allocations: %w", err)
}
children, err := s.fetchChildren(ctx, db, party, partyID)
if err != nil {
return err
}
if len(children) == 0 {
return nil
}
// Fetch SEMUA payments termasuk SALDO_AWAL agar allocation tercatat di DB
// (SaldoAwal opening credit harus consume oldest debts; tanpa allocation row,
// debt yang ter-cover SaldoAwal akan tampak "Belum Lunas" di report).
payments, err := s.fetchAllPayments(ctx, db, party, partyID)
if err != nil {
return err
}
// Greedy: per payment, alokasi ke children tertua dengan remaining > 0
allocs := make([]entity.PaymentAllocation, 0, len(payments))
now := time.Now()
for _, pay := range payments {
remaining := pay.Nominal
if remaining <= fifoEpsilon {
continue
}
for i := range children {
if remaining <= fifoEpsilon {
break
}
if children[i].Remaining <= fifoEpsilon {
continue
}
used := math.Min(remaining, children[i].Remaining)
children[i].Remaining -= used
remaining -= used
alloc := entity.PaymentAllocation{
PaymentId: pay.ID,
Amount: used,
AllocatedAt: now,
}
switch children[i].Kind {
case AllocKindPurchaseItem:
id := uint(children[i].ChildID)
alloc.PurchaseItemId = &id
case AllocKindMarketingDeliveryProduct:
id := uint(children[i].ChildID)
alloc.MarketingDeliveryProductId = &id
case AllocKindExpenseRealization:
id := children[i].ChildID
alloc.ExpenseRealizationId = &id
}
allocs = append(allocs, alloc)
}
}
if len(allocs) == 0 {
return nil
}
// Batch insert allocations
if err := db.CreateInBatches(&allocs, 500).Error; err != nil {
return fmt.Errorf("fifoPayment: insert allocations: %w", err)
}
return nil
}
// fetchChildren return eligible sub-rows sorted by date ASC, id ASC.
func (s *fifoPaymentService) fetchChildren(ctx context.Context, db *gorm.DB, party string, partyID uint) ([]childRow, error) {
if party == string(utils.PaymentPartySupplier) {
return s.fetchSupplierChildren(ctx, db, partyID)
}
return s.fetchCustomerChildren(ctx, db, partyID)
}
func (s *fifoPaymentService) fetchSupplierChildren(ctx context.Context, db *gorm.DB, supplierID uint) ([]childRow, error) {
// purchase_items eligible: purchases approval latest step >= Receiving (4), action != REJECTED, received_date IS NOT NULL
var purchaseRows []chronoRow
purchaseSQL := `
SELECT 'PURCHASE_ITEM' AS kind,
pi.id::BIGINT AS child_id,
pi.total_price AS amount,
pi.received_date AS sort_date,
pi.id::BIGINT AS sort_id
FROM purchase_items pi
JOIN purchases p ON p.id = pi.purchase_id
JOIN LATERAL (
SELECT a.step_number, a.action
FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = p.id
ORDER BY a.action_at DESC, a.id DESC
LIMIT 1
) la ON TRUE
WHERE p.supplier_id = ?
AND p.deleted_at IS NULL
AND pi.received_date IS NOT NULL
AND la.step_number >= ?
AND (la.action IS NULL OR la.action <> ?)
AND pi.total_price > 0
ORDER BY pi.received_date ASC, pi.id ASC
`
if err := db.WithContext(ctx).Raw(purchaseSQL,
string(utils.ApprovalWorkflowPurchase),
supplierID,
uint16(utils.PurchaseStepReceiving),
string(entity.ApprovalActionRejected),
).Scan(&purchaseRows).Error; err != nil {
return nil, fmt.Errorf("fifoPayment: fetch purchase items: %w", err)
}
// expense_realizations via expense_nonstocks → expenses, approval latest step >= Realisasi (5)
// Sort pakai e.transaction_date (bukan realization_date) supaya FIFO match dengan tanggal yang
// dipakai report sebagai "tanggal dokumen" — user assume FIFO = lunasi yang transaction_date paling tua dulu.
var expenseRows []chronoRow
expenseSQL := `
SELECT 'EXPENSE_REALIZATION' AS kind,
er.id::BIGINT AS child_id,
(er.qty * er.price) AS amount,
e.transaction_date AS sort_date,
er.id::BIGINT AS sort_id
FROM expense_realizations er
JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id
JOIN expenses e ON e.id = en.expense_id
JOIN LATERAL (
SELECT a.step_number, a.action
FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = e.id
ORDER BY a.action_at DESC, a.id DESC
LIMIT 1
) la ON TRUE
WHERE e.supplier_id = ?
AND e.deleted_at IS NULL
AND la.step_number >= ?
AND (la.action IS NULL OR la.action <> ?)
AND (er.qty * er.price) > 0
ORDER BY e.transaction_date ASC, e.id ASC, er.id ASC
`
if err := db.WithContext(ctx).Raw(expenseSQL,
string(utils.ApprovalWorkflowExpense),
supplierID,
uint16(utils.ExpenseStepRealisasi),
string(entity.ApprovalActionRejected),
).Scan(&expenseRows).Error; err != nil {
return nil, fmt.Errorf("fifoPayment: fetch expense realizations: %w", err)
}
// Merge in chronological order (kedua list sudah sorted; merge stable)
merged := mergeSortedByDate(purchaseRows, expenseRows)
out := make([]childRow, 0, len(merged))
for _, r := range merged {
out = append(out, childRow{
Kind: AllocationKind(r.Kind),
ChildID: r.ChildID,
Amount: r.Amount,
Remaining: r.Amount,
})
}
return out, nil
}
func (s *fifoPaymentService) fetchCustomerChildren(ctx context.Context, db *gorm.DB, customerID uint) ([]childRow, error) {
var mdpRows []chronoRow
sql := `
SELECT 'MDP' AS kind,
mdp.id::BIGINT AS child_id,
mdp.total_price AS amount,
mdp.delivery_date AS sort_date,
mdp.id::BIGINT AS sort_id
FROM marketing_delivery_products mdp
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
JOIN marketings m ON m.id = mp.marketing_id
WHERE m.customer_id = ?
AND m.deleted_at IS NULL
AND mdp.delivery_date IS NOT NULL
AND mdp.total_price > 0
ORDER BY mdp.delivery_date ASC, mdp.id ASC
`
if err := db.WithContext(ctx).Raw(sql, customerID).Scan(&mdpRows).Error; err != nil {
return nil, fmt.Errorf("fifoPayment: fetch marketing delivery products: %w", err)
}
out := make([]childRow, 0, len(mdpRows))
for _, r := range mdpRows {
out = append(out, childRow{
Kind: AllocationKind(r.Kind),
ChildID: r.ChildID,
Amount: r.Amount,
Remaining: r.Amount,
})
}
return out, nil
}
// fetchAllPayments return SEMUA payments (termasuk SALDO_AWAL) sort by payment_date ASC, id ASC.
// SALDO_AWAL diperlakukan sebagai payment tertua agar opening credit otomatis consume oldest debts via FIFO.
func (s *fifoPaymentService) fetchAllPayments(ctx context.Context, db *gorm.DB, party string, partyID uint) ([]paymentRow, error) {
var rows []paymentRow
sql := `
SELECT id, nominal, payment_date AS date
FROM payments
WHERE party_type = ? AND party_id = ?
AND deleted_at IS NULL
AND nominal > 0
ORDER BY payment_date ASC, id ASC
`
if err := db.WithContext(ctx).Raw(sql, party, partyID).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("fifoPayment: fetch payments: %w", err)
}
return rows, nil
}
// RecomputeGrandTotal refresh parent.grand_total dari SUM children eligible amount.
func (s *fifoPaymentService) RecomputeGrandTotal(ctx context.Context, tx *gorm.DB, kind ParentKind, parentID uint) error {
db := s.txOrDB(tx).WithContext(ctx)
if parentID == 0 {
return nil
}
switch kind {
case ParentKindPurchase:
return db.Exec(`
UPDATE purchases p
SET grand_total = COALESCE((SELECT SUM(total_price) FROM purchase_items WHERE purchase_id = p.id), 0)
WHERE p.id = ?
`, parentID).Error
case ParentKindMarketing:
return db.Exec(`
UPDATE marketings m
SET grand_total = COALESCE((
SELECT SUM(mdp.total_price)
FROM marketing_delivery_products mdp
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
WHERE mp.marketing_id = m.id AND mdp.delivery_date IS NOT NULL
), 0)
WHERE m.id = ?
`, parentID).Error
case ParentKindExpense:
return db.Exec(`
UPDATE expenses e
SET grand_total = COALESCE((
SELECT SUM(er.qty * er.price)
FROM expense_realizations er
JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id
WHERE en.expense_id = e.id
), 0)
WHERE e.id = ?
`, parentID).Error
default:
return fmt.Errorf("fifoPayment: unknown parent kind %q", kind)
}
}
// chronoRow row antara untuk merge sort children.
type chronoRow struct {
Kind string
ChildID uint64
Amount float64
SortDate time.Time
SortID uint64
}
func mergeSortedByDate(a, b []chronoRow) []chronoRow {
out := make([]chronoRow, 0, len(a)+len(b))
i, j := 0, 0
for i < len(a) && j < len(b) {
if a[i].SortDate.Before(b[j].SortDate) ||
(a[i].SortDate.Equal(b[j].SortDate) && a[i].SortID < b[j].SortID) {
out = append(out, a[i])
i++
} else {
out = append(out, b[j])
j++
}
}
out = append(out, a[i:]...)
out = append(out, b[j:]...)
return out
}
+389 -76
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"encoding/json"
"time" "time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
@@ -55,6 +56,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 +458,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 +467,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 +742,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 +784,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 +946,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 +999,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, Basis: basis,
hppV2PartLayingFarm, Numerator: numerator,
"Laying Farm", Denominator: denominator,
[]string{hppV2ScopeProductionCost}, Ratio: ratio,
&HppV2Proration{ }, nil
Basis: basis, }
Numerator: numerator,
Denominator: denominator, // GetExpenseProductionScopeRange menghitung BOP production_cost satu komponen expense untuk rentang
Ratio: ratio, // [startDate, endDate] secara range-correct (tidak pernah negatif untuk expense non-negatif).
}, // - laying-direct (ratio 1, monoton): selisih kumulatif end - start.
ratio, // - laying-farm (prorated): (expenseCum(end) - expenseCum(start)) × ratio(end).
), nil //
// 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(
@@ -1191,26 +1330,72 @@ func (s *hppV2Service) getDepreciationComponent(
}, nil }, nil
} }
if totalPulletCost <= 0 { // Multi-source support: 1 target kandang bisa menerima dari MULTIPLE transfer terpisah
return nil, nil // (tiap transfer = 1 source kandang). Depresiasi per target = SUM dari per-transfer depresiasi.
} // Setiap transfer dihitung dengan chick_in_date source-nya sendiri dan cost basis pro-rated
// berdasarkan qty share (transfer.qty / totalTransferQty).
transferInput, err := s.hppRepo.GetLatestTransferInputByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate) transferInputs, err := s.hppRepo.GetAllTransferInputsByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var part *HppV2ComponentPart // Filter valid transfers (punya source flock id)
if transferInput != nil && transferInput.SourceProjectFlockID > 0 { validTransfers := make([]commonRepo.HppV2LatestTransferInputRow, 0, len(transferInputs))
part, err = s.buildNormalTransferDepreciationPart(contextRow, transferInput, periodDate, totalPulletCost) totalTransferQty := 0.0
if err != nil { for _, t := range transferInputs {
return nil, err if t.SourceProjectFlockID == 0 {
continue
} }
} else { validTransfers = append(validTransfers, t)
part, err = s.buildManualCutoverDepreciationPart(projectFlockKandangId, contextRow, periodDate, totalPulletCost) totalTransferQty += t.TransferQty
if err != nil { }
return nil, err
if len(validTransfers) > 0 {
if totalPulletCost <= 0 {
return nil, nil
} }
totalDepreciation := 0.0
parts := make([]HppV2ComponentPart, 0, len(validTransfers))
for i := range validTransfers {
t := validTransfers[i]
// Pro-rate cost basis per transfer berdasarkan qty share.
// CATATAN: pendekatan ini AKURAT kalau cost per ekor sama antar source flock.
// Kalau cost per ekor berbeda signifikan antar source, follow-up: refactor
// `buildGrowingUsagePart` untuk multi-source-flock cost computation.
transferCostBasis := totalPulletCost
if totalTransferQty > 0 && len(validTransfers) > 1 {
transferCostBasis = totalPulletCost * (t.TransferQty / totalTransferQty)
}
part, partErr := s.buildNormalTransferDepreciationPart(contextRow, &t, periodDate, transferCostBasis)
if partErr != nil {
return nil, partErr
}
if part == nil {
continue
}
totalDepreciation += part.Total
parts = append(parts, *part)
}
if len(parts) == 0 {
return nil, nil
}
return &HppV2Component{
Code: hppV2ComponentDepreciation,
Title: "Depreciation",
Scopes: []string{hppV2ScopeProductionCost},
Total: totalDepreciation,
Parts: parts,
}, nil
}
// Fallback: manual cut-over (kandang tanpa transfer record)
part, err := s.buildManualCutoverDepreciationPart(projectFlockKandangId, contextRow, periodDate, totalPulletCost)
if err != nil {
return nil, err
} }
if part == nil { if part == nil {
return nil, nil return nil, nil
@@ -1288,6 +1473,18 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
depreciationPercent = (appliedDepreciation / appliedPulletCostDayN) * 100 depreciationPercent = (appliedDepreciation / appliedPulletCostDayN) * 100
} }
details := map[string]any{
"basis_total": snapshot.DepreciationValue,
"pullet_cost_day_n": appliedPulletCostDayN,
"depreciation_percent": depreciationPercent,
"snapshot_id": snapshot.ID,
"snapshot_period_date": formatDateOnly(snapshot.PeriodDate),
"snapshot_project_flock": snapshot.ProjectFlockID,
}
for key, value := range farmDepreciationSnapshotMetadata(snapshot.Components, projectFlockKandangId) {
details[key] = value
}
return &HppV2ComponentPart{ return &HppV2ComponentPart{
Code: hppV2PartDepreciationFarmSnapshot, Code: hppV2PartDepreciationFarmSnapshot,
Title: "Farm Snapshot", Title: "Farm Snapshot",
@@ -1299,14 +1496,7 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
Denominator: denominator, Denominator: denominator,
Ratio: ratio, Ratio: ratio,
}, },
Details: map[string]any{ Details: details,
"basis_total": snapshot.DepreciationValue,
"pullet_cost_day_n": appliedPulletCostDayN,
"depreciation_percent": depreciationPercent,
"snapshot_id": snapshot.ID,
"snapshot_period_date": formatDateOnly(snapshot.PeriodDate),
"snapshot_project_flock": snapshot.ProjectFlockID,
},
References: []HppV2Reference{ References: []HppV2Reference{
{ {
Type: "farm_depreciation_snapshot", Type: "farm_depreciation_snapshot",
@@ -1320,6 +1510,84 @@ func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
}, nil }, nil
} }
type farmDepreciationSnapshotComponents struct {
Kandang []farmDepreciationSnapshotKandangComponent `json:"kandang"`
}
type farmDepreciationSnapshotKandangComponent struct {
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
DayN int `json:"day_n"`
MultiplicationPercent float64 `json:"multiplication_percentage"`
ChickinDate string `json:"chickin_date"`
OriginDate string `json:"origin_date"`
StandardEffectiveDate string `json:"standard_effective_date"`
Population float64 `json:"population"`
}
func farmDepreciationSnapshotMetadata(raw []byte, projectFlockKandangID uint) map[string]any {
result := make(map[string]any)
if len(raw) == 0 {
return result
}
var components farmDepreciationSnapshotComponents
if err := json.Unmarshal(raw, &components); err != nil {
return result
}
var fallback *farmDepreciationSnapshotKandangComponent
for i := range components.Kandang {
component := &components.Kandang[i]
if !component.hasDepreciationMetadata() {
continue
}
if component.ProjectFlockKandangID == projectFlockKandangID {
return component.snapshotDetails()
}
if fallback == nil {
fallback = component
}
}
if fallback != nil {
return fallback.snapshotDetails()
}
return result
}
func (c farmDepreciationSnapshotKandangComponent) hasDepreciationMetadata() bool {
return c.DayN > 0 ||
c.MultiplicationPercent > 0 ||
c.ChickinDate != "" ||
c.OriginDate != "" ||
c.StandardEffectiveDate != "" ||
c.Population > 0
}
func (c farmDepreciationSnapshotKandangComponent) snapshotDetails() map[string]any {
chickinDate := c.ChickinDate
if chickinDate == "" {
chickinDate = c.OriginDate
}
details := map[string]any{
"schedule_day": c.DayN,
"multiplication_percentage": c.MultiplicationPercent,
}
if chickinDate != "" {
details["origin_date"] = chickinDate
details["chickin_date"] = chickinDate
}
if c.StandardEffectiveDate != "" {
details["standard_effective_date"] = c.StandardEffectiveDate
}
if c.Population > 0 {
details["kandang_population"] = c.Population
}
return details
}
func (s *hppV2Service) buildNormalTransferDepreciationPart( func (s *hppV2Service) buildNormalTransferDepreciationPart(
contextRow *commonRepo.HppV2ProjectFlockKandangContext, contextRow *commonRepo.HppV2ProjectFlockKandangContext,
transferInput *commonRepo.HppV2LatestTransferInputRow, transferInput *commonRepo.HppV2LatestTransferInputRow,
@@ -1344,20 +1612,27 @@ func (s *hppV2Service) buildNormalTransferDepreciationPart(
} }
houseType := NormalizeDepreciationHouseType(contextRow.HouseType) houseType := NormalizeDepreciationHouseType(contextRow.HouseType)
percentByHouseType, err := s.hppRepo.GetDepreciationPercents(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
} }
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN( pulletCostDayN, depreciationValue, multiplicationPercentage := CalculateDepreciationAtDayN(
totalPulletCost, totalPulletCost,
scheduleDay, scheduleDay,
contextRow.HouseType, contextRow.HouseType,
percentByHouseType, multiplicationByHouseType,
) )
if depreciationValue <= 0 { if depreciationValue <= 0 && pulletCostDayN <= 0 {
return nil, nil return nil, nil
} }
totalValueAfter := pulletCostDayN * multiplicationPercentage
depreciationPercent := (1.0 - multiplicationPercentage) * 100.0
var standardEffectiveDate string
if ed, ok := effectiveDates[houseType]; ok && ed != nil {
standardEffectiveDate = formatDateOnly(*ed)
}
return &HppV2ComponentPart{ return &HppV2ComponentPart{
Code: hppV2PartDepreciationNormal, Code: hppV2PartDepreciationNormal,
@@ -1365,13 +1640,17 @@ func (s *hppV2Service) buildNormalTransferDepreciationPart(
Scopes: []string{hppV2ScopeProductionCost}, Scopes: []string{hppV2ScopeProductionCost},
Total: depreciationValue, Total: depreciationValue,
Details: map[string]any{ Details: map[string]any{
"basis_total": totalPulletCost, "basis_total": totalPulletCost,
"pullet_cost_day_n": pulletCostDayN, "pullet_cost_day_n": pulletCostDayN,
"depreciation_percent": depreciationPercent, "multiplication_percentage": multiplicationPercentage,
"schedule_day": scheduleDay, "total_value_pullet_after_depreciation": totalValueAfter,
"origin_date": formatDateOnly(*originDate), "depreciation_percent": depreciationPercent,
"transfer_date": formatDateOnly(transferInput.TransferDate), "schedule_day": scheduleDay,
"source_project_flock_id": transferInput.SourceProjectFlockID, "origin_date": formatDateOnly(*originDate),
"transfer_date": formatDateOnly(transferInput.TransferDate),
"source_project_flock_id": transferInput.SourceProjectFlockID,
"standard_effective_date": standardEffectiveDate,
"kandang_population": transferInput.TransferQty,
}, },
References: []HppV2Reference{ References: []HppV2Reference{
{ {
@@ -1392,7 +1671,7 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart(
periodDate time.Time, periodDate time.Time,
totalPulletCost float64, totalPulletCost float64,
) (*HppV2ComponentPart, error) { ) (*HppV2ComponentPart, error) {
if contextRow == nil || totalPulletCost <= 0 { if contextRow == nil {
return nil, nil return nil, nil
} }
@@ -1407,6 +1686,21 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart(
return nil, nil return nil, nil
} }
populations, err := s.hppRepo.GetChickinPopulationByPFKForFarm(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
var totalPopulation float64
for _, qty := range populations {
totalPopulation += qty
}
kandangPopulation := populations[projectFlockKandangId]
if totalPopulation <= 0 || kandangPopulation <= 0 {
return nil, nil
}
populationShare := kandangPopulation / totalPopulation
basis := manualInput.TotalCost * populationShare
originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), contextRow.ProjectFlockID) originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), contextRow.ProjectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1415,33 +1709,46 @@ 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)
percentByHouseType, err := s.hppRepo.GetDepreciationPercents(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
} }
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange( pulletCostDayN, depreciationValue, multiplicationPercentage := CalculateDepreciationFromDayRange(
totalPulletCost, basis,
startDay, startDay,
reportScheduleDay, reportScheduleDay,
contextRow.HouseType, contextRow.HouseType,
percentByHouseType, multiplicationByHouseType,
) )
if depreciationValue <= 0 { if depreciationValue <= 0 && pulletCostDayN <= 0 {
return nil, nil return nil, nil
} }
totalValueAfter := pulletCostDayN * multiplicationPercentage
depreciationPercent := (1.0 - multiplicationPercentage) * 100.0
_ = totalPulletCost
var standardEffectiveDate string
if ed, ok := effectiveDates[houseType]; ok && ed != nil {
standardEffectiveDate = formatDateOnly(*ed)
}
return &HppV2ComponentPart{ return &HppV2ComponentPart{
Code: hppV2PartDepreciationCutover, Code: hppV2PartDepreciationCutover,
@@ -1449,15 +1756,21 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart(
Scopes: []string{hppV2ScopeProductionCost}, Scopes: []string{hppV2ScopeProductionCost},
Total: depreciationValue, Total: depreciationValue,
Details: map[string]any{ Details: map[string]any{
"basis_total": totalPulletCost, "basis_total": basis,
"pullet_cost_day_n": pulletCostDayN, "manual_input_total": manualInput.TotalCost,
"depreciation_percent": depreciationPercent, "population_share": populationShare,
"schedule_day": reportScheduleDay, "pullet_cost_day_n": pulletCostDayN,
"start_schedule_day": startDay, "multiplication_percentage": multiplicationPercentage,
"origin_date": formatDateOnly(*originDate), "total_value_pullet_after_depreciation": totalValueAfter,
"cutover_date": formatDateOnly(manualInput.CutoverDate), "depreciation_percent": depreciationPercent,
"manual_input_id": manualInput.ID, "schedule_day": reportScheduleDay,
"project_flock_kandang": projectFlockKandangId, "start_schedule_day": startDay,
"origin_date": formatDateOnly(*originDate),
"cutover_date": formatDateOnly(manualInput.CutoverDate),
"manual_input_id": manualInput.ID,
"project_flock_kandang": projectFlockKandangId,
"standard_effective_date": standardEffectiveDate,
"kandang_population": kandangPopulation,
}, },
References: []HppV2Reference{ References: []HppV2Reference{
{ {
@@ -1465,7 +1778,7 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart(
ID: manualInput.ID, ID: manualInput.ID,
Date: formatDateOnly(manualInput.CutoverDate), Date: formatDateOnly(manualInput.CutoverDate),
Qty: 1, Qty: 1,
Total: totalPulletCost, Total: manualInput.TotalCost,
AppliedTotal: depreciationValue, AppliedTotal: depreciationValue,
}, },
}, },
@@ -1724,7 +2037,7 @@ func partHasScope(part *HppV2ComponentPart, scope string) bool {
} }
func dateOnly(value time.Time) time.Time { func dateOnly(value time.Time) time.Time {
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC)
} }
func formatDateOnly(value time.Time) string { func formatDateOnly(value time.Time) string {
@@ -25,9 +25,13 @@ 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
routeCostByProject map[uint]float64 // expenseRowsByFarmDateKey (opsional) membuat ListExpenseRealizationRowsByProjectFlockID
totalPopulationByKey map[string]float64 // date-aware untuk menguji perhitungan range BOP. Bila non-nil, dipakai menggantikan
transferSummaryByPFK map[uint]struct { // expenseRowsByFarmKey; key = "<flock>|<ekspedisi>|<YYYY-MM-DD>".
expenseRowsByFarmDateKey map[string][]commonRepo.HppV2ExpenseCostRow
routeCostByProject map[uint]float64
totalPopulationByKey map[string]float64
transferSummaryByPFK map[uint]struct {
projectFlockID uint projectFlockID uint
totalQty float64 totalQty float64
} }
@@ -57,6 +61,14 @@ func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.
return s.latestTransferByPFK[projectFlockKandangId], nil return s.latestTransferByPFK[projectFlockKandangId], nil
} }
func (s *hppV2RepoStub) GetAllTransferInputsByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) ([]commonRepo.HppV2LatestTransferInputRow, error) {
row := s.latestTransferByPFK[projectFlockKandangId]
if row == nil {
return []commonRepo.HppV2LatestTransferInputRow{}, nil
}
return []commonRepo.HppV2LatestTransferInputRow{*row}, nil
}
func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) { func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) {
return s.manualInputByProject[projectFlockID], nil return s.manualInputByProject[projectFlockID], nil
} }
@@ -93,10 +105,27 @@ func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []
return result, nil return result, nil
} }
// GetMultiplicationPercentages — alias yang sama dengan GetDepreciationPercents untuk match
// interface HppV2CostRepository (interface dipakai method name baru ini).
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)
return vals, make(map[string]*time.Time), err
}
// GetChickinPopulationByPFKForFarm — return populasi per PFK dari satu project flock.
// Stub minimal: return empty map (depreciation manual cutover tidak di-test di sini).
func (s *hppV2RepoStub) GetChickinPopulationByPFKForFarm(_ context.Context, _ uint) (map[uint]float64, error) {
return map[uint]float64{}, nil
}
func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) { func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil 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
} }
@@ -105,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
} }
@@ -793,6 +825,28 @@ func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggPro
DepreciationPercentEffective: 10, DepreciationPercentEffective: 10,
DepreciationValue: 1000, DepreciationValue: 1000,
PulletCostDayNTotal: 10000, PulletCostDayNTotal: 10000,
Components: []byte(`{
"kandang_count": 2,
"total_population": 1000,
"kandang": [
{
"project_flock_kandang_id": 71,
"day_n": 5,
"multiplication_percentage": 0.95,
"chickin_date": "2026-01-02",
"standard_effective_date": "2026-06-01",
"population": 800
},
{
"project_flock_kandang_id": 70,
"day_n": 7,
"multiplication_percentage": 0.93,
"chickin_date": "2026-01-01",
"standard_effective_date": "2026-06-02",
"population": 200
}
]
}`),
}, },
}, },
eggProductionByPFK: map[uint]struct { eggProductionByPFK: map[uint]struct {
@@ -841,6 +895,21 @@ func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggPro
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) { if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details) t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details)
} }
if depreciation.Parts[0].Details["schedule_day"] != 7 {
t.Fatalf("expected snapshot schedule_day 7, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["multiplication_percentage"] != 0.93 {
t.Fatalf("expected snapshot multiplication_percentage 0.93, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["chickin_date"] != "2026-01-01" {
t.Fatalf("expected snapshot chickin_date 2026-01-01, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["standard_effective_date"] != "2026-06-02" {
t.Fatalf("expected snapshot standard_effective_date 2026-06-02, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["kandang_population"] != float64(200) {
t.Fatalf("expected snapshot kandang_population 200, got %+v", depreciation.Parts[0].Details)
}
} }
func stubKey(ids []uint, flags []string) string { func stubKey(ids []uint, flags []string) string {
@@ -883,6 +952,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,9 +195,12 @@ 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 {
@@ -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(
+4 -1
View File
@@ -121,9 +121,12 @@ func init() {
// Redis // Redis
RedisURL = viper.GetString("REDIS_URL") RedisURL = viper.GetString("REDIS_URL")
// TransferToLayingGrowingMaxWeek: batas umur (minggu dari chick_in) yang masih boleh ditransfer ke laying.
// Disatukan dengan depreciation_start_age_day = 175 hari = 25 minggu, agar konsisten antara batas transfer
// dan kapan depresiasi mulai berjalan.
TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK") TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK")
if TransferToLayingGrowingMaxWeek <= 0 { if TransferToLayingGrowingMaxWeek <= 0 {
TransferToLayingGrowingMaxWeek = 19 TransferToLayingGrowingMaxWeek = 25
} }
// Object storage // Object storage
@@ -0,0 +1,3 @@
ALTER TABLE marketings DROP COLUMN IF EXISTS grand_total;
ALTER TABLE expenses DROP COLUMN IF EXISTS grand_total;
ALTER TABLE purchases DROP COLUMN IF EXISTS grand_total;
@@ -0,0 +1,42 @@
-- Marketing belum punya grand_total. Tambahkan dengan DEFAULT 0.
ALTER TABLE marketings ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
-- Expense grand_total sebelumnya di-drop di migration 20251125055613. Re-add.
ALTER TABLE expenses ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
ALTER TABLE purchases ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
-- Backfill nilai grand_total dari children:
-- marketings.grand_total = SUM marketing_delivery_products.total_price (WHERE delivery_date IS NOT NULL)
UPDATE marketings m
SET grand_total = COALESCE(s.t, 0)
FROM (
SELECT mp.marketing_id AS marketing_id, SUM(mdp.total_price) AS t
FROM marketing_delivery_products mdp
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
WHERE mdp.delivery_date IS NOT NULL
GROUP BY mp.marketing_id
) s
WHERE s.marketing_id = m.id;
-- expenses.grand_total = SUM(expense_realizations.qty * expense_realizations.price) via expense_nonstocks
UPDATE expenses e
SET grand_total = COALESCE(s.t, 0)
FROM (
SELECT en.expense_id AS expense_id, SUM(er.qty * er.price) AS t
FROM expense_realizations er
JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id
GROUP BY en.expense_id
) s
WHERE s.expense_id = e.id;
-- purchases.grand_total sudah ada sejak migration 20251104084555.
-- Recompute juga untuk safety supaya konsisten dengan SUM purchase_items.total_price.
UPDATE purchases p
SET grand_total = COALESCE(s.t, 0)
FROM (
SELECT purchase_id, SUM(total_price) AS t
FROM purchase_items
GROUP BY purchase_id
) s
WHERE s.purchase_id = p.id;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_payments_party_active;
DROP INDEX IF EXISTS idx_mdp_delivery_date_partial;
DROP INDEX IF EXISTS idx_purchase_items_received_date_partial;
DROP TABLE IF EXISTS payment_allocations;
@@ -0,0 +1,27 @@
-- Tabel payment_allocations menyimpan hasil FIFO matching antara payment dengan
-- sub-row anak (purchase_item / marketing_delivery_product / expense_realization).
-- Setiap allocation row HARUS terhubung ke tepat 1 child via 3 nullable FK
-- (polymorphic-via-multiple-nullable-FK; lebih aman dari single polymorphic kolom).
CREATE TABLE IF NOT EXISTS payment_allocations (
id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
purchase_item_id BIGINT NULL REFERENCES purchase_items(id) ON DELETE CASCADE,
marketing_delivery_product_id BIGINT NULL REFERENCES marketing_delivery_products(id) ON DELETE CASCADE,
expense_realization_id BIGINT NULL REFERENCES expense_realizations(id) ON DELETE CASCADE,
amount NUMERIC(15, 3) NOT NULL CHECK (amount > 0),
allocated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_payment_alloc_exactly_one CHECK (
num_nonnulls(purchase_item_id, marketing_delivery_product_id, expense_realization_id) = 1
)
);
CREATE INDEX IF NOT EXISTS idx_payment_alloc_payment ON payment_allocations (payment_id);
CREATE INDEX IF NOT EXISTS idx_payment_alloc_purchase_item ON payment_allocations (purchase_item_id) WHERE purchase_item_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_payment_alloc_mdp ON payment_allocations (marketing_delivery_product_id) WHERE marketing_delivery_product_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_payment_alloc_realization ON payment_allocations (expense_realization_id) WHERE expense_realization_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_payment_alloc_allocated_at ON payment_allocations (allocated_at);
-- Helper partial indexes untuk FIFO loop performance
CREATE INDEX IF NOT EXISTS idx_purchase_items_received_date_partial ON purchase_items (received_date) WHERE received_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_mdp_delivery_date_partial ON marketing_delivery_products (delivery_date) WHERE delivery_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_payments_party_active ON payments (party_type, party_id, payment_date) WHERE deleted_at IS NULL;
@@ -0,0 +1,4 @@
-- Rollback backfill: hapus semua allocations dan drop function.
TRUNCATE payment_allocations;
DROP FUNCTION IF EXISTS fn_fifo_backfill_party(TEXT, BIGINT);
@@ -0,0 +1,170 @@
-- Backfill payment_allocations untuk data historis via FIFO simulation.
-- Seluruh migration ini berjalan dalam 1 transaction (golang-migrate default).
-- Jika ada party yang gagal di tengah loop, seluruh backfill ROLLBACK otomatis.
-- Fungsi inti: FIFO greedy untuk 1 party (supplier/customer).
-- Algoritma:
-- 1. Hapus payment_allocations existing untuk party tsb (idempotent).
-- 2. Kumpulkan eligible children sort by date ASC ke array (kind, id, amount, remaining).
-- 3. Konsumsi creditCarry (SUM payment SALDO_AWAL) ke children tertua — TIDAK insert allocation row.
-- 4. Loop payments (selain SALDO_AWAL) ORDER BY payment_date ASC: greedy alokasi ke child tertua dengan remaining > 0.
-- 5. Sisa nominal payment tidak insert row (otomatis credit balance untuk dokumen baru).
CREATE OR REPLACE FUNCTION fn_fifo_backfill_party(
p_party_type TEXT,
p_party_id BIGINT
) RETURNS VOID AS $func$
DECLARE
v_party_type TEXT := UPPER(p_party_type);
v_payment RECORD;
v_child RECORD;
v_remaining NUMERIC(15, 3);
v_used NUMERIC(15, 3);
v_eps CONSTANT NUMERIC(15, 3) := 0.001;
BEGIN
-- Acquire advisory lock untuk anti-race (1-arg form: hashtext returns int4, cast ke bigint)
PERFORM pg_advisory_xact_lock(hashtext('payment_alloc:' || v_party_type || ':' || p_party_id::text)::bigint);
-- Hapus allocations existing untuk party tsb (idempotent ulang-jalan)
DELETE FROM payment_allocations pa
USING payments p
WHERE pa.payment_id = p.id
AND p.party_type = v_party_type
AND p.party_id = p_party_id;
-- TEMP table untuk antrian children (sort sudah ada di INSERT...SELECT ORDER BY)
CREATE TEMP TABLE IF NOT EXISTS _children_queue (
seq BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL, -- 'PURCHASE_ITEM' / 'MDP' / 'EXPENSE_REALIZATION'
child_id BIGINT NOT NULL,
amount NUMERIC(15, 3) NOT NULL,
remaining NUMERIC(15, 3) NOT NULL
) ON COMMIT DROP;
TRUNCATE _children_queue;
IF v_party_type = 'SUPPLIER' THEN
-- purchase_items eligible: received_date IS NOT NULL, approval latest step >= 4 (Receiving), action != REJECTED
INSERT INTO _children_queue (kind, child_id, amount, remaining)
SELECT 'PURCHASE_ITEM', pi.id, pi.total_price, pi.total_price
FROM purchase_items pi
JOIN purchases p ON p.id = pi.purchase_id
JOIN LATERAL (
SELECT a.step_number, a.action
FROM approvals a
WHERE a.approvable_type = 'PURCHASES' AND a.approvable_id = p.id
ORDER BY a.action_at DESC, a.id DESC
LIMIT 1
) la ON true
WHERE p.supplier_id = p_party_id
AND p.deleted_at IS NULL
AND pi.received_date IS NOT NULL
AND la.step_number >= 4
AND (la.action IS NULL OR la.action <> 'REJECTED')
AND pi.total_price > 0
ORDER BY pi.received_date ASC, pi.id ASC;
-- expense_realizations eligible: parent expense approval latest step >= 5 (Realisasi), action != REJECTED.
-- Sort pakai e.transaction_date supaya FIFO konsisten dengan tanggal yang di-display di report.
INSERT INTO _children_queue (kind, child_id, amount, remaining)
SELECT 'EXPENSE_REALIZATION', er.id, (er.qty * er.price), (er.qty * er.price)
FROM expense_realizations er
JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id
JOIN expenses e ON e.id = en.expense_id
JOIN LATERAL (
SELECT a.step_number, a.action
FROM approvals a
WHERE a.approvable_type = 'EXPENSES' AND a.approvable_id = e.id
ORDER BY a.action_at DESC, a.id DESC
LIMIT 1
) la ON true
WHERE e.supplier_id = p_party_id
AND e.deleted_at IS NULL
AND la.step_number >= 5
AND (la.action IS NULL OR la.action <> 'REJECTED')
AND (er.qty * er.price) > 0
ORDER BY e.transaction_date ASC, e.id ASC, er.id ASC;
ELSIF v_party_type = 'CUSTOMER' THEN
-- marketing_delivery_products eligible: delivery_date IS NOT NULL (match current report behavior, tidak filter approval)
INSERT INTO _children_queue (kind, child_id, amount, remaining)
SELECT 'MDP', mdp.id, mdp.total_price, mdp.total_price
FROM marketing_delivery_products mdp
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
JOIN marketings m ON m.id = mp.marketing_id
WHERE m.customer_id = p_party_id
AND m.deleted_at IS NULL
AND mdp.delivery_date IS NOT NULL
AND mdp.total_price > 0
ORDER BY mdp.delivery_date ASC, mdp.id ASC;
ELSE
RETURN;
END IF;
-- Skip jika tidak ada children eligible
IF NOT EXISTS (SELECT 1 FROM _children_queue) THEN
RETURN;
END IF;
-- Loop SEMUA payments termasuk SALDO_AWAL ORDER BY payment_date ASC, id ASC.
-- SALDO_AWAL diperlakukan sebagai payment tertua sehingga opening credit otomatis
-- consume oldest debts via FIFO. Tanpa allocation row, debt yang ter-cover SaldoAwal
-- akan tampak "Belum Lunas" di report.
FOR v_payment IN
SELECT id, nominal
FROM payments
WHERE party_type = v_party_type
AND party_id = p_party_id
AND deleted_at IS NULL
AND nominal > v_eps
ORDER BY payment_date ASC, id ASC
LOOP
v_remaining := v_payment.nominal;
-- Greedy alokasi ke children tertua dengan remaining > 0
FOR v_child IN
SELECT seq, kind, child_id, remaining
FROM _children_queue
WHERE remaining > v_eps
ORDER BY seq ASC
LOOP
EXIT WHEN v_remaining <= v_eps;
-- v_child.remaining is snapshot at cursor open; re-fetch latest to avoid drift in same payment iter
SELECT remaining INTO v_used FROM _children_queue WHERE seq = v_child.seq;
IF v_used <= v_eps THEN
CONTINUE;
END IF;
v_used := LEAST(v_remaining, v_used);
UPDATE _children_queue SET remaining = remaining - v_used WHERE seq = v_child.seq;
v_remaining := v_remaining - v_used;
IF v_child.kind = 'PURCHASE_ITEM' THEN
INSERT INTO payment_allocations (payment_id, purchase_item_id, amount, allocated_at)
VALUES (v_payment.id, v_child.child_id, v_used, NOW());
ELSIF v_child.kind = 'MDP' THEN
INSERT INTO payment_allocations (payment_id, marketing_delivery_product_id, amount, allocated_at)
VALUES (v_payment.id, v_child.child_id, v_used, NOW());
ELSIF v_child.kind = 'EXPENSE_REALIZATION' THEN
INSERT INTO payment_allocations (payment_id, expense_realization_id, amount, allocated_at)
VALUES (v_payment.id, v_child.child_id, v_used, NOW());
END IF;
END LOOP;
END LOOP;
END;
$func$ LANGUAGE plpgsql;
-- Invoke per-party. Gagal di satu party → entire transaction ROLLBACK.
DO $do$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT DISTINCT party_type, party_id
FROM payments
WHERE deleted_at IS NULL
AND party_id IS NOT NULL
LOOP
PERFORM fn_fifo_backfill_party(r.party_type, r.party_id);
END LOOP;
END;
$do$;
@@ -0,0 +1,8 @@
-- IRREVERSIBLE migration: po_number lama (counter-based) tidak di-backup
-- saat UP karena user secara eksplisit pilih "tanpa backup table".
-- Down ini hanya raise notice supaya operator sadar harus restore dari
-- DB-level backup terpisah kalau memang perlu rollback.
DO $$
BEGIN
RAISE NOTICE 'WARNING: Migration 20260529143940_normalize_po_number_to_pr_pattern is irreversible. Original counter-based PO numbers were not backed up. Restore from DB-level backup if rollback is required.';
END $$;
@@ -0,0 +1,87 @@
BEGIN;
-- ============================================================
-- Normalize purchases.po_number agar mengikuti pr_number (swap prefix).
-- Contoh: pr_number='PR-LTI-0050' -> po_number='PO-LTI-0050'
--
-- Konteks: sebelumnya pr_number dan po_number punya counter sequential
-- terpisah (lihat purchase.repository.go NextPrNumber / NextPoNumber yang
-- dihapus seiring migration ini), sehingga selalu diverge. Setelah
-- perubahan code (ApproveManagerPurchase derive PO dari PR), historis
-- perlu di-backfill supaya konsisten.
--
-- Juga update expenses.po_number (snapshot dari expense_bridge.go)
-- supaya konsisten dengan purchases.
--
-- Constraint uq_purchases_po_number adalah NOT DEFERRABLE (per-row check),
-- jadi single UPDATE bulk gagal di swap-conflict (contoh: row A mau jadi
-- 'PO-LTI-0700' tapi row B masih punya 'PO-LTI-0700' -> error 23505).
-- Solusi: capture target ke temp table, NULL dulu, baru set nilai derived.
--
-- IRREVERSIBLE: nilai po_number lama (counter-based) tidak di-backup.
-- Kalau ada kegagalan di tengah, COMMIT tidak terjadi -> ROLLBACK otomatis.
-- ============================================================
-- 1. Capture target IDs (snapshot rencana update — sebelum perubahan apapun)
CREATE TEMP TABLE _purchases_po_normalize_ids ON COMMIT DROP AS
SELECT id
FROM purchases
WHERE po_number IS NOT NULL
AND pr_number LIKE 'PR-LTI-%'
AND po_number <> REPLACE(pr_number, 'PR-LTI-', 'PO-LTI-');
-- 2. Update expenses DULU — join via current po_number masih valid sebelum step 3-4
UPDATE expenses e
SET po_number = REPLACE(p.pr_number, 'PR-LTI-', 'PO-LTI-')
FROM purchases p
JOIN _purchases_po_normalize_ids n ON n.id = p.id
WHERE e.po_number = p.po_number
AND e.po_number IS NOT NULL
AND e.po_number <> '';
-- 3. NULL-kan purchases.po_number untuk target — lepas constraint conflict
UPDATE purchases
SET po_number = NULL
WHERE id IN (SELECT id FROM _purchases_po_normalize_ids);
-- 4. Set nilai derived dari pr_number (sekarang aman karena slot lama sudah NULL)
UPDATE purchases p
SET po_number = REPLACE(p.pr_number, 'PR-LTI-', 'PO-LTI-')
FROM _purchases_po_normalize_ids n
WHERE p.id = n.id;
-- 5. Sanity check — fail (auto-rollback) kalau masih ada mismatch
DO $$
DECLARE
v_mismatch_purchases INT;
v_mismatch_expenses INT;
v_target_count INT;
BEGIN
SELECT COUNT(*) INTO v_target_count FROM _purchases_po_normalize_ids;
SELECT COUNT(*) INTO v_mismatch_purchases
FROM purchases
WHERE po_number IS NOT NULL
AND pr_number LIKE 'PR-LTI-%'
AND po_number <> REPLACE(pr_number, 'PR-LTI-', 'PO-LTI-');
IF v_mismatch_purchases > 0 THEN
RAISE EXCEPTION 'Normalize failed: % purchases rows still have mismatched po_number', v_mismatch_purchases;
END IF;
SELECT COUNT(*) INTO v_mismatch_expenses
FROM expenses e
JOIN purchases p ON e.po_number = p.po_number
WHERE p.pr_number LIKE 'PR-LTI-%'
AND e.po_number IS NOT NULL
AND e.po_number <> ''
AND e.po_number <> REPLACE(p.pr_number, 'PR-LTI-', 'PO-LTI-');
IF v_mismatch_expenses > 0 THEN
RAISE EXCEPTION 'Normalize failed: % expenses rows still have mismatched po_number', v_mismatch_expenses;
END IF;
RAISE NOTICE 'Normalize complete: % purchases rows updated', v_target_count;
END $$;
COMMIT;
@@ -0,0 +1,17 @@
-- Hapus open_house dan close_house rows dengan effective_date baru
DELETE FROM house_depreciation_standards
WHERE house_type IN ('open_house', 'close_house') AND effective_date = '2026-05-29';
-- Hapus kolom multiplication_percentage
ALTER TABLE house_depreciation_standards DROP COLUMN multiplication_percentage;
-- Invalidate snapshot cache
DELETE FROM farm_depreciation_snapshots;
-- Kembalikan unique constraint lama
ALTER TABLE house_depreciation_standards
DROP CONSTRAINT house_depreciation_standards_house_type_day_eff_unique;
ALTER TABLE house_depreciation_standards
ADD CONSTRAINT house_depreciation_standards_house_type_day_unique
UNIQUE (house_type, day);
@@ -0,0 +1,172 @@
-- Drop unique constraint lama (house_type, day) agar bisa support multi effective_date
ALTER TABLE house_depreciation_standards
DROP CONSTRAINT house_depreciation_standards_house_type_day_unique;
-- Unique baru: (house_type, day, effective_date)
-- NULL dianggap distinct di PostgreSQL → row lama (effective_date NULL) tidak konflik dengan row baru
ALTER TABLE house_depreciation_standards
ADD CONSTRAINT house_depreciation_standards_house_type_day_eff_unique
UNIQUE (house_type, day, effective_date);
-- Tambah kolom multiplication_percentage (nilai dari baris ke-3 Excel "Depresiasi 25 week.xlsx")
ALTER TABLE house_depreciation_standards
ADD COLUMN multiplication_percentage numeric(20,15) NOT NULL DEFAULT 0;
-- Isi multiplication_percentage untuk semua row existing (effective_date IS NULL)
-- Value diambil dari row 3 Excel: kolom A=day1 s/d TL=day532
UPDATE house_depreciation_standards AS hds
SET multiplication_percentage = v.val
FROM (VALUES
(1,0.997742664),(2,0.997737557),(3,0.997732426),(4,0.997727273),(5,0.997722096),
(6,0.997716895),(7,0.99771167),(8,0.997706422),(9,0.997701149),(10,0.997695853),
(11,0.997690531),(12,0.9977),(13,0.997679814),(14,0.997674419),(15,0.998),
(16,0.997997998),(17,0.997993982),(18,0.99798995),(19,0.997985901),(20,0.997981837),
(21,0.997977755),(22,0.997635934),(23,0.997630332),(24,0.997624703),(25,0.997619048),
(26,0.997613365),(27,0.997607656),(28,0.997601918),(29,0.997596154),(30,0.997590361),
(31,0.997584541),(32,0.997578692),(33,0.997572816),(34,0.99756691),(35,0.997560976),
(36,0.997555012),(37,0.99754902),(38,0.997542998),(39,0.997536946),(40,0.997530864),
(41,0.997524752),(42,0.99751861),(43,0.997867804),(44,0.997863248),(45,0.997858672),
(46,0.997854077),(47,0.997849462),(48,0.997844828),(49,0.997840173),(50,0.997474747),
(51,0.997468354),(52,0.997461929),(53,0.997455471),(54,0.99744898),(55,0.997442455),
(56,0.997435897),(57,0.997429306),(58,0.99742268),(59,0.997416021),(60,0.997409326),
(61,0.997402597),(62,0.997395833),(63,0.997389034),(64,0.997756171),(65,0.997751124),
(66,0.997746056),(67,0.997740964),(68,0.997735849),(69,0.997730711),(70,0.99772555),
(71,0.997340426),(72,0.997333333),(73,0.997326203),(74,0.997319035),(75,0.997311828),
(76,0.997304582),(77,0.9972973),(78,0.99767712),(79,0.99767171),(80,0.99766628),
(81,0.99766082),(82,0.99765533),(83,0.99764982),(84,0.997644287),(85,0.997245179),
(86,0.997237569),(87,0.997229917),(88,0.997222222),(89,0.997214485),(90,0.997206704),
(91,0.99719888),(92,0.997191011),(93,0.997183099),(94,0.997175141),(95,0.997167139),
(96,0.997159091),(97,0.997150997),(98,0.997142857),(99,0.997544003),(100,0.997537957),
(101,0.99753188),(102,0.997525773),(103,0.997519636),(104,0.997513469),(105,0.99750727),
(106,0.997084548),(107,0.997076023),(108,0.997067449),(109,0.997058824),(110,0.997050147),
(111,0.99704142),(112,0.997032641),(113,0.99744898),(114,0.997442455),(115,0.997435897),
(116,0.997429306),(117,0.99742268),(118,0.997416021),(119,0.997409326),(120,0.996969697),
(121,0.996960486),(122,0.99695122),(123,0.996941896),(124,0.996932515),(125,0.996923077),
(126,0.99691358),(127,0.997346307),(128,0.997339246),(129,0.997332148),(130,0.997325011),
(131,0.997317836),(132,0.997310623),(133,0.997303371),(134,0.996845426),(135,0.996835443),
(136,0.996825397),(137,0.996815287),(138,0.996805112),(139,0.996794872),(140,0.996784566),
(141,0.997235023),(142,0.997227357),(143,0.997219648),(144,0.997211896),(145,0.997204101),
(146,0.997196262),(147,0.997188379),(148,0.996710526),(149,0.99669967),(150,0.996688742),
(151,0.996677741),(152,0.996666667),(153,0.996655518),(154,0.996644295),(155,0.997113997),
(156,0.997105644),(157,0.997097242),(158,0.997088792),(159,0.997080292),(160,0.997071742),
(161,0.997063142),(162,0.997054492),(163,0.99704579),(164,0.997037037),(165,0.997028232),
(166,0.997019374),(167,0.997010463),(168,0.997001499),(169,0.996491228),(170,0.996478873),
(171,0.996466431),(172,0.996453901),(173,0.996441281),(174,0.996428571),(175,0.996415771),
(176,0.996916752),(177,0.996907216),(178,0.996897622),(179,0.996887967),(180,0.996878252),
(181,0.996868476),(182,0.996858639),(183,0.996848739),(184,0.996838778),(185,0.996828753),
(186,0.996818664),(187,0.996808511),(188,0.996798292),(189,0.996788009),(190,0.996240602),
(191,0.996226415),(192,0.996212121),(193,0.996197719),(194,0.996183206),(195,0.996168582),
(196,0.996153846),(197,0.996690568),(198,0.996679579),(199,0.996668517),(200,0.996657382),
(201,0.996646171),(202,0.996634885),(203,0.996623523),(204,0.996612084),(205,0.996600567),
(206,0.996588971),(207,0.996577296),(208,0.996565541),(209,0.996553705),(210,0.996541787),
(211,0.996529786),(212,0.996517702),(213,0.996505533),(214,0.996493279),(215,0.996480938),
(216,0.996468511),(217,0.996455995),(218,0.996443391),(219,0.996430696),(220,0.99641791),
(221,0.996405033),(222,0.996392063),(223,0.996378998),(224,0.996365839),(225,0.995744681),
(226,0.995726496),(227,0.995708155),(228,0.995689655),(229,0.995670996),(230,0.995652174),
(231,0.995633188),(232,0.996240602),(233,0.996226415),(234,0.996212121),(235,0.996197719),
(236,0.996183206),(237,0.996168582),(238,0.996153846),(239,0.9961389960),(240,0.996124031),
(241,0.996108949),(242,0.99609375),(243,0.996078431),(244,0.996062992),(245,0.996047431),
(246,0.996031746),(247,0.996015936),(248,0.996),(249,0.995983936),(250,0.995967742),
(251,0.995951417),(252,0.995934959),(253,0.995918367),(254,0.995901639),(255,0.995884774),
(256,0.995867769),(257,0.995850622),(258,0.995833333),(259,0.9958158999),(260,0.995798319),
(261,0.995780591),(262,0.995762712),(263,0.995744681),(264,0.995726496),(265,0.995708155),
(266,0.995689655),(267,0.995670996),(268,0.995652174),(269,0.995633188),(270,0.995614035),
(271,0.995594714),(272,0.995575221),(273,0.995555556),(274,0.995535714),(275,0.995515695),
(276,0.995495495),(277,0.995475113),(278,0.995454545),(279,0.99543379),(280,0.995412844),
(281,0.995391705),(282,0.99537037),(283,0.995348837),(284,0.995327103),(285,0.995305164),
(286,0.995282919),(287,0.995260664),(288,0.996031746),(289,0.996015936),(290,0.996),
(291,0.995983936),(292,0.995967742),(293,0.995951417),(294,0.995934959),(295,0.995102041),
(296,0.995077933),(297,0.995053586),(298,0.995028998),(299,0.995004163),(300,0.994979079),
(301,0.994953743),(302,0.994928149),(303,0.994902294),(304,0.994876174),(305,0.994849785),
(306,0.994823123),(307,0.994796184),(308,0.994768963),(309,0.994741455),(310,0.994713656),
(311,0.994685562),(312,0.994657168),(313,0.994628469),(314,0.99459946),(315,0.994570136),
(316,0.994540491),(317,0.994510522),(318,0.994480221),(319,0.994449584),(320,0.994418605),
(321,0.994387278),(322,0.994355597),(323,0.995269631),(324,0.995247148),(325,0.995224451),
(326,0.995201536),(327,0.995178399),(328,0.995155039),(329,0.995131451),(330,0.994129159),
(331,0.994094488),(332,0.994059406),(333,0.994023904),(334,0.993987976),(335,0.993951613),
(336,0.993914807),(337,0.994897959),(338,0.994871795),(339,0.994845361),(340,0.994818653),
(341,0.994791667),(342,0.994764398),(343,0.994736842),(344,0.993650794),(345,0.993610224),
(346,0.993569132),(347,0.993527508),(348,0.993484342),(349,0.993442623),(350,0.99339934),
(351,0.993355482),(352,0.993311037),(353,0.993265993),(354,0.993220339),(355,0.993174061),
(356,0.993127148),(357,0.993079585),(358,0.994192799),(359,0.994158879),(360,0.994124559),
(361,0.994089835),(362,0.994054697),(363,0.994019139),(364,0.993983153),(365,0.992736077),
(366,0.992682927),(367,0.992628993),(368,0.992574257),(369,0.992518703),(370,0.992462312),
(371,0.992405063),(372,0.993622449),(373,0.993581515),(374,0.993540052),(375,0.993498049),
(376,0.993455497),(377,0.993412385),(378,0.9933687),(379,0.993324433),(380,0.99327957),
(381,0.9932341),(382,0.993188011),(383,0.993141289),(384,0.993093923),(385,0.993045897),
(386,0.991596639),(387,0.991525424),(388,0.991452991),(389,0.99137931),(390,0.991304348),
(391,0.99122807),(392,0.991150442),(393,0.992559524),(394,0.992503748),(395,0.99244713),
(396,0.99238965),(397,0.992331288),(398,0.992272025),(399,0.992211838),(400,0.992150706),
(401,0.992088608),(402,0.992025518),(403,0.991961415),(404,0.991896272),(405,0.991830065),
(406,0.991762768),(407,0.991694352),(408,0.991624791),(409,0.991554054),(410,0.991482112),
(411,0.991408935),(412,0.991334489),(413,0.991258741),(414,0.989417989),(415,0.989304813),
(416,0.989189189),(417,0.989071038),(418,0.988950276),(419,0.988826816),(420,0.988700565),
(421,0.99047619),(422,0.990384615),(423,0.990291262),(424,0.990196078),(425,0.99009901),
(426,0.99),(427,0.98989899),(428,0.989795918),(429,0.989690722),(430,0.989583333),
(431,0.989473684),(432,0.989361702),(433,0.989247312),(434,0.989130435),(435,0.989010989),
(436,0.988888889),(437,0.988764045),(438,0.988636364),(439,0.988505747),(440,0.988372093),
(441,0.988235294),(442,0.988095238),(443,0.987951807),(444,0.987804878),(445,0.987654321),
(446,0.9875),(447,0.987341772),(448,0.987179487),(449,0.987012987),(450,0.986842105),
(451,0.986666667),(452,0.986486486),(453,0.98630137),(454,0.986111111),(455,0.985915493),
(456,0.985714286),(457,0.985507246),(458,0.985294118),(459,0.985074627),(460,0.984848485),
(461,0.984615385),(462,0.984375),(463,0.987301587),(464,0.987138264),(465,0.986970684),
(466,0.98679868),(467,0.986622074),(468,0.986440678),(469,0.986254296),(470,0.982578397),
(471,0.982269504),(472,0.981949458),(473,0.981617647),(474,0.981273408),(475,0.980916031),
(476,0.980544747),(477,0.98015873),(478,0.979757085),(479,0.979338843),(480,0.978902954),
(481,0.978448276),(482,0.977973568),(483,0.977477477),(484,0.976958525),(485,0.976415094),
(486,0.975845411),(487,0.975247525),(488,0.974619289),(489,0.973958333),(490,0.973262032),
(491,0.978021978),(492,0.97752809),(493,0.977011494),(494,0.976470588),(495,0.975903614),
(496,0.975308642),(497,0.974683544),(498,0.967532468),(499,0.966442953),(500,0.965277778),
(501,0.964028777),(502,0.962686567),(503,0.96124031),(504,0.959677419),(505,0.966386555),
(506,0.965217391),(507,0.963963964),(508,0.962616822),(509,0.961165049),(510,0.95959596),
(511,0.957894737),(512,0.945054945),(513,0.941860465),(514,0.938271605),(515,0.934210526),
(516,0.929577465),(517,0.924242424),(518,0.918032787),(519,0.928571429),(520,0.923076923),
(521,0.916666667),(522,0.909090909),(523,0.9),(524,0.888888889),(525,0.875),
(526,0.857142857),(527,0.833333333),(528,0.8),(529,0.75),(530,0.666666667),
(531,0.5),(532,9.11e-12)
) AS v(day_num, val)
WHERE hds.day = v.day_num;
-- Insert open_house baru dengan effective_date 2026-05-20
-- multiplication_percentage diambil dari row existing (sudah di-UPDATE di step sebelumnya)
INSERT INTO house_depreciation_standards
(house_type, day, effective_date, depreciation_percent, standard_week, name, multiplication_percentage)
SELECT
'open_house'::house_type_enum,
day,
'2026-05-29'::date,
depreciation_percent,
25,
'Standard Open House Week 25',
multiplication_percentage
FROM (
SELECT DISTINCT ON (day)
day, depreciation_percent, multiplication_percentage
FROM house_depreciation_standards
WHERE house_type = 'open_house'
ORDER BY day, effective_date DESC NULLS LAST
) effective_open_house;
-- Insert close_house baru dengan effective_date 2026-05-29
-- multiplication_percentage diambil dari row existing (sudah di-UPDATE di step sebelumnya)
INSERT INTO house_depreciation_standards
(house_type, day, effective_date, depreciation_percent, standard_week, name, multiplication_percentage)
SELECT
'close_house'::house_type_enum,
day,
'2026-05-29'::date,
depreciation_percent,
25,
'Standard Close House Week 25',
multiplication_percentage
FROM (
SELECT DISTINCT ON (day)
day, depreciation_percent, multiplication_percentage
FROM house_depreciation_standards
WHERE house_type = 'open_house'
ORDER BY day, effective_date DESC NULLS LAST
) effective_close_house;
-- Invalidate snapshot cache depreciation agar recompute dengan standard baru
DELETE FROM farm_depreciation_snapshots;
@@ -0,0 +1,22 @@
-- Rollback: balik ke rule lama (19 minggu = 133 hari)
BEGIN;
UPDATE laying_transfers lt
SET economic_cutoff_date = sub.cutoff_date,
updated_at = NOW()
FROM (
SELECT
lt2.id AS transfer_id,
(MIN(pc.chick_in_date)::date + INTERVAL '133 days')::date AS cutoff_date
FROM laying_transfers lt2
JOIN project_chickins pc ON pc.project_flock_kandang_id = lt2.source_project_flock_kandang_id
WHERE lt2.deleted_at IS NULL
AND lt2.source_project_flock_kandang_id IS NOT NULL
AND pc.deleted_at IS NULL
GROUP BY lt2.id
) sub
WHERE lt.id = sub.transfer_id
AND lt.deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,24 @@
-- Recalculate laying_transfers.economic_cutoff_date dari rule 19 minggu (lama) ke 25 minggu (baru,
-- sejalan dengan depreciation_start_age_day = 175). Semua transfer historis yang punya
-- source_project_flock_kandang_id akan di-update agar economic_cutoff_date = source.chick_in_date + 175 hari.
BEGIN;
UPDATE laying_transfers lt
SET economic_cutoff_date = sub.cutoff_date,
updated_at = NOW()
FROM (
SELECT
lt2.id AS transfer_id,
(MIN(pc.chick_in_date)::date + INTERVAL '175 days')::date AS cutoff_date
FROM laying_transfers lt2
JOIN project_chickins pc ON pc.project_flock_kandang_id = lt2.source_project_flock_kandang_id
WHERE lt2.deleted_at IS NULL
AND lt2.source_project_flock_kandang_id IS NOT NULL
AND pc.deleted_at IS NULL
GROUP BY lt2.id
) sub
WHERE lt.id = sub.transfer_id
AND lt.deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,14 @@
-- Rollback total_cost ke nilai sebelum migration
UPDATE farm_depreciation_manual_inputs
SET total_cost = 562618200.000,
updated_at = NOW()
WHERE project_flock_id = 10;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 598552406.000,
updated_at = NOW()
WHERE project_flock_id = 11;
-- Snapshot lama tidak bisa di-restore — biarkan kosong, recompute otomatis
-- saat user request endpoint depresiasi
TRUNCATE TABLE farm_depreciation_snapshots;
@@ -0,0 +1,21 @@
-- Update total_cost farm_depreciation_manual_inputs untuk PFK 10 & 11
-- per permintaan user (cutover 28 Feb 2026)
--
-- PFK 10 (Flock Jamali 003) : 562.618.200,000 -> 1.900.157.533,55
-- PFK 11 (Flock Tamansari 001) : 598.552.406,000 -> 2.521.797.832,14
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 = 2521797832.14,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 11;
-- Pengaman: pastikan snapshot di-recompute dengan total_cost baru
-- saat user request /api/reports/expense/depreciation
TRUNCATE TABLE farm_depreciation_snapshots;
@@ -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;
@@ -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,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,2 @@
UPDATE phases SET is_active = true
WHERE id IN (2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26);
@@ -0,0 +1,2 @@
UPDATE phases SET is_active = false
WHERE id IN (2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26);
+1
View File
@@ -18,6 +18,7 @@ type Expense struct {
TransactionDate time.Time `gorm:"type:date;not null"` TransactionDate time.Time `gorm:"type:date;not null"`
Notes string `gorm:"type:text;column:notes"` Notes string `gorm:"type:text;column:notes"`
IsPaid bool `gorm:"column:is_paid;not null;default:false"` IsPaid bool `gorm:"column:is_paid;not null;default:false"`
GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);not null;default:0"`
CreatedBy uint64 `gorm:""` CreatedBy uint64 `gorm:""`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
+1
View File
@@ -15,6 +15,7 @@ type Marketing struct {
SalesPersonId uint `gorm:"not null"` SalesPersonId uint `gorm:"not null"`
Notes string `gorm:"type:text"` Notes string `gorm:"type:text"`
MarketingType string `gorm:"type:varchar(50)"` MarketingType string `gorm:"type:varchar(50)"`
GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);not null;default:0"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
+23
View File
@@ -0,0 +1,23 @@
package entities
import (
"time"
)
// PaymentAllocation merepresentasikan hasil FIFO matching dari 1 payment ke
// tepat 1 sub-row anak (purchase_item / marketing_delivery_product /
// expense_realization). DB constraint memastikan hanya satu FK yang non-null.
type PaymentAllocation struct {
Id uint64 `gorm:"primaryKey;autoIncrement"`
PaymentId uint `gorm:"not null;index"`
PurchaseItemId *uint `gorm:"column:purchase_item_id"`
MarketingDeliveryProductId *uint `gorm:"column:marketing_delivery_product_id"`
ExpenseRealizationId *uint64 `gorm:"column:expense_realization_id"`
Amount float64 `gorm:"type:numeric(15,3);not null"`
AllocatedAt time.Time `gorm:"type:timestamptz;not null;default:NOW()"`
Payment *Payment `gorm:"foreignKey:PaymentId;references:Id"`
PurchaseItem *PurchaseItem `gorm:"foreignKey:PurchaseItemId;references:Id"`
MarketingDeliveryProduct *MarketingDeliveryProduct `gorm:"foreignKey:MarketingDeliveryProductId;references:Id"`
ExpenseRealization *ExpenseRealization `gorm:"foreignKey:ExpenseRealizationId;references:Id"`
}
+1
View File
@@ -12,6 +12,7 @@ type Purchase struct {
SupplierId uint `gorm:"not null"` SupplierId uint `gorm:"not null"`
CreditTerm int `gorm:"column:credit_term;not null;default:0"` CreditTerm int `gorm:"column:credit_term;not null;default:0"`
DueDate *time.Time DueDate *time.Time
GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);not null;default:0"`
Notes *string Notes *string
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -789,11 +789,56 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
totalActualPopulation := totalChickinQty - totalDepletion totalActualPopulation := totalChickinQty - totalDepletion
// Prefer recording-based population (recordings.total_chick_qty) so closing stays
// consistent with normalized cut-over flocks. For normal flocks this equals
// chickin - depletion (no-op); it only differs when the recording population was
// normalized separately from recording_depletions. Falls back if any kandang in
// scope lacks a recording.
scopeKandangs := projectFlockKandangs
if projectFlockKandangID != nil {
scopeKandangs = nil
for _, k := range projectFlockKandangs {
if k.Id == *projectFlockKandangID {
scopeKandangs = append(scopeKandangs, k)
break
}
}
}
if recPop, ok := s.actualPopulationFromRecordings(c.Context(), scopeKandangs); ok {
totalActualPopulation = recPop
}
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount) result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount)
return &result, nil return &result, nil
} }
// actualPopulationFromRecordings sums the latest recordings.total_chick_qty across the
// given kandangs (the production population source of truth). Returns ok=false if any
// kandang lacks a recording, so the caller falls back to chickin-minus-depletion.
// For normal flocks this equals chickin - depletion; it only differs for cut-over flocks
// whose recording population was normalized separately from recording_depletions.
func (s closingService) actualPopulationFromRecordings(ctx context.Context, kandangs []entity.ProjectFlockKandang) (float64, bool) {
if s.RecordingRepo == nil || len(kandangs) == 0 {
return 0, false
}
total := 0.0
for _, k := range kandangs {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx, k.Id)
if err != nil {
s.Log.Warnf("actualPopulationFromRecordings: latest recording pfk=%d: %v", k.Id, err)
return 0, false
}
if latest == nil || latest.TotalChickQty == nil {
return 0, false
}
if *latest.TotalChickQty > 0 {
total += *latest.TotalChickQty
}
}
return total, true
}
type activeKandangMetricRow struct { type activeKandangMetricRow struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"` ProjectFlockID uint `gorm:"column:project_flock_id"`
@@ -156,7 +156,7 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl
hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData) hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData)
profitLossSection := s.buildProfitLossSection(projectFlock, costs, productionData) profitLossSection := s.buildProfitLossSection(c, projectFlock, projectFlockKandangs, costs, productionData)
data := dto.ToClosingKeuanganData(hppSection, profitLossSection) data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
return &data, nil return &data, nil
@@ -386,7 +386,7 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti
return dto.ToHPPSection(hppItems, hppSummary) return dto.ToHPPSection(hppItems, hppSummary)
} }
func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection { func (s closingKeuanganService) buildProfitLossSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.ProfitLossSection {
totalWeightProduced := production.TotalWeightProduced totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg totalEggWeightKg := production.TotalEggWeightKg
@@ -394,6 +394,11 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
totalWeightSold := production.TotalWeightSold totalWeightSold := production.TotalWeightSold
totalBirdSold := production.TotalBirdSold totalBirdSold := production.TotalBirdSold
actualPopulation := production.TotalPopulationIn - production.TotalDepletion actualPopulation := production.TotalPopulationIn - production.TotalDepletion
// Prefer recording-based population (consistent with buildHPPSection) so per-ekor
// P&L matches the normalized recording population for cut-over flocks.
if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok {
actualPopulation = lastPopulation
}
isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying) isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)
@@ -1215,7 +1215,9 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati
} }
if len(phaseIDs) > 0 { if len(phaseIDs) > 0 {
phases, err := s.PhaseRepo.GetByIDs(c.Context(), phaseIDs, nil) phases, err := s.PhaseRepo.GetByIDs(c.Context(), phaseIDs, func(db *gorm.DB) *gorm.DB {
return db.Where("is_active = true")
})
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "Phase not found") return fiber.NewError(fiber.StatusBadRequest, "Phase not found")
+2 -1
View File
@@ -45,7 +45,8 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) panic(fmt.Sprintf("failed to register expense approval workflow: %v", err))
} }
expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate) fifoPaymentSvc := commonSvc.NewFifoPaymentService(db, utils.Log)
expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, fifoPaymentSvc, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ExpenseRoutes(router, userService, expenseService) ExpenseRoutes(router, userService, expenseService)
@@ -54,9 +54,10 @@ type expenseService struct {
RealizationRepository repository.ExpenseRealizationRepository RealizationRepository repository.ExpenseRealizationRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService DocumentSvc commonSvc.DocumentService
FifoPaymentSvc commonSvc.FifoPaymentService
} }
func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoPaymentSvc commonSvc.FifoPaymentService, validate *validator.Validate) ExpenseService {
return &expenseService{ return &expenseService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -67,6 +68,23 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR
RealizationRepository: realizationRepo, RealizationRepository: realizationRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc, DocumentSvc: documentSvc,
FifoPaymentSvc: fifoPaymentSvc,
}
}
// reallocateAfterRealization called after expense realization changes that may
// affect supplier debt: recompute grand_total + reallocate FIFO.
func (s *expenseService) reallocateAfterRealization(ctx context.Context, expenseID uint, supplierID uint64) {
if s.FifoPaymentSvc == nil {
return
}
if err := s.FifoPaymentSvc.RecomputeGrandTotal(ctx, nil, commonSvc.ParentKindExpense, expenseID); err != nil {
s.Log.Warnf("Failed to recompute grand_total for expense %d: %+v", expenseID, err)
}
if supplierID > 0 {
if err := s.FifoPaymentSvc.ReallocateForParty(ctx, nil, string(utils.PaymentPartySupplier), uint(supplierID)); err != nil {
s.Log.Warnf("Failed to reallocate payments for supplier %d: %+v", supplierID, err)
}
} }
} }
@@ -1078,6 +1096,9 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
} }
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate) invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate)
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
s.reallocateAfterRealization(c.Context(), expenseID, expense.SupplierId)
return responseDTO, nil return responseDTO, nil
} }
@@ -1522,6 +1543,9 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
return nil, err return nil, err
} }
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
s.reallocateAfterRealization(c.Context(), expenseID, expense.SupplierId)
return responseDTO, nil return responseDTO, nil
} }
+3 -1
View File
@@ -29,7 +29,9 @@ func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) panic(fmt.Sprintf("failed to register payment approval workflow: %v", err))
} }
paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate) fifoPaymentService := commonSvc.NewFifoPaymentService(db, nil)
paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, fifoPaymentService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
PaymentRoutes(router, userService, paymentService) PaymentRoutes(router, userService, paymentService)
@@ -32,12 +32,14 @@ type paymentService struct {
Validate *validator.Validate Validate *validator.Validate
Repository repository.PaymentRepository Repository repository.PaymentRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoPaymentSvc commonSvc.FifoPaymentService
approvalWorkflow approvalutils.ApprovalWorkflowKey approvalWorkflow approvalutils.ApprovalWorkflowKey
} }
func NewPaymentService( func NewPaymentService(
repo repository.PaymentRepository, repo repository.PaymentRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoPaymentSvc commonSvc.FifoPaymentService,
validate *validator.Validate, validate *validator.Validate,
) PaymentService { ) PaymentService {
return &paymentService{ return &paymentService{
@@ -45,6 +47,7 @@ func NewPaymentService(
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoPaymentSvc: fifoPaymentSvc,
approvalWorkflow: utils.ApprovalWorkflowPayment, approvalWorkflow: utils.ApprovalWorkflowPayment,
} }
} }
@@ -159,6 +162,12 @@ func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
} }
} }
if s.FifoPaymentSvc != nil {
if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), dbTransaction, createBody.PartyType, createBody.PartyId); err != nil {
return err
}
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -251,7 +260,46 @@ func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return s.GetOne(c, id) return s.GetOne(c, id)
} }
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { // Snapshot party lama untuk reallocate kalau party baru berbeda.
existing, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found")
}
if err != nil {
s.Log.Errorf("Failed get payment for update: %+v", err)
return nil, err
}
oldPartyType := existing.PartyType
oldPartyID := existing.PartyId
newPartyType := oldPartyType
newPartyID := oldPartyID
if v, ok := updateBody["party_type"].(string); ok {
newPartyType = v
}
if v, ok := updateBody["party_id"].(uint); ok {
newPartyID = v
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
paymentRepoTx := repository.NewPaymentRepository(tx)
if err := paymentRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil {
return err
}
if s.FifoPaymentSvc != nil {
if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), tx, newPartyType, newPartyID); err != nil {
return err
}
if oldPartyType != newPartyType || oldPartyID != newPartyID {
if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), tx, oldPartyType, oldPartyID); err != nil {
return err
}
}
}
return nil
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found")
} }
@@ -35,7 +35,8 @@ func (TransactionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valida
panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) panic(fmt.Sprintf("failed to register injection approval workflow: %v", err))
} }
transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, validate) fifoPaymentService := commonSvc.NewFifoPaymentService(db, utils.Log)
transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, fifoPaymentService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
TransactionRoutes(router, userService, transactionService) TransactionRoutes(router, userService, transactionService)
@@ -30,19 +30,22 @@ type transactionService struct {
Validate *validator.Validate Validate *validator.Validate
Repository repository.TransactionRepository Repository repository.TransactionRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoPaymentSvc commonSvc.FifoPaymentService
approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey
} }
func NewTransactionService( func NewTransactionService(
repo repository.TransactionRepository, repo repository.TransactionRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoPaymentSvc commonSvc.FifoPaymentService,
validate *validator.Validate, validate *validator.Validate,
) TransactionService { ) TransactionService {
return &transactionService{ return &transactionService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoPaymentSvc: fifoPaymentSvc,
approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{ approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{
string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial, string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial,
string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection, string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection,
@@ -182,6 +185,19 @@ func (s transactionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, erro
} }
func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error { func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error {
// Snapshot party SEBELUM delete supaya bisa re-FIFO setelah trigger DB
// (`trg_soft_delete_fk_payments`) CASCADE hard-DELETE allocations.
existing, err := s.Repository.GetByID(c.Context(), id, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Transaction not found")
}
s.Log.Errorf("Failed to load transaction before delete: %+v", err)
return err
}
partyType := existing.PartyType
partyID := existing.PartyId
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Transaction not found") return fiber.NewError(fiber.StatusNotFound, "Transaction not found")
@@ -189,6 +205,14 @@ func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error {
s.Log.Errorf("Failed to delete transaction: %+v", err) s.Log.Errorf("Failed to delete transaction: %+v", err)
return err return err
} }
// Re-FIFO setelah delete agar payment lain yang masih punya unallocated nominal
// otomatis reflow ke MDP/purchase_item/expense_realization yang kekurangan paid.
if s.FifoPaymentSvc != nil && partyID > 0 {
if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), nil, partyType, partyID); err != nil {
s.Log.Warnf("Failed to reallocate payments after delete (party=%s id=%d): %+v", partyType, partyID, err)
}
}
return nil return nil
} }
@@ -65,6 +65,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
expenseRealizationRepo, expenseRealizationRepo,
projectFlockKandangRepo, projectFlockKandangRepo,
documentSvc, documentSvc,
commonSvc.NewFifoPaymentService(db, utils.Log),
validate, validate,
) )
@@ -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 {
@@ -104,22 +107,23 @@ func setMarketingExportColumns(file *excelize.File, sheet string) error {
func setMarketingExportHeaders(file *excelize.File, sheet string) error { func setMarketingExportHeaders(file *excelize.File, sheet string) error {
headers := []string{ headers := []string{
"No. Order", // A "No. Order", // A
"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,
+2 -1
View File
@@ -35,6 +35,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
stockLogRepo := rShared.NewStockLogRepository(db) stockLogRepo := rShared.NewStockLogRepository(db)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
fifoPaymentService := commonSvc.NewFifoPaymentService(db, utils.Log)
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo) approvalSvc := commonSvc.NewApprovalService(approvalRepo)
@@ -47,7 +48,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate) salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, productWarehouseRepo, projectFlockPopulationRepo, approvalSvc, fifoStockV2Service, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, productWarehouseRepo, projectFlockPopulationRepo, approvalSvc, fifoStockV2Service, fifoPaymentService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
@@ -48,6 +48,7 @@ type deliveryOrdersService struct {
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
FifoPaymentSvc commonSvc.FifoPaymentService
} }
func NewDeliveryOrdersService( func NewDeliveryOrdersService(
@@ -59,6 +60,7 @@ func NewDeliveryOrdersService(
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoStockV2Svc commonSvc.FifoStockV2Service, fifoStockV2Svc commonSvc.FifoStockV2Service,
fifoPaymentSvc commonSvc.FifoPaymentService,
validate *validator.Validate, validate *validator.Validate,
) DeliveryOrdersService { ) DeliveryOrdersService {
return &deliveryOrdersService{ return &deliveryOrdersService{
@@ -71,6 +73,22 @@ func NewDeliveryOrdersService(
ProjectFlockPopulationRepo: projectFlockPopulationRepo, ProjectFlockPopulationRepo: projectFlockPopulationRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
FifoPaymentSvc: fifoPaymentSvc,
}
}
// reallocateAfterDelivery refresh marketing.grand_total + reallocate FIFO untuk customer.
func (s *deliveryOrdersService) reallocateAfterDelivery(ctx context.Context, marketingID uint, customerID uint) {
if s.FifoPaymentSvc == nil {
return
}
if err := s.FifoPaymentSvc.RecomputeGrandTotal(ctx, nil, commonSvc.ParentKindMarketing, marketingID); err != nil {
utils.Log.Warnf("Failed to recompute grand_total for marketing %d: %+v", marketingID, err)
}
if customerID > 0 {
if err := s.FifoPaymentSvc.ReallocateForParty(ctx, nil, string(utils.PaymentPartyCustomer), customerID); err != nil {
utils.Log.Warnf("Failed to reallocate payments for customer %d: %+v", customerID, err)
}
} }
} }
@@ -269,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)
@@ -293,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)
@@ -418,6 +461,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery order already exists for this marketing") return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery order already exists for this marketing")
} }
var capturedCustomerID uint
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
@@ -428,6 +472,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
} }
capturedCustomerID = marketing.CustomerId
allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId)
if err != nil { if err != nil {
@@ -519,6 +564,8 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create delivery order") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create delivery order")
} }
s.reallocateAfterDelivery(c.Context(), req.MarketingId, capturedCustomerID)
return s.getMarketingWithDeliveries(c, req.MarketingId) return s.getMarketingWithDeliveries(c, req.MarketingId)
} }
@@ -547,6 +594,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status")
} }
var capturedCustomerID uint
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
@@ -557,6 +605,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
} }
capturedCustomerID = marketing.CustomerId
allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -662,6 +711,8 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery order") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery order")
} }
s.reallocateAfterDelivery(c.Context(), id, capturedCustomerID)
return s.getMarketingWithDeliveries(c, id) return s.getMarketingWithDeliveries(c, id)
} }
@@ -921,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,
@@ -928,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)
@@ -1451,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 {
@@ -50,6 +50,7 @@ func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.
phasess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { phasess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Where("is_active = true")
if params.Search != "" { if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%") return db.Where("name ILIKE ?", "%"+params.Search+"%")
} }
@@ -387,39 +387,87 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan
return nil return nil
} }
upperCategory := strings.ToUpper(category) requestedWeek := ((day - 1) / 7) + 1
weekBase := 1 if requestedWeek <= 0 {
if upperCategory == string(utils.ProjectFlockCategoryLaying) {
weekBase = config.LayingWeekStart()
}
week := ((day - 1) / 7) + weekBase
if week <= 0 {
return nil return nil
} }
upperCategory := strings.ToUpper(category)
if upperCategory == string(utils.ProjectFlockCategoryLaying) { if upperCategory == string(utils.ProjectFlockCategoryLaying) {
detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) effectiveWeek := requestedWeek
firstCommonWeek, ok, err := s.layingFirstCommonStandardWeek(ctx, standardID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
}
return err return err
} }
if detail == nil { if ok && requestedWeek < firstCommonWeek {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) effectiveWeek = firstCommonWeek
} }
detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if detail != nil && growthDetail != nil {
return nil
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
} }
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, requestedWeek)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
} }
return err return err
} }
if growthDetail == nil { if growthDetail == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
} }
return nil return nil
} }
func (s productionStandardService) layingFirstCommonStandardWeek(ctx context.Context, standardID uint) (int, bool, error) {
details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
return 0, false, err
}
detailWeeks := make(map[int]struct{}, len(details))
for _, detail := range details {
if detail.Week <= 0 {
continue
}
detailWeeks[detail.Week] = struct{}{}
}
growthDetails, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
return 0, false, err
}
firstCommonWeek := 0
for _, detail := range growthDetails {
if detail.Week <= 0 {
continue
}
if _, ok := detailWeeks[detail.Week]; !ok {
continue
}
if firstCommonWeek == 0 || detail.Week < firstCommonWeek {
firstCommonWeek = detail.Week
}
}
return firstCommonWeek, firstCommonWeek > 0, nil
}
@@ -0,0 +1,95 @@
package service
import (
"context"
"strings"
"testing"
"github.com/glebarez/sqlite"
repositories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
func TestEnsureWeekAvailableAllowsLayingBeforeFirstCommonStandardWeek(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
if err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 85); err != nil {
t.Fatalf("expected pre-standard laying week to be allowed, got %v", err)
}
}
func TestEnsureWeekAvailableRejectsLayingMissingWeekAfterStandardStarts(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 127)
if err == nil {
t.Fatal("expected missing laying standard week to be rejected")
}
if !strings.Contains(err.Error(), "week 19") {
t.Fatalf("expected error to mention requested week 19, got %v", err)
}
}
func TestEnsureWeekAvailableKeepsGrowingWeekStrict(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
err := svc.EnsureWeekAvailable(context.Background(), 2, string(utils.ProjectFlockCategoryGrowing), 8)
if err == nil {
t.Fatal("expected missing growing standard week to be rejected")
}
if !strings.Contains(err.Error(), "week 2") {
t.Fatalf("expected error to mention requested week 2, got %v", err)
}
}
func setupProductionStandardServiceTest(t *testing.T) productionStandardService {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE production_standard_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
week INTEGER NOT NULL,
target_hen_day_production NUMERIC NULL,
target_hen_house_production NUMERIC NULL,
target_egg_weight NUMERIC NULL,
target_egg_mass NUMERIC NULL,
standard_fcr NUMERIC NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
)`,
`CREATE TABLE standard_growth_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
target_mean_bw NUMERIC NULL,
max_depletion NUMERIC NULL,
min_uniformity NUMERIC NOT NULL,
week INTEGER NOT NULL,
feed_intake NUMERIC NULL,
created_at TIMESTAMP NULL,
created_by INTEGER NOT NULL
)`,
`INSERT INTO production_standard_details (id, production_standard_id, week, standard_fcr) VALUES
(1, 1, 18, 2.1)`,
`INSERT INTO standard_growth_details (id, production_standard_id, week, min_uniformity, created_by) VALUES
(1, 1, 18, 80, 1),
(2, 2, 1, 80, 1)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return productionStandardService{
ProductionStandardDetailRepo: repositories.NewProductionStandardDetailRepository(db),
StandardGrowthDetailRepo: repositories.NewStandardGrowthDetailRepository(db),
}
}
@@ -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,8 +17,9 @@ 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.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) route.Patch("/chick-in-date", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.UpdateChickInDate)
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)
route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval) route.Post("/approvals", m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval)
} }
@@ -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"`
}
@@ -312,10 +312,10 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse)
dtoResult.Warehouse = &mapped dtoResult.Warehouse = &mapped
} }
if _, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil { if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil {
return serr return serr
} else { } else {
dtoResult.IsTransition = false dtoResult.IsTransition = isTransition
dtoResult.IsLaying = isLaying dtoResult.IsLaying = isLaying
} }
applyCutOverLayingLookupOverride(&dtoResult) applyCutOverLayingLookupOverride(&dtoResult)
@@ -346,7 +346,7 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
} }
func applyCutOverLayingLookupOverride(result *dto.ProjectFlockKandangDTO) { func applyCutOverLayingLookupOverride(result *dto.ProjectFlockKandangDTO) {
if result == nil || result.ProjectFlock == nil || result.IsLaying || result.ChickInDate == nil { if result == nil || result.ProjectFlock == nil || result.IsLaying || result.IsTransition || result.ChickInDate == nil {
return return
} }
@@ -588,17 +588,29 @@ func (s projectflockService) GetProjectFlockKandangTransferStateAtDate(ctx *fibe
switch category { switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx.Context(), projectFlockKandangID) transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx.Context(), projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil
}
s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err)
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
}
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx.Context(), projectFlockKandangID) // Multi-source: target kandang bisa menerima dari multiple transfer terpisah. Pakai
default: // EARLIEST transfer (transfer_date ASC) sebagai anchor — kandang masuk transition/laying
return false, false, nil // mengikuti batch pertama yang sampai.
} allTransfers, allErr := s.TransferLayingRepo.GetAllApprovedByTargetKandang(ctx.Context(), projectFlockKandangID)
if err != nil { if allErr != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to resolve transfers for project flock kandang %d: %+v", projectFlockKandangID, allErr)
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
}
if len(allTransfers) == 0 {
return false, false, nil return false, false, nil
} }
s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err) // Repository ORDER BY transfer_date ASC, id ASC → [0] = earliest
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state") transfer = &allTransfers[0]
default:
return false, false, nil
} }
if transfer == nil { if transfer == nil {
return false, false, nil return false, false, nil
@@ -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,37 +350,66 @@ 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
safeExportText(item.ProjectFlock.FlockName), // C safeExportText(item.ProjectFlock.FlockName), // C
kandangName, // D kandangName, // D
item.ProjectFlock.Period, // E item.ProjectFlock.Period, // E
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,14 +100,20 @@ 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"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
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
@@ -34,7 +34,6 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type RecordingService interface { type RecordingService interface {
@@ -46,6 +45,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 {
@@ -198,10 +198,22 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
targetTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandangs(c.Context(), layingPFKIDs) // Multi-source support: 1 target kandang bisa menerima dari multiple transfer terpisah.
// Untuk state evaluation (IsTransition/IsLaying), kita pakai EARLIEST transfer sebagai anchor
// (sesuai dengan rule "kandang masuk fase laying mengikuti batch pertama yang sampai").
allTransfersByTarget, err := s.TransferLayingRepo.GetAllApprovedByTargetKandangs(c.Context(), layingPFKIDs)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
targetTransferByPFK := make(map[uint]*entity.LayingTransfer, len(allTransfersByTarget))
for pfkID, list := range allTransfersByTarget {
if len(list) == 0 {
continue
}
// list sudah ORDER BY transfer_date ASC, id ASC → element [0] adalah earliest
earliest := list[0]
targetTransferByPFK[pfkID] = &earliest
}
hasTargetRecordingCache := make(map[uint]bool) hasTargetRecordingCache := make(map[uint]bool)
cutOverChickinAvailability := make(map[uint]bool) cutOverChickinAvailability := make(map[uint]bool)
@@ -247,6 +259,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
@@ -392,7 +408,14 @@ 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 {
return nil, err if !isLaying {
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 {
@@ -562,10 +585,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
return err return err
} }
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err)
return err
}
action := entity.ApprovalActionCreated action := entity.ApprovalActionCreated
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
@@ -868,12 +887,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err return err
} }
} }
if hasStockChanges {
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
return err
}
}
action := entity.ApprovalActionUpdated action := entity.ApprovalActionUpdated
actorID := recordingEntity.CreatedBy actorID := recordingEntity.CreatedBy
@@ -1135,10 +1148,6 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
return err return err
} }
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err)
return err
}
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
return nil return nil
@@ -1292,17 +1301,29 @@ func (s *recordingService) evaluatePopulationMutationState(ctx context.Context,
switch category { switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, false, false, false, nil, time.Time{}, nil
}
s.Log.Errorf("Failed to resolve approved transfer for recording %d: %+v", recording.Id, err)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId) // Multi-source: target kandang bisa menerima dari multiple transfer terpisah.
default: // Pakai EARLIEST transfer (transfer_date ASC) sebagai anchor untuk state evaluation —
return true, false, false, false, nil, time.Time{}, nil // kandang dianggap masuk transition/laying berdasarkan batch pertama yang masuk.
} allTransfers, allErr := s.TransferLayingRepo.GetAllApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId)
if err != nil { if allErr != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to resolve approved transfers for recording %d: %+v", recording.Id, allErr)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
if len(allTransfers) == 0 {
return true, false, false, false, nil, time.Time{}, nil return true, false, false, false, nil, time.Time{}, nil
} }
s.Log.Errorf("Failed to resolve approved transfer for recording %d: %+v", recording.Id, err) // Repository sudah ORDER BY transfer_date ASC, id ASC → element [0] adalah earliest.
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") transfer = &allTransfers[0]
default:
return true, false, false, false, nil, time.Time{}, nil
} }
if transfer == nil { if transfer == nil {
return true, false, false, false, nil, time.Time{}, nil return true, false, false, false, nil, time.Time{}, nil
@@ -1913,172 +1934,6 @@ func (s *recordingService) getEarliestChickInDateByProjectFlockKandangID(ctx con
return row.ChickInDate, nil return row.ChickInDate, nil
} }
func (s *recordingService) syncFarmDepreciationManualInputFromRecordingStocks(
ctx context.Context,
tx *gorm.DB,
projectFlockKandangID uint,
fallbackCutoverDate time.Time,
) error {
if projectFlockKandangID == 0 {
return nil
}
targetDB := s.Repository.DB()
if tx != nil {
targetDB = tx
}
projectFlockID, err := s.resolveProjectFlockIDByProjectFlockKandangID(ctx, targetDB, projectFlockKandangID)
if err != nil {
return err
}
if projectFlockID == 0 {
return nil
}
totalCost, err := s.sumNoTransferRecordingStockCostByProjectFlockID(ctx, targetDB, projectFlockID)
if err != nil {
return err
}
existing, err := s.getFarmDepreciationManualInputByProjectFlockID(ctx, targetDB, projectFlockID)
if err != nil {
return err
}
cutoverDate := normalizeDateOnlyUTC(fallbackCutoverDate)
if existing != nil && !existing.CutoverDate.IsZero() {
cutoverDate = normalizeDateOnlyUTC(existing.CutoverDate)
}
if cutoverDate.IsZero() {
earliestDate, dateErr := s.getEarliestNoTransferRecordingDateByProjectFlockID(ctx, targetDB, projectFlockID)
if dateErr != nil {
return dateErr
}
if earliestDate != nil && !earliestDate.IsZero() {
cutoverDate = normalizeDateOnlyUTC(*earliestDate)
}
}
if cutoverDate.IsZero() {
cutoverDate = normalizeDateOnlyUTC(time.Now().UTC())
}
now := time.Now().UTC()
row := entity.FarmDepreciationManualInput{
ProjectFlockId: projectFlockID,
TotalCost: totalCost,
CutoverDate: cutoverDate,
}
if existing != nil {
row.Note = existing.Note
}
return targetDB.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "project_flock_id"}},
DoUpdates: clause.Assignments(map[string]any{
"total_cost": row.TotalCost,
"cutover_date": row.CutoverDate,
"updated_at": now,
}),
}).
Create(&row).Error
}
func (s *recordingService) resolveProjectFlockIDByProjectFlockKandangID(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (uint, error) {
var row struct {
ProjectFlockID uint `gorm:"column:project_flock_id"`
}
err := db.WithContext(ctx).
Table("project_flock_kandangs").
Select("project_flock_id").
Where("id = ?", projectFlockKandangID).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return row.ProjectFlockID, nil
}
func (s *recordingService) sumNoTransferRecordingStockCostByProjectFlockID(ctx context.Context, db *gorm.DB, projectFlockID uint) (float64, error) {
if projectFlockID == 0 {
return 0, nil
}
var total float64
err := db.WithContext(ctx).
Table("recording_stocks AS rs").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Where("rs.project_flock_kandang_id IS NULL").
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (s *recordingService) getFarmDepreciationManualInputByProjectFlockID(
ctx context.Context,
db *gorm.DB,
projectFlockID uint,
) (*entity.FarmDepreciationManualInput, error) {
if projectFlockID == 0 {
return nil, nil
}
var row entity.FarmDepreciationManualInput
err := db.WithContext(ctx).
Where("project_flock_id = ?", projectFlockID).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *recordingService) getEarliestNoTransferRecordingDateByProjectFlockID(
ctx context.Context,
db *gorm.DB,
projectFlockID uint,
) (*time.Time, error) {
if projectFlockID == 0 {
return nil, nil
}
var row struct {
RecordDate *time.Time `gorm:"column:record_date"`
}
err := db.WithContext(ctx).
Table("recording_stocks AS rs").
Select("MIN(r.record_datetime) AS record_date").
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Where("rs.project_flock_kandang_id IS NULL").
Scan(&row).Error
if err != nil {
return nil, err
}
return row.RecordDate, nil
}
func (s *recordingService) resolveEggRequestsToFarmWarehouses( func (s *recordingService) resolveEggRequestsToFarmWarehouses(
ctx context.Context, ctx context.Context,
pfk *entity.ProjectFlockKandang, pfk *entity.ProjectFlockKandang,
@@ -3969,6 +3824,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")
@@ -19,6 +19,11 @@ type TransferLayingRepository interface {
GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error)
GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error)
GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error)
// GetAllApprovedByTargetKandang return semua approved transfer yang menuju ke target kandang itu.
// Dipakai untuk multi-source case di mana 1 target kandang bisa menerima dari multiple transfer
// terpisah (tiap transfer = 1 source). Order: transfer_date ASC, id ASC (kronologis).
GetAllApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) ([]entity.LayingTransfer, error)
GetAllApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint][]entity.LayingTransfer, error)
// Tambah method baru untuk query dengan filter lengkap // Tambah method baru untuk query dengan filter lengkap
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
@@ -362,3 +367,89 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandangs(ctx con
} }
return result, nil return result, nil
} }
// GetAllApprovedByTargetKandang return SEMUA approved transfer ke target kandang itu (bukan hanya yang
// terbaru). Dipakai untuk skenario multi-source di mana 1 target kandang menerima dari multiple transfer
// terpisah, sehingga depresiasi/HPP/recording state perlu aggregate dari semua transfer.
func (r *TransferLayingRepositoryImpl) GetAllApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) ([]entity.LayingTransfer, error) {
if targetProjectFlockKandangID == 0 {
return nil, nil
}
var transfers []entity.LayingTransfer
err := r.db.WithContext(ctx).
Model(&entity.LayingTransfer{}).
Joins("JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = laying_transfers.id AND ltt.deleted_at IS NULL").
Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID).
Where("laying_transfers.deleted_at IS NULL").
Where(`(
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = laying_transfers.id
ORDER BY a.id DESC
LIMIT 1
) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved).
Order("laying_transfers.transfer_date ASC, laying_transfers.id ASC").
Distinct("laying_transfers.*").
Find(&transfers).Error
if err != nil {
return nil, err
}
return transfers, nil
}
// GetAllApprovedByTargetKandangs batch version: return map dari target_pfk_id ke list of approved transfers.
// Order per target: transfer_date ASC, id ASC.
func (r *TransferLayingRepositoryImpl) GetAllApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint][]entity.LayingTransfer, error) {
result := make(map[uint][]entity.LayingTransfer)
if len(pfkIDs) == 0 {
return result, nil
}
type targetTransferRow struct {
TargetPFKID uint `gorm:"column:target_pfk_id"`
TransferID uint `gorm:"column:transfer_id"`
}
var rows []targetTransferRow
err := r.db.WithContext(ctx).Raw(`
SELECT ltt.target_project_flock_kandang_id AS target_pfk_id, ltt.laying_transfer_id AS transfer_id
FROM laying_transfer_targets ltt
JOIN laying_transfers t ON t.id = ltt.laying_transfer_id AND t.deleted_at IS NULL
WHERE ltt.target_project_flock_kandang_id IN ?
AND ltt.deleted_at IS NULL
AND (
SELECT a.action FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = t.id
ORDER BY a.id DESC LIMIT 1
) = ?
ORDER BY t.transfer_date ASC, t.id ASC
`,
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
).Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return result, nil
}
transferIDs := make([]uint, 0, len(rows))
targetsByTransfer := make(map[uint][]uint, len(rows))
for _, row := range rows {
transferIDs = append(transferIDs, row.TransferID)
targetsByTransfer[row.TransferID] = append(targetsByTransfer[row.TransferID], row.TargetPFKID)
}
var transfers []entity.LayingTransfer
if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Order("transfer_date ASC, id ASC").Find(&transfers).Error; err != nil {
return nil, err
}
for i := range transfers {
for _, targetID := range targetsByTransfer[transfers[i].Id] {
result[targetID] = append(result[targetID], transfers[i])
}
}
return result, nil
}
@@ -1617,6 +1617,13 @@ func (s *transferLayingService) validateKandangOwnership(
return nil return nil
} }
// validateTargetSourceLineage memvalidasi bahwa source kandang yang sama TIDAK boleh ditransfer 2x ke
// target kandang yang sama (anti-duplicate pair). Aturan lama "satu target hanya boleh punya satu
// source" sudah dihapus — sekarang 1 target boleh menerima dari multiple source kandang via transfer
// terpisah (multi-source via N-call approach).
//
// Yang ditolak: kalau ada approved transfer lain (id != excludeTransferID) yang punya pair
// (source = sourceProjectFlockKandangID, target ∈ targetKandangIDs) yang sama.
func (s *transferLayingService) validateTargetSourceLineage( func (s *transferLayingService) validateTargetSourceLineage(
ctx context.Context, ctx context.Context,
sourceProjectFlockKandangID uint, sourceProjectFlockKandangID uint,
@@ -1637,7 +1644,7 @@ func (s *transferLayingService) validateTargetSourceLineage(
} }
seen[targetKandangID] = struct{}{} seen[targetKandangID] = struct{}{}
existingTransfer, err := s.Repository.GetLatestApprovedByTargetKandang(ctx, targetKandangID) existingTransfers, err := s.Repository.GetAllApprovedByTargetKandang(ctx, targetKandangID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
continue continue
@@ -1645,47 +1652,49 @@ func (s *transferLayingService) validateTargetSourceLineage(
s.Log.Errorf("Failed to validate transfer lineage for target kandang %d: %+v", targetKandangID, err) s.Log.Errorf("Failed to validate transfer lineage for target kandang %d: %+v", targetKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying") return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
} }
if existingTransfer == nil {
continue
}
if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID {
continue
}
existingSourceID := uint(0) for i := range existingTransfers {
if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 { existingTransfer := &existingTransfers[i]
existingSourceID = *existingTransfer.SourceProjectFlockKandangId if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID {
} continue
if existingSourceID == 0 && s.LayingTransferSourceRepo != nil {
sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id)
if sourceErr != nil {
s.Log.Errorf("Failed to resolve transfer sources for lineage validation transfer=%d: %+v", existingTransfer.Id, sourceErr)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
} }
for _, source := range sources {
if source.SourceProjectFlockKandangId != 0 { // Source di header (single source of truth per migration 20260307130342).
existingSourceID = source.SourceProjectFlockKandangId existingSourceID := uint(0)
break if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 {
existingSourceID = *existingTransfer.SourceProjectFlockKandangId
}
// Fallback ke laying_transfer_sources untuk transfer yang belum punya source di header
// (historis pre-migration 20260307130342).
if existingSourceID == 0 && s.LayingTransferSourceRepo != nil {
sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id)
if sourceErr != nil {
s.Log.Errorf("Failed to resolve transfer sources for lineage validation transfer=%d: %+v", existingTransfer.Id, sourceErr)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
}
for _, source := range sources {
if source.SourceProjectFlockKandangId == sourceProjectFlockKandangID {
existingSourceID = source.SourceProjectFlockKandangId
break
}
} }
} }
}
if existingSourceID == 0 {
continue
}
if existingSourceID == sourceProjectFlockKandangID {
continue
}
return fiber.NewError( if existingSourceID != sourceProjectFlockKandangID {
fiber.StatusBadRequest, continue
fmt.Sprintf( }
"Kandang tujuan %d sudah memiliki lineage sumber kandang %d dari transfer %s. Tidak boleh ganti ke sumber kandang %d.",
targetKandangID, return fiber.NewError(
existingSourceID, fiber.StatusBadRequest,
existingTransfer.TransferNumber, fmt.Sprintf(
sourceProjectFlockKandangID, "Source kandang %d sudah pernah ditransfer ke target kandang %d via transfer %s. Tidak boleh duplikat (source, target) pair yang sama.",
), sourceProjectFlockKandangID,
) targetKandangID,
existingTransfer.TransferNumber,
),
)
}
} }
return nil return nil
+3
View File
@@ -61,6 +61,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
expenseRealizationRepo, expenseRealizationRepo,
projectFlockKandangRepository, projectFlockKandangRepository,
documentSvc, documentSvc,
commonSvc.NewFifoPaymentService(db, utils.Log),
validate, validate,
) )
expenseBridge := service.NewExpenseBridge( expenseBridge := service.NewExpenseBridge(
@@ -72,6 +73,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
) )
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
fifoPaymentService := commonSvc.NewFifoPaymentService(db, utils.Log)
purchaseService := service.NewPurchaseService( purchaseService := service.NewPurchaseService(
validate, validate,
@@ -84,6 +86,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
approvalService, approvalService,
expenseBridge, expenseBridge,
fifoStockV2Service, fifoStockV2Service,
fifoPaymentService,
documentSvc, documentSvc,
) )
@@ -24,7 +24,6 @@ type PurchaseRepository interface {
UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error
DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error
NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error)
NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error)
BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error
SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error)
@@ -369,9 +368,8 @@ func (r *PurchaseRepositoryImpl) NextPrNumber(ctx context.Context, tx *gorm.DB)
return r.generateSequentialNumber(ctx, tx, "pr_number", utils.PurchasePRNumberPrefix, utils.PurchaseNumberPadding) return r.generateSequentialNumber(ctx, tx, "pr_number", utils.PurchasePRNumberPrefix, utils.PurchaseNumberPadding)
} }
func (r *PurchaseRepositoryImpl) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) { // NOTE: NextPoNumber dihapus per migration 20260529143940 — po_number sekarang
return r.generateSequentialNumber(ctx, tx, "po_number", utils.PurchasePONumberPrefix, utils.PurchaseNumberPadding) // di-derive dari pr_number (swap prefix) via derivePoFromPr di purchase.service.go.
}
func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) {
db := tx db := tx

Some files were not shown because too many files have changed in this diff Show More