Compare commits

...

87 Commits

Author SHA1 Message Date
giovanni 33bae94d43 fix over consume by code, revert migration overconsume sell 2026-06-06 07:49:34 +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 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 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 0ff720453f add api for edit chickin date 2026-06-03 11:56:32 +07:00
giovanni a70a69a5be add validasi overselling telur 2026-06-03 10:26:40 +07: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
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 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 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 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 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 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 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 7846487254 add migration for drift stock logs 2026-05-28 20:59:41 +07:00
giovanni 0ed67955a6 new file migration 2026-05-28 19:17:51 +07:00
giovanni 679d835fbb add migration for normalize jamali non aktif to gudang farm jamali 2026-05-28 17:27:46 +07:00
giovanni fecbcab48d initial refactori trasnfer to laying, and depretitation to 25 week 2026-05-27 15:00:13 +07:00
Giovanni Gabriel Septriadi 2da476b276 Merge branch 'development' into 'production'
add excel export for purchase supplier report

See merge request mbugroup/lti-api!563
2026-05-25 08:16:24 +00:00
Giovanni Gabriel Septriadi 3232fc90bb Merge branch 'export/marketing' into 'development'
fix list penjualan and export penjualan dengan qty

See merge request mbugroup/lti-api!562
2026-05-25 07:53:38 +00:00
giovanni ef985b5da5 fix list penjualan and export penjualan dengan qty 2026-05-25 14:50:01 +07:00
Rivaldi A N S 55666c1dcd Merge branch 'feat/export-balance-monitoring' into 'development'
[FEAT][BE] Export Balance Monitoring

See merge request mbugroup/lti-api!561
2026-05-25 07:25:03 +00:00
ValdiANS c107f0f683 feat(reports): add Excel export to balance monitoring endpoint
Add ?export=excel support to GetBalanceMonitoring. Creates a new
repport.balance_monitoring.export.go with a 2-row merged header layout
matching the UI (Penjualan Ayam and Penjualan Telur grouped columns),
a totals row, red styling for negative Saldo Akhir, and frozen panes
below the header rows. Exported data reflects all active query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:18:47 +07:00
Rivaldi A N S ba8f00a560 Merge branch 'feat/export-report-purchases-per-supplier' into 'development'
[FEAT][BE] Export Report Purchases Per Supplier

See merge request mbugroup/lti-api!560
2026-05-25 04:35:15 +00:00
ValdiANS 65a1282312 add excel export for purchase supplier report
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:25:53 +07:00
Giovanni Gabriel Septriadi 1ca632d838 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!559
2026-05-25 04:03:38 +00:00
Giovanni Gabriel Septriadi f0403e2699 Merge branch 'hot-fix/cikaum' into 'production'
add migration for normalize wrong location pullet cikaum

See merge request mbugroup/lti-api!558
2026-05-23 04:25:29 +00:00
giovanni 3e34da7385 add migration for normalize wrong location pullet cikaum 2026-05-23 11:12:36 +07:00
Giovanni Gabriel Septriadi 8750e2ffec Merge branch 'fix/location-flock' into 'development'
add migration for normalize wrong location pullet cikaum

See merge request mbugroup/lti-api!557
2026-05-23 04:07:29 +00:00
giovanni 3429529162 add migration for normalize wrong location pullet cikaum 2026-05-23 11:06:33 +07:00
Giovanni Gabriel Septriadi 32b8acb9dc Merge branch 'fix/po-a' into 'development'
fix monitoring saldo

See merge request mbugroup/lti-api!556
2026-05-23 02:36:42 +00:00
giovanni 1992005b01 fix monitoring saldo 2026-05-23 09:32:21 +07:00
Giovanni Gabriel Septriadi 0d7a0e30cd Merge branch 'fix/po-a' into 'development'
fix debt supplier ekspedisi only realisasi

See merge request mbugroup/lti-api!555
2026-05-22 12:55:39 +00:00
giovanni b12f563bc4 fix debt supplier ekspedisi only realisasi 2026-05-22 19:54:53 +07:00
Giovanni Gabriel Septriadi d0e7b7aad1 Merge branch 'fix/po-a' into 'development'
fix monitorin saldo without sales order;format date excel po

See merge request mbugroup/lti-api!554
2026-05-22 12:41:05 +00:00
giovanni c676aed371 fix monitorin saldo without sales order;format date excel po 2026-05-22 19:40:05 +07:00
Giovanni Gabriel Septriadi e781115390 Merge branch 'hot-fix/price-adj' into 'production'
hot fit update price adjustment stock

See merge request mbugroup/lti-api!553
2026-05-22 11:50:45 +00:00
Giovanni Gabriel Septriadi 7bbb6a836c Merge branch 'hot-fix/price-adj' into 'development'
Hot fix/price adj

See merge request mbugroup/lti-api!552
2026-05-22 11:41:53 +00:00
giovanni 6bbab2f1d5 hot fit update price adjustment stock 2026-05-22 18:40:52 +07:00
Giovanni Gabriel Septriadi 70546c2302 Merge branch 'fix/monitoring' into 'development'
fix balance monitoring

See merge request mbugroup/lti-api!550
2026-05-22 08:56:31 +00:00
giovanni 6c7d8ac83e fix balance monitoring 2026-05-22 15:55:26 +07:00
Giovanni Gabriel Septriadi 1e48bc8762 Merge branch 'fix/filter-po' into 'development'
fix

See merge request mbugroup/lti-api!549
2026-05-22 06:28:39 +00:00
giovanni 77a30837e2 fix 2026-05-22 13:27:47 +07:00
Giovanni Gabriel Septriadi a63460e853 Merge branch 'fix/filter-po' into 'development'
fix filter

See merge request mbugroup/lti-api!548
2026-05-22 05:31:57 +00:00
giovanni 1be0fa1a5f fix filter 2026-05-22 12:30:23 +07:00
Giovanni Gabriel Septriadi c9e3905a65 Merge branch 'feat/filter' into 'development'
adjust export format purchase and filter

See merge request mbugroup/lti-api!547
2026-05-21 04:49:46 +00:00
giovanni 495f5f5cc1 adjust export format purchase and filter 2026-05-21 11:48:24 +07:00
Giovanni Gabriel Septriadi 71e80634b1 Merge branch 'feat/bop-finance' into 'development'
add vendor ekspedisi to laporan keuangan

See merge request mbugroup/lti-api!546
2026-05-21 01:42:48 +00:00
giovanni af2b3366ba add vendor ekspedisi to laporan keuanga 2026-05-20 23:10:08 +07:00
Giovanni Gabriel Septriadi e015e20b5c Merge branch 'fix/expenses' into 'development'
[FIX][BE]: adjust response get report expense

See merge request mbugroup/lti-api!545
2026-05-20 07:40:50 +00:00
giovanni d92d28c892 adjust response get report expense 2026-05-20 14:39:36 +07:00
Giovanni Gabriel Septriadi 60bdd4a31a Merge branch 'feat/monitoring-saldo' into 'development'
add api monitoring saldo customer

See merge request mbugroup/lti-api!544
2026-05-20 03:35:50 +00:00
Giovanni Gabriel Septriadi cce0d44f83 Merge branch 'feat/lap-keuangan' into 'development'
add export customer payment control

See merge request mbugroup/lti-api!543
2026-05-20 01:49:58 +00:00
giovanni c8623e2f7c add export customer payment control 2026-05-19 22:42:46 +07:00
giovanni 6fc4ad5773 add api monitoring saldo customer 2026-05-19 18:42:57 +07:00
Giovanni Gabriel Septriadi e61625d2f7 Merge branch 'feat/lap-keuangan' into 'development'
[FEAT][BE]: add export laporan keuangan hutang ke supplier

See merge request mbugroup/lti-api!542
2026-05-19 11:41:05 +00:00
giovanni 907b695526 add export laporang keuangan hutang ke supplier 2026-05-19 18:38:58 +07:00
122 changed files with 10212 additions and 838 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)
}
+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)
}
}
@@ -102,11 +102,17 @@ 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)
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)
@@ -230,6 +236,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,
@@ -373,42 +435,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(
@@ -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
}
+138 -47
View File
@@ -1191,26 +1191,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
@@ -1344,20 +1390,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 +1418,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 +1449,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 +1464,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 +1487,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 +1534,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 +1556,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 +1815,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 {
@@ -57,6 +57,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,6 +101,19 @@ 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
} }
@@ -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,4 @@
UPDATE adjustment_stocks
SET price = 9535,
grand_total = ROUND(8700 * 9535, 3)
WHERE id = 532 AND adj_number = 'ADJ-00507';
@@ -0,0 +1,5 @@
UPDATE adjustment_stocks
SET price = 12635,
grand_total = ROUND(8700 * 12635, 3)
WHERE id = 532 AND adj_number = 'ADJ-00507';
@@ -0,0 +1,31 @@
BEGIN;
-- Rollback konsolidasi: kembalikan data ke loc 18 / 25 sesuai snapshot pre-migration.
-- Order: un-soft-delete locations dulu agar FK tidak gagal saat UPDATE child.
-- 1. Un-soft-delete locations
UPDATE locations SET deleted_at = NULL WHERE id IN (18, 25);
-- 2. project_flocks: PF 30 -> 18, PF 25 & 31 -> 25
UPDATE project_flocks SET location_id = 18, updated_at = NOW() WHERE id = 30;
UPDATE project_flocks SET location_id = 25, updated_at = NOW() WHERE id IN (25, 31);
-- 3. kandangs: K9, K72, K117 -> 18; K10, K73, K116 -> 25
UPDATE kandangs SET location_id = 18, updated_at = NOW() WHERE id IN (9, 72, 117);
UPDATE kandangs SET location_id = 25, updated_at = NOW() WHERE id IN (10, 73, 116);
-- 4. kandang_groups: KG 26, 68 -> 18; KG 27, 67 -> 25
UPDATE kandang_groups SET location_id = 18, updated_at = NOW() WHERE id IN (26, 68);
UPDATE kandang_groups SET location_id = 25, updated_at = NOW() WHERE id IN (27, 67);
-- 5. warehouses: W27, W145, W152 -> 18; W3, W146, W153 -> 25
UPDATE warehouses SET location_id = 18, updated_at = NOW() WHERE id IN (27, 145, 152);
UPDATE warehouses SET location_id = 25, updated_at = NOW() WHERE id IN (3, 146, 153);
-- 6. expenses: list eksplisit per location
UPDATE expenses SET location_id = 18, updated_at = NOW()
WHERE id IN (36, 345, 500, 501, 502, 503, 504, 505, 506, 507, 508);
UPDATE expenses SET location_id = 25, updated_at = NOW()
WHERE id IN (9, 37, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518);
COMMIT;
@@ -0,0 +1,34 @@
BEGIN;
-- Konsolidasi 3 lokasi "Pullet Cikaum" jadi 1.
-- Pindahkan semua data di loc 18 (Pullet Cikaum 1) & 25 (Pullet Cikaum 2) ke loc 2 (Pullet Cikaum).
-- Urutan wajib: semua UPDATE child harus selesai SEBELUM soft-delete locations,
-- karena trigger trg_soft_delete_fk_locations akan RAISE EXCEPTION untuk FK
-- RESTRICT (project_flocks, kandangs, kandang_groups, expenses) atau SET NULL
-- untuk warehouses kalau masih ada child yang reference.
-- 1. project_flocks (PF 25, 30, 31)
UPDATE project_flocks SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 2. kandangs (K9, K72, K117, K10, K73, K116)
UPDATE kandangs SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 3. kandang_groups (KG 26, 68, 27, 67)
UPDATE kandang_groups SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 4. warehouses (W3, W27, W145, W146, W152, W153)
UPDATE warehouses SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 5. expenses (23 row BOP)
UPDATE expenses SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 6. Soft-delete locations 18 & 25 (kosong, aman karena semua child sudah pindah)
UPDATE locations SET deleted_at = NOW()
WHERE id IN (18, 25) AND deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,99 @@
BEGIN;
-- ============================================================
-- Rollback dynamic via audit snapshots di schema `migration_audit.jamali_w10_*`.
-- Semua reverse dibaca dari snapshot yang dibuat oleh UP migration —
-- tidak ada IDs/qty yang hardcode. Robust terhadap data drift antara
-- dump time dan UP apply time (misalnya row baru warehouse_id=10
-- yang muncul setelah dump diambil).
--
-- LIMITASI: FK relinks di stock_logs / stock_allocations / recording_eggs /
-- marketing_products / dll. TIDAK direverse di sini (skip audit per-row
-- untuk hemat storage ~40MB). Setelah down, 9 PW W10 yang di-restore
-- akan kosong dari child rows (semua child masih pointing ke W25 PW
-- yang sebelumnya menerima merge). Untuk rollback penuh, restore DB
-- dari backup pre-migration.
-- ============================================================
-- Guard: pastikan audit tables ada (kalau tidak, fail-loud)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'migration_audit'
AND table_name = 'jamali_w10_pw_deleted_snapshot'
) THEN
RAISE EXCEPTION 'Audit table migration_audit.jamali_w10_* tidak ditemukan. UP migration belum dijalankan atau audit sudah di-drop. Restore dari DB backup jika perlu.';
END IF;
END $$;
-- 1. Un-soft-delete warehouse 10 (kalau memang di-softdelete oleh UP)
UPDATE warehouses w
SET deleted_at = NULL, updated_at = NOW()
FROM migration_audit.jamali_w10_warehouse_softdeleted a
WHERE w.id = a.id;
-- 2. Un-soft-delete stock_transfers self-loop yang disoft-delete UP step 7.1
UPDATE stock_transfers st
SET deleted_at = NULL, updated_at = NOW()
FROM migration_audit.jamali_w10_st_softdeleted a
WHERE st.id = a.id;
-- 3. Reverse stock_transfers redirect (CASE-based dari snapshot was_from_w10/was_to_w10)
UPDATE stock_transfers st
SET from_warehouse_id = CASE WHEN a.was_from_w10 THEN 10 ELSE st.from_warehouse_id END,
to_warehouse_id = CASE WHEN a.was_to_w10 THEN 10 ELSE st.to_warehouse_id END,
updated_at = NOW()
FROM migration_audit.jamali_w10_st_redirected a
WHERE st.id = a.id;
-- 3b. Self-loop transfers (W10<->W25 awal) juga punya from_warehouse_id=25 atau
-- to_warehouse_id=25 setelah UP step 7.2. Karena snapshot jamali_w10_st_softdeleted
-- punya kolom from_warehouse_id & to_warehouse_id asli, pakai itu untuk reverse.
UPDATE stock_transfers st
SET from_warehouse_id = 10, updated_at = NOW()
FROM migration_audit.jamali_w10_st_softdeleted a
WHERE st.id = a.id AND a.from_warehouse_id = 10;
UPDATE stock_transfers st
SET to_warehouse_id = 10, updated_at = NOW()
FROM migration_audit.jamali_w10_st_softdeleted a
WHERE st.id = a.id AND a.to_warehouse_id = 10;
-- 4. Reverse purchase_items.warehouse_id 25 -> 10
UPDATE purchase_items
SET warehouse_id = 10
WHERE id IN (SELECT id FROM migration_audit.jamali_w10_purchase_items);
-- 5. Reverse W10-only PW (warehouse_id 25 -> 10, restore pfk asli dari snapshot)
UPDATE product_warehouses pw
SET warehouse_id = 10, project_flock_kandang_id = a.original_pfk
FROM migration_audit.jamali_w10_pw_w10only_snapshot a
WHERE pw.id = a.id;
-- 6. Subtract qty dari W25 PW (reverse merge)
-- WARNING: kalau W25 qty sudah dikonsumsi pasca-UP (sales/recording/dll),
-- hasil bisa negatif. Tidak ada CHECK constraint di product_warehouses.qty,
-- jadi silent. Operator harus verifikasi manual post-down:
-- SELECT id, qty FROM product_warehouses WHERE qty < 0;
UPDATE product_warehouses pw
SET qty = pw.qty - a.merged_qty
FROM migration_audit.jamali_w10_qty_merge a
WHERE pw.id = a.target_pw_id;
-- 7. Re-INSERT 9 W10 PW rows yang di-DELETE oleh UP (PK asli + qty asli)
INSERT INTO product_warehouses (id, product_id, warehouse_id, qty, project_flock_kandang_id)
SELECT id, product_id, 10, qty, project_flock_kandang_id
FROM migration_audit.jamali_w10_pw_deleted_snapshot;
-- 8. Cleanup audit tables (drop satu per satu, tidak wildcard untuk safety)
DROP TABLE migration_audit.jamali_w10_pw_deleted_snapshot;
DROP TABLE migration_audit.jamali_w10_qty_merge;
DROP TABLE migration_audit.jamali_w10_pw_w10only_snapshot;
DROP TABLE migration_audit.jamali_w10_st_softdeleted;
DROP TABLE migration_audit.jamali_w10_st_redirected;
DROP TABLE migration_audit.jamali_w10_purchase_items;
DROP TABLE migration_audit.jamali_w10_warehouse_softdeleted;
-- Schema migration_audit dipertahankan (bisa dipakai migration lain di masa depan)
COMMIT;
@@ -0,0 +1,241 @@
BEGIN;
-- ============================================================
-- Normalisasi warehouse 10 (Jamali NON_AKTIF) -> 25 (Gudang Farm Jamali)
-- Background: Dua warehouse LOKASI di area & lokasi sama (area_id=6,
-- location_id=16). W10 sudah ditandai NON_AKTIF tapi masih punya 13
-- product_warehouses, 3,590 stock_logs, ~790K stock_allocations,
-- 332 marketing_products, 17 purchase_items, dan 14 stock_transfers.
-- Migration ini konsolidasikan semua relasi ke W25 lalu soft-delete W10.
--
-- Klasifikasi data:
-- A. 9 product_warehouses W10 overlap dengan W25 (sama product_id, pfk=NULL)
-- -> merge qty ke W25, relink semua FK ke product_warehouses.id,
-- lalu DELETE W10 PW rows.
-- B. 4 product_warehouses W10-only -> UPDATE warehouse_id=25.
-- Rows 1188/1189/1190 punya pfk=98 (anomali LOKASI, seharusnya NULL
-- per aturan di CLAUDE.md [2026-05-06]) -> normalisasi sekalian.
-- C. 17 purchase_items.warehouse_id=10 -> UPDATE 25 (no unique conflict).
-- D. 3 stock_transfers W10<->W25 (PND-LTI-00107/00109/00119) akan jadi
-- self-loop W25<->W25 setelah merge -> soft-delete.
-- E. 12 stock_transfers EGG_FARM_CUTOVER to_warehouse_id=10 -> UPDATE 25.
-- F. warehouse_id=10 sendiri -> soft-delete.
--
-- UP membuat 7 snapshot table di schema `migration_audit.jamali_w10_*`
-- sebelum mutasi. DOWN baca snapshot itu untuk reverse dynamic (tidak
-- hardcode IDs/qty), sehingga apapun yang ada di production saat UP
-- dijalankan akan ter-audit dan ter-reverse. FK relinks
-- (stock_logs/stock_allocations/dll) TIDAK di-audit (storage ~40MB)
-- — limitation: tidak bisa di-reverse DOWN, full rollback = DB backup.
-- ============================================================
-- STEP -1: Buat schema audit + snapshot tables (idempotent rerun via DROP IF EXISTS)
CREATE SCHEMA IF NOT EXISTS migration_audit;
DROP TABLE IF EXISTS migration_audit.jamali_w10_pw_deleted_snapshot;
DROP TABLE IF EXISTS migration_audit.jamali_w10_qty_merge;
DROP TABLE IF EXISTS migration_audit.jamali_w10_pw_w10only_snapshot;
DROP TABLE IF EXISTS migration_audit.jamali_w10_st_softdeleted;
DROP TABLE IF EXISTS migration_audit.jamali_w10_st_redirected;
DROP TABLE IF EXISTS migration_audit.jamali_w10_purchase_items;
DROP TABLE IF EXISTS migration_audit.jamali_w10_warehouse_softdeleted;
-- Snapshot 9 W10 PW yang akan di-DELETE (overlap dgn W25, pfk=NULL)
CREATE TABLE migration_audit.jamali_w10_pw_deleted_snapshot AS
SELECT pw10.id, pw10.product_id, pw10.qty, pw10.project_flock_kandang_id
FROM product_warehouses pw10
JOIN product_warehouses pw25
ON pw25.product_id = pw10.product_id
AND pw25.warehouse_id = 25
AND pw25.project_flock_kandang_id IS NULL
WHERE pw10.warehouse_id = 10 AND pw10.project_flock_kandang_id IS NULL;
-- Snapshot qty delta per W25 target (untuk reverse subtract)
CREATE TABLE migration_audit.jamali_w10_qty_merge AS
SELECT pw25.id AS target_pw_id, pw10.id AS source_pw_id, pw10.qty AS merged_qty
FROM product_warehouses pw10
JOIN product_warehouses pw25
ON pw25.product_id = pw10.product_id
AND pw25.warehouse_id = 25
AND pw25.project_flock_kandang_id IS NULL
WHERE pw10.warehouse_id = 10 AND pw10.project_flock_kandang_id IS NULL;
-- Snapshot W10-only PW (yang akan di-UPDATE warehouse_id 10->25)
CREATE TABLE migration_audit.jamali_w10_pw_w10only_snapshot AS
SELECT pw10.id, pw10.project_flock_kandang_id AS original_pfk
FROM product_warehouses pw10
WHERE pw10.warehouse_id = 10
AND pw10.id NOT IN (SELECT id FROM migration_audit.jamali_w10_pw_deleted_snapshot);
-- Snapshot stock_transfers yang akan di-soft-delete (self-loop W10<->W25)
-- Simpan from/to_warehouse_id asli supaya DOWN bisa reverse direction tepat
CREATE TABLE migration_audit.jamali_w10_st_softdeleted AS
SELECT id, movement_number, from_warehouse_id, to_warehouse_id
FROM stock_transfers
WHERE deleted_at IS NULL
AND ((from_warehouse_id = 10 AND to_warehouse_id = 25)
OR (from_warehouse_id = 25 AND to_warehouse_id = 10));
-- Snapshot stock_transfers yang akan di-UPDATE (W10<->other, bukan self-loop)
CREATE TABLE migration_audit.jamali_w10_st_redirected AS
SELECT id,
(from_warehouse_id = 10) AS was_from_w10,
(to_warehouse_id = 10) AS was_to_w10
FROM stock_transfers
WHERE deleted_at IS NULL
AND (from_warehouse_id = 10 OR to_warehouse_id = 10)
AND id NOT IN (SELECT id FROM migration_audit.jamali_w10_st_softdeleted);
-- Snapshot purchase_items IDs (cheap, ~17 rows)
CREATE TABLE migration_audit.jamali_w10_purchase_items AS
SELECT id FROM purchase_items WHERE warehouse_id = 10;
-- Snapshot warehouses soft-delete flag (1 row, kalau memang masih aktif)
CREATE TABLE migration_audit.jamali_w10_warehouse_softdeleted AS
SELECT id FROM warehouses WHERE id = 10 AND deleted_at IS NULL;
-- STEP 0: Pre-check sanity (idempotent guards)
DO $$
DECLARE v_count INT;
BEGIN
SELECT COUNT(*) INTO v_count FROM warehouses
WHERE id IN (10, 25) AND type = 'LOKASI' AND area_id = 6 AND location_id = 16;
IF v_count <> 2 THEN
RAISE EXCEPTION 'Pre-check: warehouse 10/25 schema mismatch (got % rows)', v_count;
END IF;
SELECT COUNT(*) INTO v_count FROM purchase_items a
JOIN purchase_items b ON a.purchase_id = b.purchase_id
AND a.product_id = b.product_id
AND a.id <> b.id
WHERE a.warehouse_id = 10 AND b.warehouse_id = 25;
IF v_count > 0 THEN
RAISE EXCEPTION 'Pre-check: % purchase_items unique conflict (purchase_id,product_id)', v_count;
END IF;
END $$;
-- STEP 1: Merge qty W10 -> W25 untuk overlap (pfk=NULL)
UPDATE product_warehouses pw25
SET qty = pw25.qty + pw10.qty
FROM product_warehouses pw10
WHERE pw10.warehouse_id = 10 AND pw10.project_flock_kandang_id IS NULL
AND pw25.warehouse_id = 25 AND pw25.project_flock_kandang_id IS NULL
AND pw25.product_id = pw10.product_id;
-- STEP 2: Build temp mapping (W10 PW id -> W25 PW id) untuk overlap saja
CREATE TEMP TABLE _pw_map ON COMMIT DROP AS
SELECT pw10.id AS old_id, pw25.id AS new_id
FROM product_warehouses pw10
JOIN product_warehouses pw25
ON pw25.product_id = pw10.product_id
AND pw25.warehouse_id = 25
AND pw25.project_flock_kandang_id IS NULL
WHERE pw10.warehouse_id = 10 AND pw10.project_flock_kandang_id IS NULL;
CREATE INDEX ON _pw_map(old_id);
-- STEP 3: Relink semua FK ke product_warehouses.id (hanya rows di _pw_map)
UPDATE stock_logs SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE stock_logs.product_warehouse_id = m.old_id;
UPDATE stock_allocations SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE stock_allocations.product_warehouse_id = m.old_id;
UPDATE recording_eggs SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE recording_eggs.product_warehouse_id = m.old_id;
UPDATE recording_stocks SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE recording_stocks.product_warehouse_id = m.old_id;
UPDATE recording_depletions SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE recording_depletions.product_warehouse_id = m.old_id;
UPDATE recording_depletions SET source_product_warehouse_id = m.new_id
FROM _pw_map m WHERE recording_depletions.source_product_warehouse_id = m.old_id;
UPDATE adjustment_stocks SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE adjustment_stocks.product_warehouse_id = m.old_id;
UPDATE marketing_products SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE marketing_products.product_warehouse_id = m.old_id;
UPDATE marketing_delivery_products SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE marketing_delivery_products.product_warehouse_id = m.old_id;
UPDATE project_chickins SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE project_chickins.product_warehouse_id = m.old_id;
UPDATE project_chickin_details SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE project_chickin_details.product_warehouse_id = m.old_id;
UPDATE project_flock_populations SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE project_flock_populations.product_warehouse_id = m.old_id;
UPDATE laying_transfers SET source_product_warehouse_id = m.new_id
FROM _pw_map m WHERE laying_transfers.source_product_warehouse_id = m.old_id;
UPDATE laying_transfer_sources SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE laying_transfer_sources.product_warehouse_id = m.old_id;
UPDATE laying_transfer_targets SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE laying_transfer_targets.product_warehouse_id = m.old_id;
UPDATE stock_transfer_details SET source_product_warehouse_id = m.new_id
FROM _pw_map m WHERE stock_transfer_details.source_product_warehouse_id = m.old_id;
UPDATE stock_transfer_details SET dest_product_warehouse_id = m.new_id
FROM _pw_map m WHERE stock_transfer_details.dest_product_warehouse_id = m.old_id;
UPDATE purchase_items SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE purchase_items.product_warehouse_id = m.old_id;
-- FIFO v2 tables (kosong di dump 2026-05-25, defensive)
UPDATE fifo_stock_v2_operation_log SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE fifo_stock_v2_operation_log.product_warehouse_id = m.old_id;
UPDATE fifo_stock_v2_reflow_checkpoints SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE fifo_stock_v2_reflow_checkpoints.product_warehouse_id = m.old_id;
UPDATE fifo_stock_v2_shadow_allocations SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE fifo_stock_v2_shadow_allocations.product_warehouse_id = m.old_id;
-- STEP 4: Hard-delete W10 PW yang sudah merged (9 rows expected)
DELETE FROM product_warehouses WHERE id IN (SELECT old_id FROM _pw_map);
-- STEP 5: Sisa W10 PW (4 rows: 1188/1189/1190/1196) -> warehouse_id=25,
-- pfk dinormalisasi ke NULL sekalian (LOKASI rule)
UPDATE product_warehouses
SET warehouse_id = 25, project_flock_kandang_id = NULL
WHERE warehouse_id = 10;
-- STEP 6: purchase_items.warehouse_id (17 rows)
UPDATE purchase_items SET warehouse_id = 25 WHERE warehouse_id = 10;
-- STEP 7: stock_transfers
-- 7.1 Soft-delete self-loop (W10<->W25 akan jadi W25<->W25)
UPDATE stock_transfers
SET deleted_at = NOW(), updated_at = NOW()
WHERE deleted_at IS NULL
AND ((from_warehouse_id = 10 AND to_warehouse_id = 25)
OR (from_warehouse_id = 25 AND to_warehouse_id = 10));
-- 7.2 Sisa W10<->other -> 25 (12 EGG_FARM_CUTOVER ke W10)
UPDATE stock_transfers SET from_warehouse_id = 25, updated_at = NOW() WHERE from_warehouse_id = 10;
UPDATE stock_transfers SET to_warehouse_id = 25, updated_at = NOW() WHERE to_warehouse_id = 10;
-- STEP 8: Soft-delete warehouse 10 sendiri
UPDATE warehouses SET deleted_at = NOW(), updated_at = NOW()
WHERE id = 10 AND deleted_at IS NULL;
-- STEP 9: Post-check (fail-fast jika ada residu)
DO $$
DECLARE v_count INT;
BEGIN
SELECT COUNT(*) INTO v_count FROM product_warehouses WHERE warehouse_id = 10;
IF v_count <> 0 THEN RAISE EXCEPTION 'product_warehouses W10 residual %', v_count; END IF;
SELECT COUNT(*) INTO v_count FROM purchase_items WHERE warehouse_id = 10;
IF v_count <> 0 THEN RAISE EXCEPTION 'purchase_items W10 residual %', v_count; END IF;
SELECT COUNT(*) INTO v_count FROM stock_transfers
WHERE deleted_at IS NULL AND (from_warehouse_id = 10 OR to_warehouse_id = 10);
IF v_count <> 0 THEN RAISE EXCEPTION 'stock_transfers W10 residual %', v_count; END IF;
END $$;
COMMIT;
@@ -0,0 +1,29 @@
BEGIN;
-- ============================================================
-- Rollback stock_log drift fix: DELETE corrective rows yang di-insert UP.
-- IDs ditarik dari audit table `migration_audit.jamali_w10_stocklog_corrections`.
-- Setelah delete, `last_stock_log.stock` kembali ke nilai pre-fix (drift muncul lagi).
-- ============================================================
-- Guard: audit table harus ada
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'migration_audit'
AND table_name = 'jamali_w10_stocklog_corrections'
) THEN
RAISE EXCEPTION
'Audit table migration_audit.jamali_w10_stocklog_corrections tidak ditemukan. UP belum dijalankan atau audit sudah di-drop.';
END IF;
END $$;
-- DELETE corrective stock_logs yang di-insert oleh UP
DELETE FROM stock_logs
WHERE id IN (SELECT stock_log_id FROM migration_audit.jamali_w10_stocklog_corrections);
-- Cleanup audit table
DROP TABLE migration_audit.jamali_w10_stocklog_corrections;
COMMIT;
@@ -0,0 +1,111 @@
BEGIN;
-- ============================================================
-- Fix stock_log drift pasca-merge warehouse Jamali (NON_AKTIF) -> Gudang Farm Jamali.
-- Follow-up migration setelah 20260528121631_normalize_warehouse_jamali_10_to_25.
--
-- Setelah merge, `stock_logs.stock` (running ledger) drift dari
-- `product_warehouses.qty` karena: pre-existing drift di W10 + W25 sources,
-- plus FIFO reflow yang trigger pasca-merge (Recording-Edit) recompute
-- pw.qty tapi stock_logs tidak ikut update.
--
-- Migration ini insert 1 ADJUSTMENT stock_log corrective per PW yang drift
-- supaya `last_stock_log.stock = pw.qty`. Logic ekivalen dengan
-- `cmd/fix-stock-log-drift`.
--
-- Karakteristik dynamic:
-- - Tidak hardcode PW IDs atau drift values
-- - Iterate via merge target + W10-only kept PWs (data-driven dari snapshot)
-- - Per PW: hitung drift runtime, skip kalau negligible (< 0.001) atau no logs
-- - Track stock_log IDs yang di-insert untuk DOWN reverse
-- ============================================================
-- Guard: previous migration (normalisasi) audit harus ada
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'migration_audit'
AND table_name = 'jamali_w10_qty_merge'
) THEN
RAISE EXCEPTION
'Migration 20260528121631 (normalize_warehouse_jamali) belum dijalankan atau audit-nya sudah di-drop. Apply UP-nya dulu sebelum migration ini.';
END IF;
END $$;
-- Audit table untuk track stock_log IDs yang di-insert (untuk DOWN reverse)
DROP TABLE IF EXISTS migration_audit.jamali_w10_stocklog_corrections;
CREATE TABLE migration_audit.jamali_w10_stocklog_corrections (
stock_log_id BIGINT NOT NULL PRIMARY KEY,
product_warehouse_id BIGINT NOT NULL,
drift NUMERIC(15,3) NOT NULL,
inserted_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert corrective ADJUSTMENT stock_log untuk tiap PW yang drift
DO $$
DECLARE
rec RECORD;
v_last_log_stock NUMERIC(15,3);
v_drift NUMERIC(15,3);
v_new_log_id BIGINT;
v_inserts INT := 0;
BEGIN
FOR rec IN (
SELECT pw.id AS pw_id, pw.qty AS qty
FROM product_warehouses pw
WHERE pw.id IN (
-- Merge target W25 PWs (9 rows)
SELECT target_pw_id FROM migration_audit.jamali_w10_qty_merge
UNION
-- W10-only PWs yang di-update warehouse_id 10->25 (4 rows)
SELECT id FROM migration_audit.jamali_w10_pw_w10only_snapshot
)
) LOOP
-- Ambil stock akhir di stock_logs ledger
SELECT stock INTO v_last_log_stock
FROM stock_logs
WHERE product_warehouse_id = rec.pw_id
ORDER BY id DESC
LIMIT 1;
-- PW tanpa stock_logs entry (mis. 1188/1189/1190 ayam) -> skip
IF v_last_log_stock IS NULL THEN
CONTINUE;
END IF;
v_drift := rec.qty - v_last_log_stock;
-- Drift negligible -> skip
IF ABS(v_drift) < 0.001 THEN
CONTINUE;
END IF;
-- Insert corrective ADJUSTMENT stock_log
INSERT INTO stock_logs (
product_warehouse_id, loggable_type, loggable_id,
notes, increase, decrease, stock, created_by, created_at
) VALUES (
rec.pw_id,
'ADJUSTMENT',
0,
'Koreksi stock_log drift pasca-merge warehouse Jamali (migration 20260528123243)',
CASE WHEN v_drift > 0 THEN v_drift ELSE 0 END,
CASE WHEN v_drift < 0 THEN -v_drift ELSE 0 END,
rec.qty,
1,
NOW()
) RETURNING id INTO v_new_log_id;
-- Track ke audit table untuk DOWN
INSERT INTO migration_audit.jamali_w10_stocklog_corrections (
stock_log_id, product_warehouse_id, drift
) VALUES (v_new_log_id, rec.pw_id, v_drift);
v_inserts := v_inserts + 1;
END LOOP;
RAISE NOTICE 'Inserted % corrective stock_logs to align ledger with pw.qty', v_inserts;
END $$;
COMMIT;
@@ -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;
+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"`
+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)
@@ -98,6 +98,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return db. return db.
Preload("Expense"). Preload("Expense").
Preload("Expense.Supplier"). Preload("Expense.Supplier").
Preload("Expense.Location").
Preload("Kandang"). Preload("Kandang").
Preload("Kandang.Location"). Preload("Kandang.Location").
Preload("Nonstock"). Preload("Nonstock").
@@ -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")
} }
@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations"
@@ -13,6 +14,8 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
const transactionExcelExportFetchLimit = 99999999
type TransactionController struct { type TransactionController struct {
TransactionService service.TransactionService TransactionService service.TransactionService
} }
@@ -107,6 +110,14 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
if isTransactionExcelExportRequest(c) {
results, err := u.getAllTransactionsForExcel(c, query)
if err != nil {
return err
}
return exportTransactionListExcel(c, results)
}
result, totalResults, err := u.TransactionService.GetAll(c, query) result, totalResults, err := u.TransactionService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -149,6 +160,32 @@ func (u *TransactionController) GetOne(c *fiber.Ctx) error {
}) })
} }
func isTransactionExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func (u *TransactionController) getAllTransactionsForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]entity.Payment, error) {
query := *baseQuery
query.Page = 1
query.Limit = transactionExcelExportFetchLimit
results := make([]entity.Payment, 0)
for {
pageResults, total, err := u.TransactionService.GetAll(c, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func (u *TransactionController) DeleteOne(c *fiber.Ctx) error { func (u *TransactionController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -0,0 +1,307 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
const transactionExportSheetName = "Transaksi"
func exportTransactionListExcel(c *fiber.Ctx, payments []entity.Payment) error {
content, err := buildTransactionExportWorkbook(payments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("transaksi_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildTransactionExportWorkbook(payments []entity.Payment) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != transactionExportSheetName {
if err := file.SetSheetName(defaultSheet, transactionExportSheetName); err != nil {
return nil, err
}
}
if err := setTransactionExportColumns(file); err != nil {
return nil, err
}
if err := setTransactionExportHeaders(file); err != nil {
return nil, err
}
if err := setTransactionExportRows(file, payments); err != nil {
return nil, err
}
if err := file.SetPanes(transactionExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setTransactionExportColumns(file *excelize.File) error {
columnWidths := map[string]float64{
"A": 20,
"B": 22,
"C": 18,
"D": 25,
"E": 14,
"F": 16,
"G": 16,
"H": 22,
"I": 22,
"J": 18,
"K": 18,
"L": 18,
"M": 30,
"N": 22,
"O": 20,
}
sheet := transactionExportSheetName
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setTransactionExportHeaders(file *excelize.File) error {
sheet := transactionExportSheetName
headers := []string{
"Kode Pembayaran",
"No. Referensi",
"Tipe Transaksi",
"Pihak",
"Tipe Pihak",
"Tanggal Bayar",
"Metode Bayar",
"Bank",
"No. Rekening Bank",
"Pemasukan",
"Pengeluaran",
"Nominal",
"Catatan",
"Dibuat Oleh",
"Status",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+"1", header); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "A1", "O1", headerStyle)
}
func setTransactionExportRows(file *excelize.File, payments []entity.Payment) error {
if len(payments) == 0 {
return nil
}
sheet := transactionExportSheetName
for i, p := range payments {
row := strconv.Itoa(i + 2)
if err := writeTransactionExportRow(file, sheet, row, p); err != nil {
return err
}
}
lastRow := strconv.Itoa(len(payments) + 1)
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "O"+lastRow, dataStyle); err != nil {
return err
}
numericStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "J2", "L"+lastRow, numericStyle)
}
func writeTransactionExportRow(file *excelize.File, sheet, row string, p entity.Payment) error {
incomeAmount, expenseAmount := txAmounts(p.Direction, p.Nominal)
values := []interface{}{
safeTxText(p.PaymentCode),
safeTxRefNumber(p.ReferenceNumber),
safeTxText(txTransactionType(p)),
safeTxText(txPartyName(p)),
safeTxText(p.PartyType),
formatTxDate(p.PaymentDate),
safeTxText(p.PaymentMethod),
safeTxBank(p),
safeTxBankAccount(p),
incomeAmount,
expenseAmount,
p.Nominal,
safeTxText(p.Notes),
safeTxText(txCreatedBy(p)),
formatTxStatus(p),
}
for colIdx, val := range values {
colName, err := excelize.ColumnNumberToName(colIdx + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+row, val); err != nil {
return err
}
}
return nil
}
func safeTxText(s string) string {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return "-"
}
return trimmed
}
func safeTxRefNumber(s *string) string {
if s == nil {
return "-"
}
return safeTxText(*s)
}
func safeTxBank(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.Name)
}
func safeTxBankAccount(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.AccountNumber)
}
func formatTxDate(t time.Time) string {
if t.IsZero() {
return "-"
}
loc, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(loc)
}
return t.Format("02-01-2006")
}
func formatTxStatus(p entity.Payment) string {
if p.LatestApproval == nil {
return "-"
}
return safeTxText(p.LatestApproval.StepName)
}
func txTransactionType(p entity.Payment) string {
if p.TransactionType != "" {
return p.TransactionType
}
return p.Direction
}
func txPartyName(p entity.Payment) string {
switch p.PartyType {
case "CUSTOMER":
if p.Customer != nil && p.Customer.Id != 0 {
return p.Customer.Name
}
case "SUPPLIER":
if p.Supplier != nil && p.Supplier.Id != 0 {
return p.Supplier.Name
}
}
return ""
}
func txCreatedBy(p entity.Payment) string {
if p.CreatedUser.Id == 0 {
return ""
}
return p.CreatedUser.Name
}
func txAmounts(direction string, nominal float64) (income, expense float64) {
switch strings.ToUpper(direction) {
case "IN":
return nominal, 0
case "OUT":
return 0, nominal
default:
return 0, 0
}
}
@@ -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
} }
@@ -10,7 +10,7 @@ type Update struct {
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"` TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"` BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
@@ -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) {
@@ -2,7 +2,6 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -71,14 +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": 18, "E": 20, // Sales (new)
"F": 60, "F": 14, // Tipe
"G": 24, "G": 40, // Nama Produk
"H": 20, // Gudang (new)
"I": 10, // Week
"J": 12, // Jumlah
"K": 12, // Qty Peti
"L": 16, // Berat Rata-rata (kg)
"M": 16, // Total Berat (kg)
"N": 18, // Harga Satuan
"O": 18, // Total Harga
"P": 18, // Grand Total
"Q": 24, // Catatan
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -96,13 +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", "No. Order", // A
"Tanggal", "Tanggal", // B
"Status", "Status", // C
"Customer", "Customer", // D
"Grand Total", "Sales", // E (new)
"Products", "Tipe", // F
"Notes", "Nama Produk", // G
"Gudang", // H (new)
"Week", // I
"Jumlah Butir", // J
"Qty Peti", // K
"Berat Rata-rata (kg)", // L
"Total Berat (kg)", // M
"Harga Satuan", // N
"Total Harga", // O
"Grand Total", // P
"Catatan", // Q
} }
for i, header := range headers { for i, header := range headers {
@@ -131,7 +152,7 @@ func setMarketingExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "G1", 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 {
@@ -139,70 +160,296 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
return nil return nil
} }
for i, item := range items { row := 1
rowNumber := i + 2 for _, item := range items {
if err := file.SetCellValue(sheet, "A"+strconv.Itoa(rowNumber), safeMarketingExportText(item.SoNumber)); err != nil { soNumber := safeMarketingExportText(item.SoNumber)
return err soDate := formatMarketingExportDate(item.SoDate)
status := formatMarketingExportStatus(item)
customer := safeMarketingExportText(item.Customer.Name)
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
} }
if err := file.SetCellValue(sheet, "B"+strconv.Itoa(rowNumber), formatMarketingExportDate(item.SoDate)); err != nil {
return err // ── Sales Order branch (all other statuses) ───────────────────────────
grandTotal := sumMarketingGrandTotal(item.SalesOrder)
if len(item.SalesOrder) == 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
} }
if err := file.SetCellValue(sheet, "C"+strconv.Itoa(rowNumber), formatMarketingExportStatus(item)); err != nil {
return err for _, prod := range item.SalesOrder {
} row++
if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil { r := strconv.Itoa(row)
return err
} productName := "-"
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil { if prod.ProductWarehouse != nil && prod.ProductWarehouse.Product != nil {
return err if n := strings.TrimSpace(prod.ProductWarehouse.Product.Name); n != "" {
} productName = n
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil { }
return err }
}
if err := file.SetCellValue(sheet, "G"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Notes)); err != nil { week := "-"
return err if prod.Week != nil {
week = strconv.Itoa(*prod.Week)
}
gudang := "-"
if prod.ProductWarehouse != nil && prod.ProductWarehouse.Warehouse != nil {
gudang = safeMarketingExportText(prod.ProductWarehouse.Warehouse.Name)
}
if err := file.SetCellValue(sheet, "A"+r, soNumber); 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, safeMarketingExportText(prod.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, prod.Qty); err != nil {
return err
}
if prod.TotalPeti != nil {
if err := file.SetCellValue(sheet, "K"+r, *prod.TotalPeti); err != nil {
return err
}
} else {
if err := file.SetCellValue(sheet, "K"+r, "-"); err != nil {
return err
}
}
if err := file.SetCellValue(sheet, "L"+r, prod.AvgWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+r, prod.TotalWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+r, prod.UnitPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+r, prod.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
}
} }
} }
lastRow := len(items) + 1 lastRow := row
lastRowStr := strconv.Itoa(lastRow)
border := []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
}
dataStyle, err := file.NewStyle(&excelize.Style{ dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{ Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true},
Horizontal: "left", Border: border,
Vertical: "center",
WrapText: true,
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
}) })
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A2", "Q"+lastRowStr, dataStyle); err != nil {
if err := file.SetCellStyle(sheet, "A2", "G"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err return err
} }
moneyStyle, err := file.NewStyle(&excelize.Style{ numberStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{ Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"},
Horizontal: "right", Border: border,
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
}) })
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "L2", "P"+lastRowStr, numberStyle); err != nil {
return err
}
return file.SetCellStyle(sheet, "E2", "E"+strconv.Itoa(lastRow), moneyStyle) centerStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: border,
})
if err != nil {
return err
}
for _, col := range []string{"I", "J", "K"} {
if err := file.SetCellStyle(sheet, col+"2", col+lastRowStr, centerStyle); err != nil {
return err
}
}
return nil
} }
func formatMarketingExportDate(value time.Time) string { func formatMarketingExportDate(value time.Time) string {
@@ -226,79 +473,22 @@ func formatMarketingExportStatus(item dto.MarketingListDTO) string {
return safeMarketingExportText(item.LatestApproval.StepName) return safeMarketingExportText(item.LatestApproval.StepName)
} }
func formatMarketingProducts(items []dto.DeliveryMarketingProductDTO) string {
if len(items) == 0 {
return "-"
}
seen := make(map[string]struct{})
names := make([]string, 0, len(items))
for _, item := range items {
if item.ProductWarehouse == nil || item.ProductWarehouse.Product == nil {
continue
}
name := strings.TrimSpace(item.ProductWarehouse.Product.Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return "-"
}
return strings.Join(names, ", ")
}
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 formatMarketingRupiah(value float64) string { func sumDeliveryGrandTotal(groups []dto.DeliveryGroupDTO) float64 {
if math.IsNaN(value) || math.IsInf(value, 0) { total := 0.0
return "Rp 0" for _, g := range groups {
} for _, d := range g.Deliveries {
total += d.TotalPrice
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
} }
} }
for i := rem; i < len(raw); i += 3 { return total
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
} }
func safeMarketingExportText(value string) string { func safeMarketingExportText(value string) string {
@@ -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,7 +28,10 @@ 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"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -197,12 +200,20 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
} }
} }
var grandTotalSO float64
for _, p := range marketing.Products {
grandTotalSO += p.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: marketing.GrandTotal,
SalesOrder: salesOrderProducts, SalesOrder: salesOrderProducts,
DeliveryOrder: extractDeliveryGroupsFromProducts(marketing),
CreatedUser: createdUser, CreatedUser: createdUser,
CreatedAt: marketing.CreatedAt, CreatedAt: marketing.CreatedAt,
UpdatedAt: marketing.UpdatedAt, UpdatedAt: marketing.UpdatedAt,
@@ -376,6 +387,23 @@ func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, wareh
return numberPrefix return numberPrefix
} }
func extractDeliveryGroupsFromProducts(marketing *entity.Marketing) []DeliveryGroupDTO {
var dps []MarketingDeliveryProductDTO
for _, product := range marketing.Products {
if product.DeliveryProduct == nil || product.DeliveryProduct.DeliveryDate == nil {
continue
}
dp := ToMarketingDeliveryProductDTO(*product.DeliveryProduct)
if product.ProductWarehouse.Id != 0 {
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(product.ProductWarehouse)
dp.ProductWarehouse = &mapped
}
dp.ConvertionUnit = product.ConvertionUnit
dps = append(dps, dp)
}
return groupDeliveryProducts(dps, marketing.SoNumber)
}
func collectDoNumbers(marketing *entity.Marketing) []string { func collectDoNumbers(marketing *entity.Marketing) []string {
if marketing == nil || len(marketing.Products) == 0 { if marketing == nil || len(marketing.Products) == 0 {
return nil return nil
+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 {
@@ -387,16 +387,12 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan
return nil return nil
} }
upperCategory := strings.ToUpper(category) week := ((day - 1) / 7) + 1
weekBase := 1
if upperCategory == string(utils.ProjectFlockCategoryLaying) {
weekBase = config.LayingWeekStart()
}
week := ((day - 1) / 7) + weekBase
if week <= 0 { 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) detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
if err != nil { if err != nil {
@@ -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
@@ -46,6 +46,7 @@ type RecordingService interface {
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error)
} }
type recordingService struct { type recordingService struct {
@@ -198,10 +199,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 +260,10 @@ func (s recordingService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Qu
return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict) return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
} }
func (s recordingService) GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) {
return s.Repository.GetEggsWithFlagsByRecordingIDs(ctx, recordingIDs)
}
func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) {
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
return nil, err return nil, err
@@ -392,7 +409,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 {
@@ -1292,17 +1316,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
@@ -3969,6 +4005,27 @@ func (s *recordingService) reflowRollbackRecordingInventory(ctx context.Context,
return nil return nil
} }
func (s *recordingService) ensureLayingTransferExecutedForKandang(ctx context.Context, pfkID uint) error {
var count int64
err := s.Repository.DB().WithContext(ctx).
Table("laying_transfer_targets ltt").
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Where("ltt.target_project_flock_kandang_id = ?", pfkID).
Where("ltt.deleted_at IS NULL").
Where("lt.deleted_at IS NULL").
Where("lt.executed_at IS NOT NULL").
Where("ltt.total_qty > 0").
Count(&count).Error
if err != nil {
s.Log.Errorf("Failed to check executed laying transfer for pfk_id=%d: %+v", pfkID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa transfer laying")
}
if count == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Kandang laying belum memiliki transfer laying yang telah dieksekusi sehingga belum dapat membuat recording")
}
return nil
}
func (s *recordingService) requireFIFO() error { func (s *recordingService) requireFIFO() error {
if s.FifoStockV2Svc == nil { if s.FifoStockV2Svc == nil {
s.Log.Errorf("FIFO v2 service is not available for recording operations") s.Log.Errorf("FIFO v2 service is not available for recording operations")
@@ -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
@@ -91,11 +91,9 @@ func buildPurchaseQuery(c *fiber.Ctx) *validation.Query {
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search")), Search: strings.TrimSpace(c.Query("search")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")), ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
PoDate: strings.TrimSpace(c.Query("po_date")), StartDate: strings.TrimSpace(c.Query("start_date")),
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")), EndDate: strings.TrimSpace(c.Query("end_date")),
PoDateTo: strings.TrimSpace(c.Query("po_date_to")), FilterBy: strings.TrimSpace(c.Query("filter_by")),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)), SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)), AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)), LocationID: uint(c.QueryInt("location_id", 0)),
@@ -2,7 +2,6 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -43,15 +42,13 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
} }
} }
grandTotals := buildPurchaseGrandTotalMap(purchases)
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil { if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
return nil, err return nil, err
} }
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil { if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
return nil, err return nil, err
} }
if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases, grandTotals); err != nil { if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases); err != nil {
return nil, err return nil, err
} }
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{ if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
@@ -80,9 +77,17 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
"F": 22, "F": 22,
"G": 22, "G": 22,
"H": 32, "H": 32,
"I": 18, "I": 10,
"J": 18, "J": 12,
"K": 24, "K": 16,
"L": 16,
"M": 22,
"N": 12,
"O": 16,
"P": 16,
"Q": 18,
"R": 18,
"S": 24,
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -99,17 +104,25 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
func setPurchaseExportHeaders(file *excelize.File, sheet string) error { func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{ headers := []string{
"PR Number", "PR Number", // A
"PO Number", "PO Number", // B
"Tanggal PO", "Tanggal PO", // C
"Tanggal Terima", "Tanggal Terima", // D
"Supplier", "Supplier", // E
"Lokasi", "Lokasi", // F
"Gudang", "Gudang", // G
"Product", "Product", // H
"Status", "Qty", // I
"Grand Total", "Satuan", // J
"Notes", "Price", // K
"Total Produk", // L
"Vendor Ekspedisi",// M
"Qty Ekspedisi", // N
"Price Ekspedisi", // O
"Total Ekspedisi", // P
"Grand Total All", // Q
"Status", // R
"Notes", // S
} }
for i, header := range headers { for i, header := range headers {
@@ -137,34 +150,36 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "K1", headerStyle) return file.SetCellStyle(sheet, "A1", "S1", headerStyle)
} }
func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase, grandTotals map[uint]float64) error { func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase) error {
if len(purchases) == 0 { if len(purchases) == 0 {
return nil return nil
} }
var sumL, sumP, sumQ float64
rowIdx := 2 rowIdx := 2
for p := range purchases { for p := range purchases {
purchase := &purchases[p] purchase := &purchases[p]
total := grandTotals[purchase.Id]
if len(purchase.Items) == 0 { if len(purchase.Items) == 0 {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, total); err != nil { if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, &sumL, &sumP, &sumQ); err != nil {
return err return err
} }
rowIdx++ rowIdx++
continue continue
} }
for it := range purchase.Items { for it := range purchase.Items {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], total); err != nil { if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], &sumL, &sumP, &sumQ); err != nil {
return err return err
} }
rowIdx++ rowIdx++
} }
} }
lastRow := rowIdx - 1 lastDataRow := rowIdx - 1
dataStyle, err := file.NewStyle(&excelize.Style{ dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{ Alignment: &excelize.Alignment{
Horizontal: "left", Horizontal: "left",
@@ -181,7 +196,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A2", "K"+strconv.Itoa(lastRow), dataStyle); err != nil { if err := file.SetCellStyle(sheet, "A2", "S"+strconv.Itoa(lastDataRow), dataStyle); err != nil {
return err return err
} }
@@ -200,14 +215,17 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "K2", "Q"+strconv.Itoa(lastDataRow), moneyStyle); err != nil {
return err
}
return file.SetCellStyle(sheet, "J2", "J"+strconv.Itoa(lastRow), moneyStyle) return addPurchaseExportSumRow(file, sheet, rowIdx, sumL, sumP, sumQ)
} }
func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, grandTotal float64) error { func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, sumL, sumP, sumQ *float64) error {
row := strconv.Itoa(rowIdx) row := strconv.Itoa(rowIdx)
// Purchase-level columns (repeat across rows of the same purchase) // Purchase-level columns (repeat for every item row of the same purchase)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil { if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil {
return err return err
} }
@@ -220,26 +238,40 @@ func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purch
if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil { if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "I"+row, formatPurchaseExportEntityStatus(purchase)); err != nil { if err := file.SetCellValue(sheet, "R"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "J"+row, formatPurchaseRupiah(grandTotal)); err != nil { if err := file.SetCellValue(sheet, "S"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
return err return err
} }
// Item-level columns
if item == nil { if item == nil {
for _, col := range []string{"D", "F", "G", "H"} { for _, col := range []string{"D", "F", "G", "H", "J", "M"} {
if err := file.SetCellValue(sheet, col+row, "-"); err != nil { if err := file.SetCellValue(sheet, col+row, "-"); err != nil {
return err return err
} }
} }
for _, col := range []string{"I", "K", "L", "N", "O", "P", "Q"} {
if err := file.SetCellValue(sheet, col+row, 0); err != nil {
return err
}
}
return nil return nil
} }
// Item-level columns
var expeditionQty, expeditionPrice, expeditionTotal float64
if item.ExpenseNonstock != nil {
expeditionQty = item.ExpenseNonstock.Qty
expeditionPrice = item.ExpenseNonstock.Price
expeditionTotal = expeditionQty * expeditionPrice
}
itemGrandTotal := item.TotalPrice + expeditionTotal
*sumL += item.TotalPrice
*sumP += expeditionTotal
*sumQ += itemGrandTotal
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil { if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
return err return err
} }
@@ -252,20 +284,96 @@ func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purch
if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil { if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "I"+row, item.TotalQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, safePurchaseItemUomName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+row, item.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, item.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+row, safePurchaseItemExpeditionVendorName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+row, expeditionQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+row, expeditionPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, expeditionTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+row, itemGrandTotal); err != nil {
return err
}
return nil return nil
} }
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 { func addPurchaseExportSumRow(file *excelize.File, sheet string, rowIdx int, sumL, sumP, sumQ float64) error {
result := make(map[uint]float64, len(items)) row := strconv.Itoa(rowIdx)
for i := range items {
total := 0.0 sumStyle, err := file.NewStyle(&excelize.Style{
for j := range items[i].Items { Font: &excelize.Font{Bold: true, Color: "1F2937"},
total += items[i].Items[j].TotalPrice Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
} Alignment: &excelize.Alignment{
result[items[i].Id] = total Horizontal: "left",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
} }
return result
sumMoneyStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+row, "S"+row, sumStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "L"+row, "L"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "P"+row, "Q"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+row, "TOTAL"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, sumL); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, sumP); err != nil {
return err
}
return file.SetCellValue(sheet, "Q"+row, sumQ)
} }
func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string { func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string {
@@ -296,6 +404,24 @@ func safePurchaseItemProductName(item *entity.PurchaseItem) string {
return safePurchaseExportText(item.Product.Name) return safePurchaseExportText(item.Product.Name)
} }
func safePurchaseItemUomName(item *entity.PurchaseItem) string {
if item.Product == nil || item.Product.Uom.Id == 0 {
return "-"
}
return safePurchaseExportText(item.Product.Uom.Name)
}
func safePurchaseItemExpeditionVendorName(item *entity.PurchaseItem) string {
if item.ExpenseNonstock == nil || item.ExpenseNonstock.Expense == nil {
return "-"
}
exp := item.ExpenseNonstock.Expense
if exp.Supplier == nil || exp.Supplier.Id == 0 {
return "-"
}
return safePurchaseExportText(exp.Supplier.Name)
}
func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string { func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string {
if purchase.LatestApproval == nil { if purchase.LatestApproval == nil {
return "-" return "-"
@@ -309,6 +435,21 @@ func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string {
return safePurchaseExportText(purchase.LatestApproval.StepName) return safePurchaseExportText(purchase.LatestApproval.StepName)
} }
var purchaseIndonesianMonths = map[time.Month]string{
time.January: "Jan",
time.February: "Feb",
time.March: "Mar",
time.April: "Apr",
time.May: "Mei",
time.June: "Jun",
time.July: "Jul",
time.August: "Ags",
time.September: "Sep",
time.October: "Okt",
time.November: "Nov",
time.December: "Des",
}
func formatPurchaseExportDate(value *time.Time) string { func formatPurchaseExportDate(value *time.Time) string {
if value == nil || value.IsZero() { if value == nil || value.IsZero() {
return "-" return "-"
@@ -320,7 +461,8 @@ func formatPurchaseExportDate(value *time.Time) string {
t = t.In(location) t = t.In(location)
} }
return t.Format("02-01-2006") month := purchaseIndonesianMonths[t.Month()]
return fmt.Sprintf("%d-%s-%02d", t.Day(), month, t.Year()%100)
} }
func safePurchaseExportPointerText(value *string) string { func safePurchaseExportPointerText(value *string) string {
@@ -338,37 +480,3 @@ func safePurchaseExportText(value string) string {
return trimmed return trimmed
} }
func formatPurchaseRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
@@ -22,9 +22,8 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
nil, nil,
"catatan", "catatan",
[]entity.PurchaseItem{ []entity.PurchaseItem{
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"), buildPurchaseItemForExportTest(11, "Pakan Starter", 500, 2, 1000000, "Location A", "kg"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"), buildPurchaseItemForExportTest(12, "Vitamin A", 350, 1, 350000, "Location B", "botol"),
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
}, },
), ),
buildPurchaseForExportTest( buildPurchaseForExportTest(
@@ -37,7 +36,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
ptrApprovalAction(entity.ApprovalActionRejected), ptrApprovalAction(entity.ApprovalActionRejected),
"", "",
[]entity.PurchaseItem{ []entity.PurchaseItem{
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""), buildPurchaseItemForExportTest(21, "Obat X", 75000, 1, 75000, "", ""),
}, },
), ),
}) })
@@ -51,16 +50,27 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
} }
defer file.Close() defer file.Close()
// Verify all 19 headers
expectedHeaders := map[string]string{ expectedHeaders := map[string]string{
"A1": "PR Number", "A1": "PR Number",
"B1": "PO Number", "B1": "PO Number",
"C1": "Tanggal PO", "C1": "Tanggal PO",
"D1": "Supplier", "D1": "Tanggal Terima",
"E1": "Lokasi", "E1": "Supplier",
"F1": "Status", "F1": "Lokasi",
"G1": "Grand Total", "G1": "Gudang",
"H1": "Products", "H1": "Product",
"I1": "Notes", "I1": "Qty",
"J1": "Satuan",
"K1": "Price",
"L1": "Total Produk",
"M1": "Vendor Ekspedisi",
"N1": "Qty Ekspedisi",
"O1": "Price Ekspedisi",
"P1": "Total Ekspedisi",
"Q1": "Grand Total All",
"R1": "Status",
"S1": "Notes",
} }
for cell, expected := range expectedHeaders { for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(purchaseExportSheetName, cell) got, err := file.GetCellValue(purchaseExportSheetName, cell)
@@ -72,24 +82,46 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
} }
} }
// Row 2: Purchase 1, Item 1 (Pakan Starter)
assertPurchaseCellEquals(t, file, "A2", "PR-00011") assertPurchaseCellEquals(t, file, "A2", "PR-00011")
assertPurchaseCellEquals(t, file, "B2", "PO-00011") assertPurchaseCellEquals(t, file, "B2", "PO-00011")
assertPurchaseCellEquals(t, file, "C2", "22-04-2026") assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
assertPurchaseCellEquals(t, file, "D2", "Supplier A") assertPurchaseCellEquals(t, file, "E2", "Supplier A")
assertPurchaseCellEquals(t, file, "E2", "Location A") assertPurchaseCellEquals(t, file, "F2", "Location A")
assertPurchaseCellEquals(t, file, "F2", "Manager Purchase") assertPurchaseCellEquals(t, file, "H2", "Pakan Starter")
assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000") assertPurchaseCellEquals(t, file, "J2", "kg")
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A") assertPurchaseCellEquals(t, file, "K2", "500")
assertPurchaseCellEquals(t, file, "I2", "catatan") assertPurchaseCellEquals(t, file, "L2", "1000000")
assertPurchaseCellEquals(t, file, "M2", "-")
assertPurchaseCellEquals(t, file, "P2", "0")
assertPurchaseCellEquals(t, file, "Q2", "1000000")
assertPurchaseCellEquals(t, file, "R2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "S2", "catatan")
assertPurchaseCellEquals(t, file, "A3", "PR-00012") // Row 3: Purchase 1, Item 2 (Vitamin A)
assertPurchaseCellEquals(t, file, "B3", "-") assertPurchaseCellEquals(t, file, "A3", "PR-00011")
assertPurchaseCellEquals(t, file, "C3", "-") assertPurchaseCellEquals(t, file, "H3", "Vitamin A")
assertPurchaseCellEquals(t, file, "E3", "-") assertPurchaseCellEquals(t, file, "J3", "botol")
assertPurchaseCellEquals(t, file, "F3", "Ditolak") assertPurchaseCellEquals(t, file, "L3", "350000")
assertPurchaseCellEquals(t, file, "G3", "Rp 75.000") assertPurchaseCellEquals(t, file, "Q3", "350000")
assertPurchaseCellEquals(t, file, "H3", "Obat X")
assertPurchaseCellEquals(t, file, "I3", "-") // Row 4: Purchase 2, Item 1 (Obat X) — no location, rejected
assertPurchaseCellEquals(t, file, "A4", "PR-00012")
assertPurchaseCellEquals(t, file, "B4", "-")
assertPurchaseCellEquals(t, file, "C4", "-")
assertPurchaseCellEquals(t, file, "F4", "-")
assertPurchaseCellEquals(t, file, "H4", "Obat X")
assertPurchaseCellEquals(t, file, "J4", "-")
assertPurchaseCellEquals(t, file, "L4", "75000")
assertPurchaseCellEquals(t, file, "Q4", "75000")
assertPurchaseCellEquals(t, file, "R4", "Ditolak")
assertPurchaseCellEquals(t, file, "S4", "-")
// Row 5: SUM row — total produk=1425000, ekspedisi=0, grand total all=1425000
assertPurchaseCellEquals(t, file, "A5", "TOTAL")
assertPurchaseCellEquals(t, file, "L5", "1425000")
assertPurchaseCellEquals(t, file, "P5", "0")
assertPurchaseCellEquals(t, file, "Q5", "1425000")
} }
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) { func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -144,13 +176,20 @@ func buildPurchaseForExportTest(
} }
} }
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem { func buildPurchaseItemForExportTest(productID uint, productName string, price, totalQty, totalPrice float64, locationName, uomName string) entity.PurchaseItem {
uomID := uint(0)
if uomName != "" {
uomID = productID + 2000
}
item := entity.PurchaseItem{ item := entity.PurchaseItem{
ProductId: productID, ProductId: productID,
Price: price,
TotalQty: totalQty,
TotalPrice: totalPrice, TotalPrice: totalPrice,
Product: &entity.Product{ Product: &entity.Product{
Id: productID, Id: productID,
Name: productName, Name: productName,
Uom: entity.Uom{Id: uomID, Name: uomName},
}, },
} }

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