Compare commits

..

85 Commits

Author SHA1 Message Date
giovanni f64839dfe1 add delete snapshoot if change chickin date 2026-06-04 23:46:25 +07:00
giovanni 0ff720453f add api for edit chickin date 2026-06-03 11:56:32 +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 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
Giovanni Gabriel Septriadi 621d0d2bfd Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!541
2026-05-19 06:48:41 +00:00
Giovanni Gabriel Septriadi 32c34be2c6 Merge branch 'fix/30' into 'development'
[FIX][BE]: fix status marketing when edit sales order and edit marketing delivery

See merge request mbugroup/lti-api!540
2026-05-19 05:16:40 +00:00
giovanni d2aa3ebac7 fix status marketing when edit sales order and edit marketing delivery 2026-05-19 12:15:53 +07:00
Giovanni Gabriel Septriadi 02b86be4c5 Merge branch 'feat/edit-dc' into 'development'
[FIX][BE]: fix detail daily checklist empty kandang; add sorting to report biaya operasional

See merge request mbugroup/lti-api!539
2026-05-19 01:47:53 +00:00
giovanni 99e185a16a ad sorting to laporan biaya operasional 2026-05-19 00:15:08 +07:00
giovanni 995d585f54 fix get detail kandang kosong 2026-05-18 22:45:30 +07:00
Giovanni Gabriel Septriadi d05be1aef4 Merge branch 'feat/edit-dc' into 'development'
[FEAT][BE]: fix get search keuangan; add bank name to supplier and customer

See merge request mbugroup/lti-api!538
2026-05-17 13:50:15 +00:00
giovanni 872a71efda fix get search keuangan; add bank name to supplier and customer 2026-05-17 20:48:13 +07:00
Giovanni Gabriel Septriadi 6a2d6eec92 Merge branch 'feat/edit-dc' into 'development'
[FEAT][BE]: daily checklist can edit empty kandanG

See merge request mbugroup/lti-api!537
2026-05-17 12:12:27 +00:00
giovanni 18f9da1eaf daily checklist can edit empty kandang kosong 2026-05-17 19:11:13 +07:00
Giovanni Gabriel Septriadi 45bbe2ab1b Merge branch 'fix/sorting' into 'development'
[FIX][BE]: add sorting transaction, report keuangan

See merge request mbugroup/lti-api!536
2026-05-16 16:41:46 +00:00
giovanni 18bd8ad1d9 add sorting transaction, report keuangan 2026-05-16 23:40:52 +07:00
Giovanni Gabriel Septriadi a40adc22d2 Merge branch 'feat/sort-po-ex' into 'development'
[FIX][BE]: adjust sorting pembelian dan expenses

See merge request mbugroup/lti-api!535
2026-05-13 08:35:17 +00:00
giovanni 04626560eb adjust sorting pembelian dan expenses 2026-05-13 15:34:24 +07:00
Giovanni Gabriel Septriadi 945683bdf5 Merge branch 'feat/sort-po-ex' into 'development'
[FEAT][BE]: add sorting server side po and expense

See merge request mbugroup/lti-api!534
2026-05-13 06:31:27 +00:00
giovanni 490c7fc9fd add sorting server side po and expense 2026-05-13 13:28:37 +07:00
Giovanni Gabriel Septriadi 4f03b631ef Merge branch 'fix/pay' into 'development'
[FIX][BE]: adjust calculate total price  marketing ayam pullet

See merge request mbugroup/lti-api!533
2026-05-13 02:41:24 +00:00
giovanni eac671fa80 adjust calculate total price marketing ayam pullet 2026-05-12 19:55:06 +07:00
117 changed files with 8647 additions and 1005 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)
}
@@ -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) (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,74 @@ 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,
projectFlockID uint,
) (map[uint]float64, error) {
type row struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
TotalQty float64 `gorm:"column:total_qty"`
}
var rows []row
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("pc.project_flock_kandang_id, SUM(pc.usage_qty) AS total_qty").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pc.deleted_at IS NULL").
Where("pfk.project_flock_id = ?", projectFlockID).
Group("pc.project_flock_kandang_id").
Scan(&rows).Error
if err != nil {
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, ctx context.Context,
houseTypes []string, houseTypes []string,
maxDay int, maxDay int,
) (map[string]map[int]float64, error) { ) (map[string]map[int]float64, map[string]*time.Time, error) {
result := make(map[string]map[int]float64) result := make(map[string]map[int]float64)
effectiveDates := make(map[string]*time.Time)
if len(houseTypes) == 0 || maxDay <= 0 { if len(houseTypes) == 0 || maxDay <= 0 {
return result, nil return result, effectiveDates, nil
} }
type row struct { type row struct {
HouseType string HouseType string
Day int Day int
DepreciationPercent float64 MultiplicationPercentage float64
EffectiveDate *time.Time
} }
rows := make([]row, 0) rows := make([]row, 0)
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).Raw(`
Table("house_depreciation_standards"). SELECT DISTINCT ON (house_type::text, day)
Select("house_type::text AS house_type, day, depreciation_percent"). house_type::text AS house_type, day, multiplication_percentage, effective_date
Where("house_type::text IN ?", houseTypes). FROM house_depreciation_standards
Where("day <= ?", maxDay). WHERE house_type::text IN ? AND day <= ?
Order("house_type ASC, day ASC"). ORDER BY house_type, day, effective_date DESC NULLS LAST
Scan(&rows).Error `, houseTypes, maxDay).Scan(&rows).Error
if err != nil { if err != nil {
return nil, err 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
}
+111 -25
View File
@@ -1191,27 +1191,73 @@ func (s *hppV2Service) getDepreciationComponent(
}, nil }, nil
} }
// Multi-source support: 1 target kandang bisa menerima dari MULTIPLE transfer terpisah
// (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).
transferInputs, err := s.hppRepo.GetAllTransferInputsByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate)
if err != nil {
return nil, err
}
// Filter valid transfers (punya source flock id)
validTransfers := make([]commonRepo.HppV2LatestTransferInputRow, 0, len(transferInputs))
totalTransferQty := 0.0
for _, t := range transferInputs {
if t.SourceProjectFlockID == 0 {
continue
}
validTransfers = append(validTransfers, t)
totalTransferQty += t.TransferQty
}
if len(validTransfers) > 0 {
if totalPulletCost <= 0 { if totalPulletCost <= 0 {
return nil, nil return nil, nil
} }
transferInput, err := s.hppRepo.GetLatestTransferInputByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate) totalDepreciation := 0.0
if err != nil { parts := make([]HppV2ComponentPart, 0, len(validTransfers))
return nil, err 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)
} }
var part *HppV2ComponentPart part, partErr := s.buildNormalTransferDepreciationPart(contextRow, &t, periodDate, transferCostBasis)
if transferInput != nil && transferInput.SourceProjectFlockID > 0 { if partErr != nil {
part, err = s.buildNormalTransferDepreciationPart(contextRow, transferInput, periodDate, totalPulletCost) 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 { if err != nil {
return nil, err return nil, err
} }
} else {
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)
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,
@@ -1367,11 +1420,15 @@ func (s *hppV2Service) buildNormalTransferDepreciationPart(
Details: map[string]any{ Details: map[string]any{
"basis_total": totalPulletCost, "basis_total": totalPulletCost,
"pullet_cost_day_n": pulletCostDayN, "pullet_cost_day_n": pulletCostDayN,
"multiplication_percentage": multiplicationPercentage,
"total_value_pullet_after_depreciation": totalValueAfter,
"depreciation_percent": depreciationPercent, "depreciation_percent": depreciationPercent,
"schedule_day": scheduleDay, "schedule_day": scheduleDay,
"origin_date": formatDateOnly(*originDate), "origin_date": formatDateOnly(*originDate),
"transfer_date": formatDateOnly(transferInput.TransferDate), "transfer_date": formatDateOnly(transferInput.TransferDate),
"source_project_flock_id": transferInput.SourceProjectFlockID, "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
@@ -1427,21 +1499,29 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart(
} }
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)
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,8 +1529,12 @@ 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,
"manual_input_total": manualInput.TotalCost,
"population_share": populationShare,
"pullet_cost_day_n": pulletCostDayN, "pullet_cost_day_n": pulletCostDayN,
"multiplication_percentage": multiplicationPercentage,
"total_value_pullet_after_depreciation": totalValueAfter,
"depreciation_percent": depreciationPercent, "depreciation_percent": depreciationPercent,
"schedule_day": reportScheduleDay, "schedule_day": reportScheduleDay,
"start_schedule_day": startDay, "start_schedule_day": startDay,
@@ -1458,6 +1542,8 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart(
"cutover_date": formatDateOnly(manualInput.CutoverDate), "cutover_date": formatDateOnly(manualInput.CutoverDate),
"manual_input_id": manualInput.ID, "manual_input_id": manualInput.ID,
"project_flock_kandang": projectFlockKandangId, "project_flock_kandang": projectFlockKandangId,
"standard_effective_date": standardEffectiveDate,
"kandang_population": kandangPopulation,
}, },
References: []HppV2Reference{ References: []HppV2Reference{
{ {
@@ -1465,7 +1551,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 +1810,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) (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
} }
@@ -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,5 @@
BEGIN;
DROP TABLE IF EXISTS daily_checklist_empty_kandangs;
COMMIT;
@@ -0,0 +1,60 @@
BEGIN;
CREATE TABLE daily_checklist_empty_kandangs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
daily_checklist_id bigint NOT NULL,
kandang_id bigint NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
created_by bigint,
deleted_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT fk_dcek_daily_checklist
FOREIGN KEY (daily_checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_dcek_deleted_by
FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT ck_dcek_range CHECK (end_date >= start_date)
);
CREATE INDEX idx_dcek_kandang_range
ON daily_checklist_empty_kandangs (kandang_id, start_date, end_date)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_dcek_daily_checklist_unique
ON daily_checklist_empty_kandangs (daily_checklist_id)
WHERE deleted_at IS NULL;
INSERT INTO daily_checklist_empty_kandangs (
daily_checklist_id, kandang_id, start_date, end_date, created_by, created_at, updated_at
)
SELECT
dc.id,
dc.kandang_id,
dc.date AS start_date,
COALESCE(
(SELECT (next_dc.date - INTERVAL '1 day')::date
FROM daily_checklists next_dc
WHERE next_dc.kandang_id = dc.kandang_id
AND next_dc.date > dc.date
AND next_dc.category <> 'empty_kandang'
AND (next_dc.status IS NULL OR next_dc.status <> 'REJECTED')
AND next_dc.deleted_at IS NULL
ORDER BY next_dc.date ASC
LIMIT 1),
dc.date
) AS end_date,
dc.created_by,
dc.created_at,
dc.updated_at
FROM daily_checklists dc
WHERE dc.category = 'empty_kandang'
AND dc.deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,2 @@
ALTER TABLE customers DROP COLUMN bank_name;
ALTER TABLE suppliers DROP COLUMN bank_name;
@@ -0,0 +1,2 @@
ALTER TABLE customers ADD COLUMN bank_name VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE suppliers ADD COLUMN bank_name VARCHAR(100);
@@ -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;
+1
View File
@@ -15,6 +15,7 @@ type Customer struct {
Phone string `gorm:"not null;size:20"` Phone string `gorm:"not null;size:20"`
Email string `gorm:"type:varchar(50);not null"` Email string `gorm:"type:varchar(50);not null"`
AccountNumber string `gorm:"not null;size:50"` AccountNumber string `gorm:"not null;size:50"`
BankName string `gorm:"not null;size:100;default:''"`
Balance float64 `gorm:"default:0"` Balance float64 `gorm:"default:0"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
@@ -0,0 +1,27 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandang struct {
Id uint `gorm:"primaryKey"`
DailyChecklistId uint `gorm:"not null"`
KandangId uint `gorm:"not null"`
StartDate time.Time `gorm:"type:date;not null"`
EndDate time.Time `gorm:"type:date;not null"`
CreatedBy *uint
DeletedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
DailyChecklist *DailyChecklist `gorm:"foreignKey:DailyChecklistId;references:Id"`
Kandang *KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
}
func (DailyChecklistEmptyKandang) TableName() string {
return "daily_checklist_empty_kandangs"
}
+1
View File
@@ -28,6 +28,7 @@ type DailyChecklist struct {
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"` Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
EmptyKandang *DailyChecklistEmptyKandang `gorm:"foreignKey:DailyChecklistId;references:Id"`
} }
type DailyChecklistPhase struct { type DailyChecklistPhase struct {
+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"`
+1
View File
@@ -19,6 +19,7 @@ type Supplier struct {
Address string `gorm:"not null"` Address string `gorm:"not null"`
Npwp *string `gorm:"size:50"` Npwp *string `gorm:"size:50"`
AccountNumber *string `gorm:"size:50"` AccountNumber *string `gorm:"size:50"`
BankName *string `gorm:"size:100"`
Balance float64 `gorm:"type:numeric(15,3);default:0"` Balance float64 `gorm:"type:numeric(15,3);default:0"`
DueDate int `gorm:"not null"` DueDate int `gorm:"not null"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
@@ -42,6 +42,13 @@ type DailyChecklistDetailDTO struct {
TotalActivity int `json:"total_activity"` TotalActivity int `json:"total_activity"`
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"` DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"`
EmptyKandang *DailyChecklistEmptyKandangDTO `json:"empty_kandang,omitempty"`
}
type DailyChecklistEmptyKandangDTO struct {
Id uint `json:"id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
} }
type DailyChecklistDocumentDTO struct { type DailyChecklistDocumentDTO struct {
@@ -180,6 +187,17 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
} }
} }
func ToDailyChecklistEmptyKandangDTO(e *entity.DailyChecklistEmptyKandang) *DailyChecklistEmptyKandangDTO {
if e == nil || e.Id == 0 {
return nil
}
return &DailyChecklistEmptyKandangDTO{
Id: e.Id,
StartDate: e.StartDate,
EndDate: e.EndDate,
}
}
func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO { func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO {
phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases)) phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases))
for _, phase := range phases { for _, phase := range phases {
@@ -241,5 +259,6 @@ func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.
TotalActivity: totalActivities, TotalActivity: totalActivities,
Progress: progress, Progress: progress,
DocumentURLs: documentURLs, DocumentURLs: documentURLs,
EmptyKandang: ToDailyChecklistEmptyKandangDTO(checklist.EmptyKandang),
} }
} }
+2 -1
View File
@@ -22,6 +22,7 @@ type DailyChecklistModule struct{}
func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db) dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db)
emptyKandangRepo := rDailyChecklist.NewDailyChecklistEmptyKandangRepository(db)
phasesRepo := rPhases.NewPhasesRepository(db) phasesRepo := rPhases.NewPhasesRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db) documentRepo := commonRepo.NewDocumentRepository(db)
@@ -30,7 +31,7 @@ func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
panic(fmt.Sprintf("failed to create document service: %v", err)) panic(fmt.Sprintf("failed to create document service: %v", err))
} }
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate, documentSvc) dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, emptyKandangRepo, phasesRepo, validate, documentSvc)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
DailyChecklistRoutes(router, userService, dailyChecklistService) DailyChecklistRoutes(router, userService, dailyChecklistService)
@@ -0,0 +1,98 @@
package repository
import (
"context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandangRepository interface {
repository.BaseRepository[entity.DailyChecklistEmptyKandang]
FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error)
FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error)
SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error
}
type DailyChecklistEmptyKandangRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.DailyChecklistEmptyKandang]
}
func NewDailyChecklistEmptyKandangRepository(db *gorm.DB) DailyChecklistEmptyKandangRepository {
return &DailyChecklistEmptyKandangRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklistEmptyKandang](db),
}
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("daily_checklist_id = ?", dailyChecklistID).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
query := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
if excludeDailyChecklistID > 0 {
query = query.Where("daily_checklist_id <> ?", excludeDailyChecklistID)
}
if err := query.First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error) {
if len(kandangIDs) == 0 {
return []entity.DailyChecklistEmptyKandang{}, nil
}
var recs []entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?", kandangIDs, rangeEnd, rangeStart).
Find(&recs).Error; err != nil {
return nil, err
}
return recs, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID != nil {
updates["deleted_by"] = *actorID
}
return r.DB().WithContext(ctx).
Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", dailyChecklistID).
Updates(updates).Error
}
@@ -47,6 +47,7 @@ type dailyChecklistService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
Repository repository.DailyChecklistRepository Repository repository.DailyChecklistRepository
EmptyKandangRepo repository.DailyChecklistEmptyKandangRepository
PhaseRepo phaseRepo.PhasesRepository PhaseRepo phaseRepo.PhasesRepository
DocumentSvc commonSvc.DocumentService DocumentSvc commonSvc.DocumentService
} }
@@ -130,20 +131,24 @@ const (
dailyChecklistStatusDraft = "DRAFT" dailyChecklistStatusDraft = "DRAFT"
dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist" dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist"
dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date" dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date"
dailyChecklistErrEmptyKandangRangeOverlap = "Empty kandang range overlaps with an existing empty kandang period for this kandang"
dailyChecklistErrDateInsideEmptyKandang = "Tanggal berada dalam periode kandang kosong untuk kandang ini"
dailyChecklistErrEmptyKandangEndDateInvalid = "empty_kandang_end_date harus >= date"
) )
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { func NewDailyChecklistService(repo repository.DailyChecklistRepository, emptyKandangRepo repository.DailyChecklistEmptyKandangRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
return &dailyChecklistService{ return &dailyChecklistService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
EmptyKandangRepo: emptyKandangRepo,
PhaseRepo: phaseRepo, PhaseRepo: phaseRepo,
DocumentSvc: documentSvc, DocumentSvc: documentSvc,
} }
} }
func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB { func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("Kandang") return db.Preload("Kandang").Preload("EmptyKandang")
} }
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error { func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
@@ -529,6 +534,23 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
category = dailyChecklistCategoryEmptyKandang category = dailyChecklistCategoryEmptyKandang
} }
var emptyEndDate time.Time
if category == dailyChecklistCategoryEmptyKandang {
trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate)
if trimmedEnd == "" {
emptyEndDate = date
} else {
parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd)
if parseErr != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if parsedEnd.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid)
}
emptyEndDate = parsedEnd
}
}
targetID := uint(0) targetID := uint(0)
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
@@ -537,25 +559,39 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
} }
if category == dailyChecklistCategoryEmptyKandang { if category == dailyChecklistCategoryEmptyKandang {
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, date); err != nil { if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoExistingEmptyKandangInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err return err
} }
if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil { if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil {
return err return err
} }
} else { } else {
conflictID := uint(0) if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, 0); err != nil {
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date, category, status, &conflictID); err != nil {
return err return err
} }
if conflictID > 0 { if err := s.validateDateNotInExistingEmptyKandangChecklist(tx, req.KandangId, date, 0); err != nil {
targetID = conflictID return err
return nil
} }
} }
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID) if err := s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID); err != nil {
return err
}
if category == dailyChecklistCategoryEmptyKandang {
actorID, _ := m.ActorIDFromContext(c)
if err := s.upsertEmptyKandangRange(tx, targetID, req.KandangId, date, emptyEndDate, actorID); err != nil {
return err
}
}
return nil
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err) s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
@@ -585,41 +621,118 @@ func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kan
return nil return nil
} }
func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error { func (s *dailyChecklistService) validateNoNormalChecklistInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category <> ? AND deleted_at IS NULL",
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64 var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}). if err := q.Count(&conflictCount).Error; err != nil {
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", kandangID, startDate, endDate).
Count(&conflictCount).Error; err != nil {
return err return err
} }
if conflictCount > 0 { if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist) return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist)
} }
return nil return nil
} }
func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, newCategory, newStatus string, conflictID *uint) error { func (s *dailyChecklistService) validateNoEmptyKandangRangeOverlap(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
var existing entity.DailyChecklist q := tx.Model(&entity.DailyChecklistEmptyKandang{}).
if err := tx.Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang). if excludeDCID > 0 {
First(&existing).Error; err != nil { q = q.Where("daily_checklist_id <> ?", excludeDCID)
}
var overlapCount int64
if err := q.Count(&overlapCount).Error; err != nil {
return err
}
if overlapCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
return nil
}
func (s *dailyChecklistService) validateDateNotInEmptyKandangRange(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date)
if excludeDCID > 0 {
q = q.Where("daily_checklist_id <> ?", excludeDCID)
}
var rec entity.DailyChecklistEmptyKandang
if err := q.First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil return nil
} }
return err return err
} }
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
if err := tx.Model(&entity.DailyChecklist{}).Where("id = ?", existing.Id).Updates(map[string]interface{}{ func (s *dailyChecklistService) validateNoExistingEmptyKandangInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
"category": newCategory, q := tx.Model(&entity.DailyChecklist{}).
"status": newStatus, Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL",
}).Error; err != nil { kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
return nil
}
func (s *dailyChecklistService) validateDateNotInExistingEmptyKandangChecklist(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL",
kandangID, date, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
return nil
}
func (s *dailyChecklistService) upsertEmptyKandangRange(tx *gorm.DB, dailyChecklistID, kandangID uint, startDate, endDate time.Time, actorID uint) error {
var existing entity.DailyChecklistEmptyKandang
err := tx.Where("daily_checklist_id = ?", dailyChecklistID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
*conflictID = existing.Id if err == nil {
return nil return tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("id = ?", existing.Id).
Updates(map[string]any{
"kandang_id": kandangID,
"start_date": startDate,
"end_date": endDate,
"updated_at": time.Now(),
}).Error
}
record := &entity.DailyChecklistEmptyKandang{
DailyChecklistId: dailyChecklistID,
KandangId: kandangID,
StartDate: startDate,
EndDate: endDate,
}
if actorID > 0 {
actor := actorID
record.CreatedBy = &actor
}
return tx.Create(record).Error
} }
func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error { func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error {
@@ -900,11 +1013,53 @@ func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create
status := req.Status status := req.Status
var emptyEndDate time.Time
if category == dailyChecklistCategoryEmptyKandang {
trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate)
if trimmedEnd == "" {
emptyEndDate = date
} else {
parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd)
if parseErr != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if parsedEnd.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid)
}
emptyEndDate = parsedEnd
}
}
var wasBranchC bool // non-empty_kandang → empty_kandang transition
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil { if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil {
return err return err
} }
var existing entity.DailyChecklist
if err := tx.Where("id = ? AND deleted_at IS NULL", id).First(&existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return err
}
existingIsEmpty := existing.Category == dailyChecklistCategoryEmptyKandang
newIsEmpty := category == dailyChecklistCategoryEmptyKandang
if newIsEmpty {
if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, id); err != nil {
return err
}
if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, id); err != nil {
return err
}
} else {
if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, id); err != nil {
return err
}
}
var conflictCount int64 var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}). if err := tx.Model(&entity.DailyChecklist{}).
Where("id <> ? AND date = ? AND kandang_id = ? AND category = ? AND deleted_at IS NULL", Where("id <> ? AND date = ? AND kandang_id = ? AND category = ? AND deleted_at IS NULL",
@@ -928,12 +1083,63 @@ func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound return gorm.ErrRecordNotFound
} }
actorID, _ := m.ActorIDFromContext(c)
if newIsEmpty {
if err := s.upsertEmptyKandangRange(tx, id, req.KandangId, date, emptyEndDate, actorID); err != nil {
return err
}
// Branch C: non-empty → empty_kandang, hard-delete task/progress data
if !existingIsEmpty {
wasBranchC = true
if err := tx.Exec(`
DELETE FROM daily_checklist_activity_task_assignments
WHERE task_id IN (
SELECT id FROM daily_checklist_activity_tasks WHERE checklist_id = ?
)`, id).Error; err != nil {
return err
}
if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil {
return err
}
if err := tx.Where("daily_checklist_id = ?", id).Delete(&entity.DailyChecklistTask{}).Error; err != nil {
return err
}
}
} else if existingIsEmpty {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID > 0 {
updates["deleted_by"] = actorID
}
if err := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", id).
Updates(updates).Error; err != nil {
return err
}
}
return nil return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Branch C: delete DC documents outside transaction (storage is external)
if wasBranchC && s.DocumentSvc != nil {
docs, docErr := s.DocumentSvc.ListByTarget(c.Context(), string(utils.DocumentTypeDailyChecklist), uint64(id))
if docErr == nil && len(docs) > 0 {
docIDs := make([]uint, 0, len(docs))
for _, doc := range docs {
docIDs = append(docIDs, doc.Id)
}
if delErr := s.DocumentSvc.DeleteDocuments(c.Context(), docIDs, true); delErr != nil {
s.Log.Errorf("Failed to delete documents for DC %d during empty_kandang conversion: %+v", id, delErr)
}
}
}
return s.GetOne(c, id) return s.GetOne(c, id)
} }
@@ -968,6 +1174,15 @@ func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
return gorm.ErrRecordNotFound return gorm.ErrRecordNotFound
} }
if err := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", id).
Updates(map[string]any{
"deleted_at": time.Now(),
"deleted_by": actorID,
}).Error; err != nil {
return err
}
return nil return nil
}); err != nil { }); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1695,82 +1910,34 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
} }
firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC) firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC)
lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1) lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1)
today := time.Now().UTC().Truncate(24 * time.Hour)
type emptyKandangRec struct { type emptyRangeRec struct {
KandangID uint KandangID uint
Date time.Time StartDate time.Time
EndDate time.Time
} }
var emptyRecs []emptyKandangRec var rangeRecs []emptyRangeRec
if err := s.Repository.DB().WithContext(c.Context()). if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.DailyChecklist{}). Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id IN ? AND category = ? AND date <= ? AND deleted_at IS NULL", Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?",
kandangIDs, dailyChecklistCategoryEmptyKandang, lastDay). kandangIDs, lastDay, firstDay).
Select("kandang_id, date"). Select("kandang_id, start_date, end_date").
Scan(&emptyRecs).Error; err != nil { Scan(&rangeRecs).Error; err != nil {
s.Log.Errorf("Failed to get empty kandang records for report: %+v", err) s.Log.Errorf("Failed to get empty kandang ranges for report: %+v", err)
return err return err
} }
emptyDaysByKandang := make(map[uint]map[int]struct{}) emptyDaysByKandang := make(map[uint]map[int]struct{})
if len(emptyRecs) > 0 { for _, rec := range rangeRecs {
minEmptyDate := emptyRecs[0].Date effectiveStart := rec.StartDate
for _, rec := range emptyRecs[1:] {
if rec.Date.Before(minEmptyDate) {
minEmptyDate = rec.Date
}
}
type checklistDateRec struct {
KandangID uint
Date time.Time
}
var nextDates []checklistDateRec
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.DailyChecklist{}).
Where("kandang_id IN ? AND category != ? AND date > ? AND (status IS NULL OR status != ?) AND deleted_at IS NULL",
kandangIDs, dailyChecklistCategoryEmptyKandang, minEmptyDate, dailyChecklistStatusRejected).
Select("kandang_id, date").
Order("kandang_id ASC, date ASC").
Scan(&nextDates).Error; err != nil {
s.Log.Errorf("Failed to get next checklist dates for empty kandang: %+v", err)
return err
}
nextDatesByKandang := make(map[uint][]time.Time)
for _, row := range nextDates {
nextDatesByKandang[row.KandangID] = append(nextDatesByKandang[row.KandangID], row.Date)
}
for _, rec := range emptyRecs {
var nextDate time.Time
for _, d := range nextDatesByKandang[rec.KandangID] {
if d.After(rec.Date) {
nextDate = d
break
}
}
// If no next checklist, cap empty period at today (not end of month)
ceiling := lastDay
if today.Before(lastDay) {
ceiling = today
}
periodEnd := ceiling
if !nextDate.IsZero() {
periodEnd = nextDate.AddDate(0, 0, -1)
}
effectiveStart := rec.Date
if effectiveStart.Before(firstDay) { if effectiveStart.Before(firstDay) {
effectiveStart = firstDay effectiveStart = firstDay
} }
effectiveEnd := periodEnd effectiveEnd := rec.EndDate
if effectiveEnd.After(lastDay) { if effectiveEnd.After(lastDay) {
effectiveEnd = lastDay effectiveEnd = lastDay
} }
if effectiveStart.After(effectiveEnd) { if effectiveStart.After(effectiveEnd) {
continue continue
} }
@@ -1782,7 +1949,6 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{} emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{}
} }
} }
}
for i, item := range items { for i, item := range items {
daySet := emptyDaysByKandang[item.KandangID] daySet := emptyDaysByKandang[item.KandangID]
@@ -208,8 +208,18 @@ func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *
Count(&activeInRange).Error; err != nil { Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err) t.Fatalf("failed counting checklists in range: %v", err)
} }
if activeInRange != 5 { if activeInRange != 1 {
t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange) t.Fatalf("expected 1 active empty_kandang checklist created for range, got %d", activeInRange)
}
var emptyRangeCount int64
if err := db.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date = ? AND end_date = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&emptyRangeCount).Error; err != nil {
t.Fatalf("failed counting empty kandang ranges: %v", err)
}
if emptyRangeCount != 1 {
t.Fatalf("expected 1 empty kandang range record for [2026-01-01, 2026-01-05], got %d", emptyRangeCount)
} }
} }
@@ -304,6 +314,18 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
updated_at DATETIME NULL, updated_at DATETIME NULL,
deleted_at DATETIME NULL deleted_at DATETIME NULL
)`, )`,
`CREATE TABLE daily_checklist_empty_kandangs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
daily_checklist_id INTEGER NOT NULL,
kandang_id INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_by INTEGER NULL,
deleted_by INTEGER NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
@@ -316,7 +338,8 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
} }
repo := repository.NewDailyChecklistRepository(db) repo := repository.NewDailyChecklistRepository(db)
svc := NewDailyChecklistService(repo, nil, validator.New(), nil) emptyRepo := repository.NewDailyChecklistEmptyKandangRepository(db)
svc := NewDailyChecklistService(repo, emptyRepo, nil, validator.New(), nil)
return svc, db return svc, db
} }
@@ -10,6 +10,7 @@ type Create struct {
Category string `json:"category" validate:"required"` Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"` Status string `json:"status" validate:"required"`
EmptyKandang bool `json:"empty_kandang"` EmptyKandang bool `json:"empty_kandang"`
EmptyKandangEndDate string `json:"empty_kandang_end_date" validate:"omitempty"`
} }
type Update struct { type Update struct {
@@ -65,6 +65,8 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")), RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")),
ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)), ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)), ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
} }
if isAllExpenseExcelExportRequest(c) { if isAllExpenseExcelExportRequest(c) {
+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").
@@ -177,10 +178,48 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return nil, 0, err return nil, 0, err
} }
sortExpr := "expense_realizations.created_at"
order := "DESC"
if filters.SortOrder == "asc" {
order = "ASC"
}
switch filters.SortBy {
case "po_number":
sortExpr = "expenses.po_number"
case "reference_number":
sortExpr = "expenses.reference_number"
case "realization_date":
sortExpr = "expenses.realization_date"
case "transaction_date":
sortExpr = "expenses.transaction_date"
case "category":
sortExpr = "expenses.category"
case "product":
sortExpr = "(SELECT name FROM nonstocks WHERE id = expense_nonstocks.nonstock_id)"
case "supplier":
sortExpr = "suppliers.name"
case "location":
sortExpr = "(SELECT l.name FROM kandangs k JOIN locations l ON l.id = k.location_id WHERE k.id = expense_nonstocks.kandang_id)"
case "kandang":
sortExpr = "(SELECT name FROM kandangs WHERE id = expense_nonstocks.kandang_id)"
case "qty_pengajuan":
sortExpr = "expense_nonstocks.qty"
case "price_pengajuan":
sortExpr = "expense_nonstocks.price"
case "total_pengajuan":
sortExpr = "expense_nonstocks.qty * expense_nonstocks.price"
case "qty_realisasi":
sortExpr = "expense_realizations.qty"
case "price_realisasi":
sortExpr = "expense_realizations.price"
case "total_realisasi":
sortExpr = "expense_realizations.qty * expense_realizations.price"
}
if err := db. if err := db.
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).
Order("expense_realizations.created_at DESC"). Order(sortExpr + " " + order).
Find(&realizations).Error; err != nil { Find(&realizations).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -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)
}
} }
} }
@@ -289,7 +307,40 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
like, like,
) )
} }
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "reference_number":
return db.Order("expenses.reference_number " + sortOrder)
case "transaction_date":
return db.Order("expenses.transaction_date " + sortOrder)
case "realization_date":
return db.Order("expenses.realization_date " + sortOrder)
case "location":
return db.Order("(SELECT COALESCE(name,'') FROM locations WHERE id = expenses.location_id) " + sortOrder)
case "created_user":
return db.Order("(SELECT COALESCE(name,'') FROM users WHERE id = expenses.created_by) " + sortOrder)
case "supplier":
return db.Order("(SELECT COALESCE(name,'') FROM suppliers WHERE id = expenses.supplier_id) " + sortOrder)
case "grand_total":
return db.Order(`(SELECT 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 = expenses.id),
(SELECT SUM(en2.qty * en2.price) FROM expense_nonstocks en2
WHERE en2.expense_id = expenses.id),
0)) ` + sortOrder)
case "is_paid":
return db.Order("expenses.is_paid " + sortOrder)
case "created_at":
return db.Order("expenses.created_at " + sortOrder)
default:
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC") return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
}
}) })
if scopeErr != nil { if scopeErr != nil {
@@ -1045,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
} }
@@ -1489,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
} }
@@ -54,6 +54,8 @@ type Query struct {
RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"` RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"`
ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=reference_number transaction_date realization_date location created_user supplier grand_total is_paid created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
} }
type CreateRealization struct { type CreateRealization struct {
+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
} }
@@ -97,6 +100,8 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
CustomerIDs: customerIDs, CustomerIDs: customerIDs,
SupplierIDs: supplierIDs, SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""), SortDate: c.Query("sort_date", ""),
SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""),
StartDate: c.Query("start_date", ""), StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""), EndDate: c.Query("end_date", ""),
} }
@@ -105,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
@@ -147,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,12 +30,14 @@ 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{
@@ -43,6 +45,7 @@ func NewTransactionService(
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,
@@ -72,19 +75,26 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if params.Search != "" { needsPartyJoin := params.Search != "" || params.SortBy == "customer_name"
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" needsBankJoin := params.Search != "" || params.SortBy == "bank"
if needsPartyJoin {
db = db.Joins( db = db.Joins(
"LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL", "LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL",
string(utils.PaymentPartyCustomer), string(utils.PaymentPartyCustomer),
).Joins( ).Joins(
"LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL", "LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL",
string(utils.PaymentPartySupplier), string(utils.PaymentPartySupplier),
).Joins(
"LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL",
) )
}
if needsBankJoin {
db = db.Joins("LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL")
}
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Where( db = db.Where(
`LOWER(payment_code) LIKE ? OR `(LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR
@@ -93,7 +103,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
LOWER(COALESCE(banks.name, '')) LIKE ? OR LOWER(COALESCE(banks.name, '')) LIKE ? OR
CAST(payments.nominal AS TEXT) LIKE ? OR CAST(payments.nominal AS TEXT) LIKE ? OR
TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?`, TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?)`,
like, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like,
) )
} }
@@ -138,7 +148,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
db = db.Where("payment_date < ?", *endDate) db = db.Where("payment_date < ?", *endDate)
} }
return applyTransactionSort(db, params.SortDate) return applyTransactionSort(db, params.SortBy, params.SortOrder, params.SortDate)
}) })
if err != nil { if err != nil {
@@ -175,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")
@@ -182,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
} }
@@ -270,13 +301,39 @@ func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Tim
return startPtr, endPtr, nil return startPtr, endPtr, nil
} }
func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB { func applyTransactionSort(db *gorm.DB, sortBy, sortOrder, sortDate string) *gorm.DB {
order := "DESC"
if strings.ToUpper(strings.TrimSpace(sortOrder)) == "ASC" {
order = "ASC"
}
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "payment_code":
return db.Order("payments.payment_code " + order)
case "reference_number":
return db.Order("payments.reference_number " + order)
case "transaction_type":
return db.Order("payments.transaction_type " + order)
case "customer_name":
return db.Order("COALESCE(customers.name, suppliers.name) " + order)
case "payment_date":
return db.Order("payments.payment_date " + order)
case "created_at":
return db.Order("payments.created_at " + order)
case "payment_method":
return db.Order("payments.payment_method " + order)
case "bank":
return db.Order("banks.account_number " + order)
case "expense_amount":
return db.Order("CASE WHEN payments.direction = 'OUT' THEN payments.nominal ELSE 0 END " + order)
case "income_amount":
return db.Order("CASE WHEN payments.direction = 'IN' THEN payments.nominal ELSE 0 END " + order)
}
switch strings.ToLower(strings.TrimSpace(sortDate)) { switch strings.ToLower(strings.TrimSpace(sortDate)) {
case "created_at": case "created_at":
return db.Order("created_at DESC").Order("payment_date DESC") return db.Order("payments.created_at DESC").Order("payments.payment_date DESC")
case "payment_date":
return db.Order("payment_date DESC").Order("created_at DESC")
default: default:
return db.Order("payment_date DESC").Order("created_at DESC") return db.Order("payments.payment_date DESC").Order("payments.created_at DESC")
} }
} }
@@ -10,13 +10,15 @@ 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"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"` CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"` SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"` SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=payment_code reference_number transaction_type customer_name payment_date created_at payment_method bank expense_amount income_amount"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
} }
@@ -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,6 +72,7 @@ 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,
} }
@@ -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,301 @@ 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)
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 return err
} }
if err := file.SetCellValue(sheet, "B"+strconv.Itoa(rowNumber), formatMarketingExportDate(item.SoDate)); err != nil { }
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)
doDate := "-"
if group.DeliveryDate != nil {
doDate = formatMarketingExportDate(*group.DeliveryDate)
}
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": doDate, "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 return err
} }
if err := file.SetCellValue(sheet, "C"+strconv.Itoa(rowNumber), formatMarketingExportStatus(item)); err != nil {
return err
} }
if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil { continue
return err
} }
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil {
return err for _, delivery := range group.Deliveries {
} row++
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil { r := strconv.Itoa(row)
return err
} productName := "-"
if err := file.SetCellValue(sheet, "G"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Notes)); err != nil { if delivery.ProductWarehouse != nil && delivery.ProductWarehouse.Product != nil {
return err if n := strings.TrimSpace(delivery.ProductWarehouse.Product.Name); n != "" {
productName = n
} }
} }
lastRow := len(items) + 1 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, doDate); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+r, status); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+r, customer); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+r, salesPerson); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+r, marketingType); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+r, productName); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+r, gudang); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+r, week); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+r, delivery.Qty); err != nil {
return err
}
if delivery.TotalPeti != nil {
if err := file.SetCellValue(sheet, "K"+r, *delivery.TotalPeti); err != nil {
return err
}
} else {
if err := file.SetCellValue(sheet, "K"+r, "-"); err != nil {
return err
}
}
if err := file.SetCellValue(sheet, "L"+r, delivery.AvgWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+r, delivery.TotalWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+r, delivery.UnitPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+r, delivery.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+r, grandTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+r, notes); err != nil {
return err
}
}
}
continue
}
// ── Sales Order branch (all other statuses) ───────────────────────────
grandTotal := sumMarketingGrandTotal(item.SalesOrder)
if len(item.SalesOrder) == 0 {
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
}
for _, prod := range item.SalesOrder {
row++
r := strconv.Itoa(row)
productName := "-"
if prod.ProductWarehouse != nil && prod.ProductWarehouse.Product != nil {
if n := strings.TrimSpace(prod.ProductWarehouse.Product.Name); n != "" {
productName = n
}
}
week := "-"
if prod.Week != nil {
week = strconv.Itoa(*prod.Week)
}
gudang := "-"
if prod.ProductWarehouse != 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 := 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 +478,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 {
@@ -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)
@@ -310,6 +338,8 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id").Order("COALESCE(customers.name, '') " + orderDir) return db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id").Order("COALESCE(customers.name, '') " + orderDir)
case "grand_total": case "grand_total":
return db.Order("(SELECT COALESCE(SUM(mp.total_price), 0) FROM marketing_products mp WHERE mp.marketing_id = marketings.id) " + orderDir) return db.Order("(SELECT COALESCE(SUM(mp.total_price), 0) FROM marketing_products mp WHERE mp.marketing_id = marketings.id) " + orderDir)
case "created_at":
return db.Order("marketings.created_at " + orderDir)
default: default:
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
} }
@@ -416,6 +446,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)
@@ -426,6 +457,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 {
@@ -517,6 +549,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)
} }
@@ -540,15 +574,23 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, err return nil, err
} }
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil)
if err != nil {
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)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction) marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil) marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil)
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) {
@@ -628,6 +670,23 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
} }
} }
if latestApproval != nil && latestApproval.StepNumber == uint16(utils.MarketingDeliveryOrder) {
action := entity.ApprovalActionUpdated
_, err := approvalSvcTx.CreateApproval(
c.Context(),
utils.ApprovalWorkflowMarketing,
id,
utils.MarketingStepSalesOrder,
&action,
actorID,
nil)
if err != nil {
if !errors.Is(err, gorm.ErrDuplicatedKey) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval to Sales Order")
}
}
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -637,6 +696,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)
} }
@@ -516,7 +516,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
c.Context(), c.Context(),
utils.ApprovalWorkflowMarketing, utils.ApprovalWorkflowMarketing,
id, id,
approvalutils.ApprovalStep(latestApproval.StepNumber), utils.MarketingStepPengajuan,
&action, &action,
actorID, actorID,
nil) nil)
@@ -770,7 +770,12 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
totalWeight, totalPrice := s.calculatePriceByMarketingType( var totalWeight, totalPrice float64
if rp.TotalPrice != nil {
totalWeight = math.Round(rp.Qty*rp.AvgWeight*100) / 100
totalPrice = *rp.TotalPrice
} else {
totalWeight, totalPrice = s.calculatePriceByMarketingType(
marketingType, marketingType,
rp.Qty, rp.Qty,
rp.AvgWeight, rp.AvgWeight,
@@ -779,6 +784,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
rp.ConvertionUnit, rp.ConvertionUnit,
rp.WeightPerConvertion, rp.WeightPerConvertion,
) )
}
marketingProduct := &entity.MarketingProduct{ marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId, MarketingId: marketingId,
@@ -821,7 +827,7 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
totalPrice = math.Round(qty*unitPrice*100) / 100 totalPrice = math.Round(qty*unitPrice*100) / 100
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 { } else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
totalWeight = math.Round(qty*avgWeight*100) / 100 totalWeight = math.Round(qty*avgWeight*100) / 100
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100 totalPrice = math.Round(totalWeight*unitPrice*100) / 100
} else { } else {
totalWeight = math.Round(qty*avgWeight*100) / 100 totalWeight = math.Round(qty*avgWeight*100) / 100
@@ -31,7 +31,8 @@ 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"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total"` 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"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
} }
@@ -26,6 +26,7 @@ type CreateMarketingProduct struct {
UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` UnitPrice float64 `json:"unit_price" validate:"required,gt=0"`
Qty float64 `json:"qty" validate:"required,gt=0"` Qty float64 `json:"qty" validate:"required,gt=0"`
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"` AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"`
TotalPrice *float64 `json:"total_price" validate:"omitempty,gt=0"`
} }
type Update struct { type Update struct {
@@ -14,6 +14,7 @@ type CustomerRelationDTO struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
AccountNumber string `json:"account_number"` AccountNumber string `json:"account_number"`
BankName string `json:"bank_name"`
Address string `json:"address,omitempty"` Address string `json:"address,omitempty"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
Pic *userDTO.UserRelationDTO `json:"pic,omitempty"` Pic *userDTO.UserRelationDTO `json:"pic,omitempty"`
@@ -28,6 +29,7 @@ type CustomerListDTO struct {
Phone string `json:"phone"` Phone string `json:"phone"`
Email string `json:"email"` Email string `json:"email"`
AccountNumber string `json:"account_number"` AccountNumber string `json:"account_number"`
BankName string `json:"bank_name"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
Pic userDTO.UserRelationDTO `json:"pic"` Pic userDTO.UserRelationDTO `json:"pic"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedUser userDTO.UserRelationDTO `json:"created_user"`
@@ -53,6 +55,7 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO {
Name: e.Name, Name: e.Name,
Type: e.Type, Type: e.Type,
AccountNumber: e.AccountNumber, AccountNumber: e.AccountNumber,
BankName: e.BankName,
Address: e.Address, Address: e.Address,
Balance: e.Balance, Balance: e.Balance,
Pic: pic, Pic: pic,
@@ -81,6 +84,7 @@ func ToCustomerListDTO(e entity.Customer) CustomerListDTO {
Phone: e.Phone, Phone: e.Phone,
Email: e.Email, Email: e.Email,
AccountNumber: e.AccountNumber, AccountNumber: e.AccountNumber,
BankName: e.BankName,
Pic: pic, Pic: pic,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
@@ -133,6 +133,7 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
Phone: req.Phone, Phone: req.Phone,
Email: req.Email, Email: req.Email,
AccountNumber: req.AccountNumber, AccountNumber: req.AccountNumber,
BankName: req.BankName,
CreatedBy: actorID, CreatedBy: actorID,
} }
@@ -193,6 +194,10 @@ func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["account_number"] = *req.AccountNumber updateBody["account_number"] = *req.AccountNumber
} }
if req.BankName != nil {
updateBody["bank_name"] = *req.BankName
}
if len(updateBody) == 0 { if len(updateBody) == 0 {
return s.GetOne(c, id) return s.GetOne(c, id)
} }
@@ -8,6 +8,7 @@ type Create struct {
Phone string `json:"phone" validate:"required_strict,max=20"` Phone string `json:"phone" validate:"required_strict,max=20"`
Email string `json:"email" validate:"required_strict,email,max=50"` Email string `json:"email" validate:"required_strict,email,max=50"`
AccountNumber string `json:"account_number" validate:"required_strict,max=50"` AccountNumber string `json:"account_number" validate:"required_strict,max=50"`
BankName string `json:"bank_name" validate:"required_strict,max=100"`
} }
type Update struct { type Update struct {
@@ -18,6 +19,7 @@ type Update struct {
Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"` Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"`
Email *string `json:"email,omitempty" validate:"omitempty,max=50"` Email *string `json:"email,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
} }
type Query struct { type Query 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 {
@@ -26,6 +26,7 @@ type SupplierListDTO struct {
Address string `json:"address"` Address string `json:"address"`
Npwp *string `json:"npwp,omitempty"` Npwp *string `json:"npwp,omitempty"`
AccountNumber *string `json:"account_number,omitempty"` AccountNumber *string `json:"account_number,omitempty"`
BankName *string `json:"bank_name,omitempty"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
DueDate int `json:"due_date"` DueDate int `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
@@ -66,6 +67,7 @@ func ToSupplierListDTO(e entity.Supplier) SupplierListDTO {
Address: e.Address, Address: e.Address,
Npwp: e.Npwp, Npwp: e.Npwp,
AccountNumber: e.AccountNumber, AccountNumber: e.AccountNumber,
BankName: e.BankName,
Balance: e.Balance, Balance: e.Balance,
DueDate: e.DueDate, DueDate: e.DueDate,
SupplierRelationDTO: ToSupplierRelationDTO(e), SupplierRelationDTO: ToSupplierRelationDTO(e),
@@ -160,6 +160,7 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
Address: req.Address, Address: req.Address,
Npwp: req.Npwp, Npwp: req.Npwp,
AccountNumber: req.AccountNumber, AccountNumber: req.AccountNumber,
BankName: req.BankName,
DueDate: req.DueDate, DueDate: req.DueDate,
CreatedBy: actorID, CreatedBy: actorID,
} }
@@ -243,6 +244,10 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["account_number"] = *req.AccountNumber updateBody["account_number"] = *req.AccountNumber
} }
if req.BankName != nil {
updateBody["bank_name"] = *req.BankName
}
if req.DueDate != nil { if req.DueDate != nil {
updateBody["due_date"] = *req.DueDate updateBody["due_date"] = *req.DueDate
} }
@@ -12,6 +12,7 @@ type Create struct {
Address string `json:"address" validate:"required_strict"` Address string `json:"address" validate:"required_strict"`
Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
DueDate int `json:"due_date" validate:"required_strict,number,gt=0"` DueDate int `json:"due_date" validate:"required_strict,number,gt=0"`
} }
@@ -27,6 +28,7 @@ type Update struct {
Address *string `json:"address,omitempty" validate:"omitempty"` Address *string `json:"address,omitempty" validate:"omitempty"`
Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
DueDate *int `json:"due_date,omitempty" validate:"omitempty,number,gt=0"` DueDate *int `json:"due_date,omitempty" validate:"omitempty,number,gt=0"`
} }
@@ -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,11 +588,6 @@ 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)
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx.Context(), projectFlockKandangID)
default:
return false, false, nil
}
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil return false, false, nil
@@ -600,6 +595,23 @@ func (s projectflockService) GetProjectFlockKandangTransferStateAtDate(ctx *fibe
s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err) 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") return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
} }
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
// Multi-source: target kandang bisa menerima dari multiple transfer terpisah. Pakai
// EARLIEST transfer (transfer_date ASC) sebagai anchor — kandang masuk transition/laying
// mengikuti batch pertama yang sampai.
allTransfers, allErr := s.TransferLayingRepo.GetAllApprovedByTargetKandang(ctx.Context(), projectFlockKandangID)
if allErr != nil {
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
}
// Repository ORDER BY transfer_date ASC, id ASC → [0] = earliest
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,6 +350,23 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
for sIdx, s := range sapronaks { for sIdx, s := range sapronaks {
if sIdx == 0 { if sIdx == 0 {
eggQty := func(flagName string) int {
if item.EggExportBreakdown != nil {
if bd, ok := item.EggExportBreakdown[flagName]; ok {
return bd.Qty
}
}
return 0
}
eggKg := func(flagName string) float64 {
if item.EggExportBreakdown != nil {
if bd, ok := item.EggExportBreakdown[flagName]; ok {
return bd.Kg
}
}
return 0
}
rowValues := []interface{}{ rowValues := []interface{}{
i + 1, // A i + 1, // A
locationName, // B locationName, // B
@@ -320,28 +376,40 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F
formatAgeLabel(item), // G formatAgeLabel(item), // G
formatDateIndonesian(item.RecordDatetime), // H formatDateIndonesian(item.RecordDatetime), // H
formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), // I item.ProjectFlock.TotalChickQty, // I
formatNumberID(item.FcrValue, 2, true), // J item.FcrValue, // J
formatNumberID(fcrStd, 2, true), // K fcrStd, // K
formatNumberID(item.FeedIntake, 2, true), // L item.FeedIntake, // L
formatNumberID(feedIntakeStd, 2, true), // M feedIntakeStd, // M
formatPercentID(item.CumDepletionRate, 2), // N item.CumDepletionRate, // N
formatPercentID(maxDepletionStd, 2), // O maxDepletionStd, // O
formatNumberID(item.TotalDepletionQty, 2, true), // P item.TotalDepletionQty, // P
formatNumberID(item.EggMass, 2, true), // Q item.EggMass, // Q
formatNumberID(eggMassStd, 2, true), // R eggMassStd, // R
formatNumberID(item.EggWeight, 2, true), // S item.EggWeight, // S
formatNumberID(eggWeightStd, 2, true), // T eggWeightStd, // T
formatPercentID(item.HenDay, 2), // U item.HenDay, // U
formatPercentID(henDayStd, 2), // V henDayStd, // V
formatPercentID(item.HenHouse, 2), // W item.HenHouse, // W
formatPercentID(henHouseStd, 2), // X henHouseStd, // X
formatApprovalStatus(item), // Y formatApprovalStatus(item), // Y
safeExportText(pointerString(item.Approval.Notes)), // Z safeExportText(pointerString(item.Approval.Notes)), // Z
createdBy, // AA createdBy, // AA
formatDateIndonesian(item.CreatedAt), // AB formatDateIndonesian(item.CreatedAt), // AB
s.name, // AC s.name, // AC
s.input, // AD s.input, // AD
eggQty(string(utils.FlagTelurUtuh)), // AE
eggKg(string(utils.FlagTelurUtuh)), // AF
eggQty(string(utils.FlagTelurPecah)), // AG
eggKg(string(utils.FlagTelurPecah)), // AH
eggQty(string(utils.FlagTelurPutih)), // AI
eggKg(string(utils.FlagTelurPutih)), // AJ
eggQty(string(utils.FlagTelurRetak)), // AK
eggKg(string(utils.FlagTelurRetak)), // AL
eggQty(string(utils.FlagTelurPapacal)), // AM
eggKg(string(utils.FlagTelurPapacal)), // AN
eggQty(string(utils.FlagTelurJumbo)), // AO
eggKg(string(utils.FlagTelurJumbo)), // AP
} }
for idx, col := range columns { for idx, col := range columns {
@@ -379,7 +447,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AD%d", lastRow), dataCenterStyle); err != nil { if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AP%d", lastRow), dataCenterStyle); err != nil {
return err return err
} }
@@ -445,6 +513,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
"AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP",
} }
for _, rng := range itemRanges { for _, rng := range itemRanges {
if rng.end > rng.start { if rng.end > rng.start {
@@ -454,6 +523,53 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
} }
file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle) file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle)
file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle) file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle)
// Egg columns use center + thick bottom border
for _, col := range []string{"AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP"} {
file.SetCellStyle(sheet, fmt.Sprintf("%s%d", col, rng.end), fmt.Sprintf("%s%d", col, rng.end), borderBottomCenterStyle)
}
}
numFmtInt := "0"
numberIntStyle, err := file.NewStyle(&excelize.Style{
CustomNumFmt: &numFmtInt,
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "E6E6E6", Style: 1},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
numFmtFloat := "0.00"
numberFloatStyle, err := file.NewStyle(&excelize.Style{
CustomNumFmt: &numFmtFloat,
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "E6E6E6", Style: 1},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
intCols := []string{"E", "I", "AE", "AG", "AI", "AK", "AM", "AO"}
for _, col := range intCols {
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), numberIntStyle); err != nil {
return err
}
}
floatCols := []string{"J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "AD", "AF", "AH", "AJ", "AL", "AN", "AP"}
for _, col := range floatCols {
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), numberFloatStyle); err != nil {
return err
}
} }
return nil return nil
@@ -100,6 +100,11 @@ type RecordingFeedUsageDTO struct {
PendingQty float64 `json:"pending_qty"` PendingQty float64 `json:"pending_qty"`
} }
type EggExportBreakdownDTO struct {
Qty int `json:"qty"`
Kg float64 `json:"kg"`
}
type RecordingListDTO struct { type RecordingListDTO struct {
RecordingRelationDTO RecordingRelationDTO
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -108,6 +113,7 @@ type RecordingListDTO struct {
Kandang *RecordingKandangDTO `json:"kandang,omitempty"` Kandang *RecordingKandangDTO `json:"kandang,omitempty"`
Location *RecordingLocationDTO `json:"location,omitempty"` Location *RecordingLocationDTO `json:"location,omitempty"`
FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"` FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"`
EggExportBreakdown map[string]EggExportBreakdownDTO `json:"egg_breakdown,omitempty"`
} }
type RecordingDetailDTO struct { type RecordingDetailDTO struct {
@@ -51,6 +51,7 @@ type RecordingRepository interface {
UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error
UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error
GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error)
GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error)
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
@@ -581,6 +582,22 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID(
return &egg, nil return &egg, nil
} }
func (r *RecordingRepositoryImpl) GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) {
if len(recordingIDs) == 0 {
return nil, nil
}
var eggs []entity.RecordingEgg
err := r.DB().WithContext(ctx).
Preload("ProductWarehouse.Product").
Where("recording_eggs.recording_id IN ?", recordingIDs).
Find(&eggs).Error
if err != nil {
return nil, err
}
return eggs, nil
}
func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) {
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return false, nil return false, nil
@@ -46,6 +46,7 @@ type RecordingService interface {
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error)
} }
type recordingService struct { type recordingService struct {
@@ -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
@@ -1292,11 +1309,6 @@ 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)
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId)
default:
return true, false, false, false, nil, time.Time{}, nil
}
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return true, false, false, false, nil, time.Time{}, nil return true, false, false, false, nil, time.Time{}, nil
@@ -1304,6 +1316,23 @@ func (s *recordingService) evaluatePopulationMutationState(ctx context.Context,
s.Log.Errorf("Failed to resolve approved transfer for recording %d: %+v", recording.Id, err) 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") return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
} }
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
// Multi-source: target kandang bisa menerima dari multiple transfer terpisah.
// Pakai EARLIEST transfer (transfer_date ASC) sebagai anchor untuk state evaluation —
// kandang dianggap masuk transition/laying berdasarkan batch pertama yang masuk.
allTransfers, allErr := s.TransferLayingRepo.GetAllApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId)
if allErr != nil {
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
}
// Repository sudah ORDER BY transfer_date ASC, id ASC → element [0] adalah earliest.
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
} }
@@ -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,17 +1652,21 @@ 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 for i := range existingTransfers {
} existingTransfer := &existingTransfers[i]
if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID { if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID {
continue continue
} }
// Source di header (single source of truth per migration 20260307130342).
existingSourceID := uint(0) existingSourceID := uint(0)
if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 { if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 {
existingSourceID = *existingTransfer.SourceProjectFlockKandangId 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 { if existingSourceID == 0 && s.LayingTransferSourceRepo != nil {
sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id) sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id)
if sourceErr != nil { if sourceErr != nil {
@@ -1663,30 +1674,28 @@ func (s *transferLayingService) validateTargetSourceLineage(
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying") return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
} }
for _, source := range sources { for _, source := range sources {
if source.SourceProjectFlockKandangId != 0 { if source.SourceProjectFlockKandangId == sourceProjectFlockKandangID {
existingSourceID = source.SourceProjectFlockKandangId existingSourceID = source.SourceProjectFlockKandangId
break break
} }
} }
} }
if existingSourceID == 0 {
continue if existingSourceID != sourceProjectFlockKandangID {
}
if existingSourceID == sourceProjectFlockKandangID {
continue continue
} }
return fiber.NewError( return fiber.NewError(
fiber.StatusBadRequest, fiber.StatusBadRequest,
fmt.Sprintf( fmt.Sprintf(
"Kandang tujuan %d sudah memiliki lineage sumber kandang %d dari transfer %s. Tidak boleh ganti ke sumber kandang %d.", "Source kandang %d sudah pernah ditransfer ke target kandang %d via transfer %s. Tidak boleh duplikat (source, target) pair yang sama.",
targetKandangID,
existingSourceID,
existingTransfer.TransferNumber,
sourceProjectFlockKandangID, sourceProjectFlockKandangID,
targetKandangID,
existingTransfer.TransferNumber,
), ),
) )
} }
}
return nil return nil
} }
@@ -91,17 +91,17 @@ 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)),
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)),
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
} }
} }
@@ -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{
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
} }
result[items[i].Id] = total
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
} }
return result
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},
}, },
} }
@@ -38,6 +38,9 @@ type PurchaseListDTO struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
ProductsTotal float64 `json:"products_total"`
ExpeditionTotal float64 `json:"expedition_total"`
GrandTotalAll float64 `json:"grand_total_all"`
} }
type PurchaseDetailDTO struct { type PurchaseDetailDTO struct {
@@ -69,6 +72,8 @@ type PurchaseItemDTO struct {
VehicleNumber *string `json:"vehicle_number"` VehicleNumber *string `json:"vehicle_number"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"` TransportPerItem *float64 `json:"transport_per_item,omitempty"`
ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"`
ExpeditionQty float64 `json:"expedition_qty"`
ExpeditionTotal float64 `json:"expedition_total"`
HasChickin bool `json:"has_chickin"` HasChickin bool `json:"has_chickin"`
} }
@@ -127,6 +132,8 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO {
if item.ExpenseNonstock != nil { if item.ExpenseNonstock != nil {
priceCopy := item.ExpenseNonstock.Price priceCopy := item.ExpenseNonstock.Price
dto.TransportPerItem = &priceCopy dto.TransportPerItem = &priceCopy
dto.ExpeditionQty = item.ExpenseNonstock.Qty
dto.ExpeditionTotal = item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
if item.ExpenseNonstock.Expense != nil { if item.ExpenseNonstock.Expense != nil {
exp := item.ExpenseNonstock.Expense exp := item.ExpenseNonstock.Expense
@@ -177,11 +184,17 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
location *locationDTO.LocationRelationDTO location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO area *areaDTO.AreaRelationDTO
receivedDate *time.Time receivedDate *time.Time
productsTotal float64
expeditionTotal float64
) )
productMap := make(map[uint]productDTO.ProductRelationDTO) productMap := make(map[uint]productDTO.ProductRelationDTO)
expeditionRefSet := make(map[uint64]struct{}) expeditionRefSet := make(map[uint64]struct{})
for i := range p.Items { for i := range p.Items {
item := p.Items[i] item := p.Items[i]
productsTotal += item.TotalPrice
if item.ExpenseNonstock != nil {
expeditionTotal += item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
}
if item.Product != nil && item.Product.Id != 0 { if item.Product != nil && item.Product.Id != 0 {
if _, exists := productMap[item.Product.Id]; !exists { if _, exists := productMap[item.Product.Id]; !exists {
productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product) productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product)
@@ -235,6 +248,9 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
CreatedAt: p.CreatedAt, CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval, LatestApproval: latestApproval,
ProductsTotal: productsTotal,
ExpeditionTotal: expeditionTotal,
GrandTotalAll: productsTotal + expeditionTotal,
} }
} }
+3
View File
@@ -61,6 +61,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
expenseRealizationRepo, expenseRealizationRepo,
projectFlockKandangRepository, projectFlockKandangRepository,
documentSvc, documentSvc,
commonSvc.NewFifoPaymentService(db, utils.Log),
validate, validate,
) )
expenseBridge := service.NewExpenseBridge( expenseBridge := service.NewExpenseBridge(
@@ -72,6 +73,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
) )
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
fifoPaymentService := commonSvc.NewFifoPaymentService(db, utils.Log)
purchaseService := service.NewPurchaseService( purchaseService := service.NewPurchaseService(
validate, validate,
@@ -84,6 +86,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
approvalService, approvalService,
expenseBridge, expenseBridge,
fifoStockV2Service, fifoStockV2Service,
fifoPaymentService,
documentSvc, documentSvc,
) )
@@ -24,7 +24,6 @@ type PurchaseRepository interface {
UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error
DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error
NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error)
NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error)
BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error
SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error)
@@ -369,9 +368,8 @@ func (r *PurchaseRepositoryImpl) NextPrNumber(ctx context.Context, tx *gorm.DB)
return r.generateSequentialNumber(ctx, tx, "pr_number", utils.PurchasePRNumberPrefix, utils.PurchaseNumberPadding) return r.generateSequentialNumber(ctx, tx, "pr_number", utils.PurchasePRNumberPrefix, utils.PurchaseNumberPadding)
} }
func (r *PurchaseRepositoryImpl) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) { // NOTE: NextPoNumber dihapus per migration 20260529143940 — po_number sekarang
return r.generateSequentialNumber(ctx, tx, "po_number", utils.PurchasePONumberPrefix, utils.PurchaseNumberPadding) // di-derive dari pr_number (swap prefix) via derivePoFromPr di purchase.service.go.
}
func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) {
db := tx db := tx
@@ -64,6 +64,7 @@ type purchaseService struct {
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
ExpenseBridge PurchaseExpenseBridge ExpenseBridge PurchaseExpenseBridge
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
FifoPaymentSvc commonSvc.FifoPaymentService
DocumentSvc commonSvc.DocumentService DocumentSvc commonSvc.DocumentService
approvalWorkflow approvalutils.ApprovalWorkflowKey approvalWorkflow approvalutils.ApprovalWorkflowKey
} }
@@ -91,6 +92,7 @@ func NewPurchaseService(
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
expenseBridge PurchaseExpenseBridge, expenseBridge PurchaseExpenseBridge,
fifoStockV2Svc commonSvc.FifoStockV2Service, fifoStockV2Svc commonSvc.FifoStockV2Service,
fifoPaymentSvc commonSvc.FifoPaymentService,
documentSvc commonSvc.DocumentService, documentSvc commonSvc.DocumentService,
) PurchaseService { ) PurchaseService {
return &purchaseService{ return &purchaseService{
@@ -105,6 +107,7 @@ func NewPurchaseService(
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
ExpenseBridge: expenseBridge, ExpenseBridge: expenseBridge,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
FifoPaymentSvc: fifoPaymentSvc,
DocumentSvc: documentSvc, DocumentSvc: documentSvc,
approvalWorkflow: utils.ApprovalWorkflowPurchase, approvalWorkflow: utils.ApprovalWorkflowPurchase,
} }
@@ -145,33 +148,16 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id") productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id")
if err != nil { if err != nil {
return nil, 0, utils.BadRequest(err.Error()) return nil, 0, utils.BadRequest(err.Error())
} }
var poDateStart *time.Time dateStart, dateEnd, err := parsePurchaseDateRangeForQuery(params.StartDate, params.EndDate, "date")
var poDateEnd *time.Time
if strings.TrimSpace(params.PoDate) != "" {
poDate, parseErr := utils.ParseDateString(strings.TrimSpace(params.PoDate))
if parseErr != nil {
return nil, 0, utils.BadRequest("po_date must use format YYYY-MM-DD")
}
poDateStart = &poDate
poDateEndValue := poDate.AddDate(0, 0, 1)
poDateEnd = &poDateEndValue
} else {
poDateStart, poDateEnd, err = parsePoDateRangeForQuery(params.PoDateFrom, params.PoDateTo)
if err != nil { if err != nil {
return nil, 0, utils.BadRequest(err.Error()) return nil, 0, utils.BadRequest(err.Error())
} }
} filterBy := strings.TrimSpace(params.FilterBy)
search := strings.ToLower(strings.TrimSpace(params.Search)) search := strings.ToLower(strings.TrimSpace(params.Search))
approvalStatuses := parseStringCSVFilter(params.ApprovalStatus) approvalStatuses := parseStringCSVFilter(params.ApprovalStatus)
@@ -187,23 +173,41 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = db.Where("supplier_id = ?", params.SupplierID) db = db.Where("supplier_id = ?", params.SupplierID)
} }
if createdFrom != nil { switch filterBy {
db = db.Where("created_at >= ?", *createdFrom) case "po_date":
if dateStart != nil {
db = db.Where("purchases.po_date >= ?", *dateStart)
} }
if dateEnd != nil {
if createdTo != nil { db = db.Where("purchases.po_date < ?", *dateEnd)
db = db.Where("created_at < ?", *createdTo)
} }
if poDateStart != nil { case "due_date":
db = db.Where("purchases.po_date >= ?", *poDateStart) if dateStart != nil {
db = db.Where("purchases.due_date >= ?", *dateStart)
} }
if dateEnd != nil {
if poDateStart != nil { db = db.Where("purchases.due_date < ?", *dateEnd)
db = db.Where("purchases.po_date >= ?", *poDateStart) }
case "received_date":
if dateStart != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date >= ?)`,
*dateStart,
)
}
if dateEnd != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date < ?)`,
*dateEnd,
)
}
default:
if dateStart != nil {
db = db.Where("purchases.created_at >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where("purchases.created_at < ?", *dateEnd)
} }
if poDateEnd != nil {
db = db.Where("purchases.po_date < ?", *poDateEnd)
} }
if scope.Restrict { if scope.Restrict {
@@ -261,7 +265,56 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = applyPurchaseApprovalStatusFilter(db, approvalStatuses) db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
db = applyPurchaseSearchFilter(db, search) db = applyPurchaseSearchFilter(db, search)
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortBy == "" && (filterBy == "po_date" || filterBy == "due_date" || filterBy == "received_date" || filterBy == "created_at") {
sortBy = filterBy
if sortOrder == "" {
sortOrder = "ASC"
}
}
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "po_expedition":
return db.Order(`(SELECT MIN(e.reference_number) FROM purchase_items pi
LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
LEFT JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder + " NULLS LAST")
case "supplier":
return db.Order(`(SELECT COALESCE(s.name, '') FROM suppliers s WHERE s.id = purchases.supplier_id) ` + sortOrder)
case "requester_name":
return db.Order(`(SELECT COALESCE(u.name, '') FROM users u WHERE u.id = purchases.created_by) ` + sortOrder)
case "products":
return db.Order(`(SELECT MIN(COALESCE(p.name, '')) FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder)
case "location":
return db.Order(`(SELECT MIN(COALESCE(l.name, '')) FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
JOIN locations l ON l.id = w.location_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder)
case "po_date":
return db.Order("purchases.po_date " + sortOrder)
case "po_number":
return db.Order("COALESCE(purchases.po_number, purchases.pr_number) " + sortOrder)
case "received_date":
return db.Order(`(SELECT MIN(pi2.received_date) FROM purchase_items pi2 WHERE pi2.purchase_id = purchases.id) ` + sortOrder)
case "due_date":
return db.Order("purchases.due_date " + sortOrder)
case "status":
return db.Order(`(SELECT COALESCE(a.step_name, '') FROM approvals a
WHERE a.approvable_type = 'PURCHASES' AND a.approvable_id = purchases.id
ORDER BY a.action_at DESC, a.id DESC LIMIT 1) ` + sortOrder)
case "created_at":
return db.Order("purchases.created_at " + sortOrder)
default:
return db.Order("created_at DESC").Order("purchases.id DESC") return db.Order("created_at DESC").Order("purchases.id DESC")
}
}) })
if err != nil { if err != nil {
@@ -726,8 +779,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
updateData := map[string]any{} updateData := map[string]any{}
if !hasExistingPO { if !hasExistingPO {
repoTx := rPurchase.NewPurchaseRepository(tx) code, err := derivePoFromPr(purchase.PrNumber)
code, err := repoTx.NextPoNumber(c.Context(), tx)
if err != nil { if err != nil {
return err return err
} }
@@ -1356,6 +1408,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return nil, err return nil, err
} }
// Refresh purchase.grand_total + reallocate payment FIFO untuk supplier (new debt baru emerges).
if s.FifoPaymentSvc != nil && receivingAction == entity.ApprovalActionApproved {
if err := s.FifoPaymentSvc.RecomputeGrandTotal(c.Context(), nil, commonSvc.ParentKindPurchase, purchase.Id); err != nil {
s.Log.Warnf("Failed to recompute grand_total for purchase %d: %+v", purchase.Id, err)
}
if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), nil, string(utils.PaymentPartySupplier), uint(purchase.SupplierId)); err != nil {
s.Log.Warnf("Failed to reallocate payments for supplier %d: %+v", purchase.SupplierId, err)
}
}
return updated, nil return updated, nil
} }
@@ -2197,30 +2259,36 @@ func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []ent
return nil return nil
} }
func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) { func parsePurchaseDateRangeForQuery(fromStr, toStr, fieldName string) (*time.Time, *time.Time, error) {
jakartaLoc, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
jakartaLoc = time.FixedZone("WIB", 7*60*60)
}
var fromPtr *time.Time var fromPtr *time.Time
var toPtr *time.Time var toPtr *time.Time
if strings.TrimSpace(fromStr) != "" { if strings.TrimSpace(fromStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr)) parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr))
if err != nil { if err != nil {
return nil, nil, errors.New("po_date_from must use format YYYY-MM-DD") return nil, nil, errors.New(fieldName + "_from must use format YYYY-MM-DD")
} }
fromValue := parsed t := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, jakartaLoc)
fromPtr = &fromValue fromPtr = &t
} }
if strings.TrimSpace(toStr) != "" { if strings.TrimSpace(toStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(toStr)) parsed, err := utils.ParseDateString(strings.TrimSpace(toStr))
if err != nil { if err != nil {
return nil, nil, errors.New("po_date_to must use format YYYY-MM-DD") return nil, nil, errors.New(fieldName + "_to must use format YYYY-MM-DD")
} }
nextDay := parsed.AddDate(0, 0, 1) t := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, jakartaLoc)
nextDay := t.AddDate(0, 0, 1)
toPtr = &nextDay toPtr = &nextDay
} }
if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
return nil, nil, errors.New("po_date_from must be earlier than po_date_to") return nil, nil, errors.New(fieldName + "_from must be earlier than " + fieldName + "_to")
} }
return fromPtr, toPtr, nil return fromPtr, toPtr, nil
@@ -2444,6 +2512,18 @@ func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) {
} }
} }
// derivePoFromPr menghasilkan po_number dari pr_number dengan swap prefix.
// Contoh: "PR-LTI-0050" -> "PO-LTI-0050". Mengembalikan error kalau pr_number
// tidak diawali prefix standar — caller harus memastikan PR sudah valid.
func derivePoFromPr(prNumber string) (string, error) {
trimmed := strings.TrimSpace(prNumber)
if !strings.HasPrefix(trimmed, utils.PurchasePRNumberPrefix) {
return "", fmt.Errorf("invalid pr_number %q: missing prefix %q", trimmed, utils.PurchasePRNumberPrefix)
}
suffix := strings.TrimPrefix(trimmed, utils.PurchasePRNumberPrefix)
return utils.PurchasePONumberPrefix + suffix, nil
}
func (s *purchaseService) rejectAndReload( func (s *purchaseService) rejectAndReload(
c *fiber.Ctx, c *fiber.Ctx,
step approvalutils.ApprovalStep, step approvalutils.ApprovalStep,
@@ -75,10 +75,10 @@ type Query struct {
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=po_date due_date received_date created_at"`
Search string `query:"search" validate:"omitempty,max=100"` Search string `query:"search" validate:"omitempty,max=100"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` SortBy string `query:"sort_by" validate:"omitempty,oneof=po_expedition supplier requester_name products location po_date received_date due_date status created_at po_number"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
} }
@@ -0,0 +1,286 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
)
func isBalanceMonitoringExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func exportBalanceMonitoringExcel(c *fiber.Ctx, items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) error {
content, err := buildBalanceMonitoringWorkbook(items, totals)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-balance-monitoring-%s.xlsx", time.Now().Format("2006-01-02-1504"))
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 buildBalanceMonitoringWorkbook(items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
const sheet = "Balance Monitoring"
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
if err := setBalanceMonitoringColumns(file, sheet); err != nil {
return nil, err
}
if err := setBalanceMonitoringHeaders(file, sheet); err != nil {
return nil, err
}
if err := writeBalanceMonitoringRows(file, sheet, items, totals); err != nil {
return nil, err
}
if err := file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 2,
TopLeftCell: "A3",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
var bmColumnWidths = map[string]float64{
"A": 5,
"B": 28,
"C": 18,
"D": 12,
"E": 12,
"F": 20,
"G": 12,
"H": 12,
"I": 20,
"J": 20,
"K": 18,
"L": 12,
"M": 16,
"N": 20,
}
func setBalanceMonitoringColumns(file *excelize.File, sheet string) error {
for col, width := range bmColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return file.SetRowHeight(sheet, 2, 24)
}
func setBalanceMonitoringHeaders(file *excelize.File, sheet string) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: borderStyle,
})
if err != nil {
return err
}
// Single-column headers: merge rows 1 and 2 vertically
singleColHeaders := map[string]string{
"A": "No",
"B": "Customer",
"C": "Saldo Awal",
"J": "Penjualan Trading",
"K": "Pembayaran",
"L": "Aging",
"M": "Aging Rata-Rata",
"N": "Saldo Akhir",
}
for col, header := range singleColHeaders {
if err := file.SetCellValue(sheet, col+"1", header); err != nil {
return err
}
if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil {
return err
}
}
// Group headers: merge columns horizontally in row 1
if err := file.SetCellValue(sheet, "D1", "Penjualan Ayam"); err != nil {
return err
}
if err := file.MergeCell(sheet, "D1", "F1"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G1", "Penjualan Telur"); err != nil {
return err
}
if err := file.MergeCell(sheet, "G1", "I1"); err != nil {
return err
}
// Sub-column headers in row 2
subHeaders := map[string]string{
"D": "Ekor",
"E": "Kg",
"F": "Nominal",
"G": "Butir",
"H": "Kg",
"I": "Nominal",
}
for col, header := range subHeaders {
if err := file.SetCellValue(sheet, col+"2", header); err != nil {
return err
}
}
return file.SetCellStyle(sheet, "A1", "N2", headerStyle)
}
func writeBalanceMonitoringRows(file *excelize.File, sheet string, items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
dataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
totalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
redDataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "FF0000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
redTotalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FF0000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
for i, row := range items {
rowNum := i + 3
rowStr := strconv.Itoa(rowNum)
cells := map[string]interface{}{
"A": i + 1,
"B": row.Customer.Name,
"C": row.SaldoAwal,
"D": row.PenjualanAyam.Ekor,
"E": row.PenjualanAyam.Kg,
"F": row.PenjualanAyam.Nominal,
"G": row.PenjualanTelur.Butir,
"H": row.PenjualanTelur.Kg,
"I": row.PenjualanTelur.Nominal,
"J": row.PenjualanTrading.Nominal,
"K": row.Pembayaran,
"L": fmt.Sprintf("%d hari", row.Aging),
"M": formatBMAging(row.AgingRataRata),
"N": row.SaldoAkhir,
}
for col, val := range cells {
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+rowStr, "N"+rowStr, dataStyle); err != nil {
return err
}
if row.SaldoAkhir < 0 {
if err := file.SetCellStyle(sheet, "N"+rowStr, "N"+rowStr, redDataStyle); err != nil {
return err
}
}
}
// Totals row
totalRowStr := strconv.Itoa(len(items) + 3)
totalCells := map[string]interface{}{
"A": "Total",
"C": totals.SaldoAwal,
"D": totals.PenjualanAyam.Ekor,
"E": totals.PenjualanAyam.Kg,
"F": totals.PenjualanAyam.Nominal,
"G": totals.PenjualanTelur.Butir,
"H": totals.PenjualanTelur.Kg,
"I": totals.PenjualanTelur.Nominal,
"J": totals.PenjualanTrading.Nominal,
"K": totals.Pembayaran,
"N": totals.SaldoAkhir,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+totalRowStr, "N"+totalRowStr, totalStyle); err != nil {
return err
}
if totals.SaldoAkhir < 0 {
if err := file.SetCellStyle(sheet, "N"+totalRowStr, "N"+totalRowStr, redTotalStyle); err != nil {
return err
}
}
return nil
}
func formatBMAging(v float64) string {
s := strconv.FormatFloat(v, 'f', 2, 64)
s = strings.ReplaceAll(s, ".", ",")
return s + " hari"
}

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