Compare commits

..

43 Commits

Author SHA1 Message Date
giovanni be440af1c2 add api update periode flock/project flock kandang 2026-04-23 21:56:17 +07:00
Adnan Zahir e6010fe47e Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
Cmd/consolidate and repoint stocks

See merge request mbugroup/lti-api!446
2026-04-23 21:32:59 +07:00
Adnan Zahir cdcc268d89 : 2026-04-23 21:32:20 +07:00
Adnan Zahir 891da70efb cmd: add commands to fix misplaced stocks and move leftover kandang stocks to farm-level warehouse 2026-04-23 21:09:04 +07:00
Giovanni Gabriel Septriadi e45ebca5a4 Merge branch 'feat/excel-po-mrk' into 'development'
[FIX][BE]: adjust validation create daily checklist empty kandang

See merge request mbugroup/lti-api!445
2026-04-23 07:26:22 +00:00
giovanni eacc460f67 adjust validation create daily checklist empty kandang 2026-04-23 14:24:13 +07:00
Giovanni Gabriel Septriadi d2ab1c7ea5 Merge branch 'feat/excel-po-mrk' into 'development'
[FIX][BE]: add kolom lokasi to export

See merge request mbugroup/lti-api!444
2026-04-23 06:50:42 +00:00
giovanni 151edf578e add kolom lokasi to export 2026-04-23 13:49:51 +07:00
Adnan Zahir e065e1fb25 Merge branch 'codex/filter-improvement' into 'development'
feat: filter improvement

See merge request mbugroup/lti-api!442
2026-04-23 00:19:08 +07:00
Adnan Zahir e24e2ff123 feat: filter improvement 2026-04-23 00:17:24 +07:00
Giovanni Gabriel Septriadi 266f683db1 Merge branch 'feat/excel-po-mrk' into 'development'
[FEAT][BE]: add export excel all expenses

See merge request mbugroup/lti-api!441
2026-04-22 16:30:16 +00:00
giovanni c744043321 add export excel all expenses 2026-04-22 23:29:05 +07:00
Giovanni Gabriel Septriadi 4673c7ad33 Merge branch 'feat/excel-po-mrk' into 'development'
[FEAT][BE]: add export excel from api Expense

See merge request mbugroup/lti-api!440
2026-04-22 15:51:19 +00:00
giovanni 3e99caf3a7 add export excel from api 2026-04-22 22:50:20 +07:00
Giovanni Gabriel Septriadi a15fd1b174 Merge branch 'fix/sapronak-cal' into 'development'
[FIX][BE]: fix perhitunga sapronak

See merge request mbugroup/lti-api!439
2026-04-22 12:36:16 +00:00
Giovanni Gabriel Septriadi 831d72cb86 Merge branch 'feat/excel-po-mrk' into 'development'
[FEAT][BE]: add export po and marketing

See merge request mbugroup/lti-api!438
2026-04-22 12:24:48 +00:00
giovanni ff630a1ed0 add export po and marketing 2026-04-22 19:22:29 +07:00
Giovanni Gabriel Septriadi 7d223c81ba Merge branch 'fix/dc-softdelete' into 'development'
[FEAT][BE]: adjust softdelete daily checklist; add empty kandang

See merge request mbugroup/lti-api!437
2026-04-22 09:26:31 +00:00
giovanni 91d51bf1b8 adjust softdelete daily checklist; add empty kandang 2026-04-22 16:24:31 +07:00
Giovanni Gabriel Septriadi 2a141a96d1 Merge branch 'codex/recording' into 'development'
Codex/recording

See merge request mbugroup/lti-api!435
2026-04-22 05:58:42 +00:00
giovanni f51fa0a16c adjust repo hpp v2 2026-04-22 12:57:41 +07:00
Adnan Zahir 9b9f5e257e Merge branch 'codex/bulk-approve-marketings-expenses' into 'development'
fix: DTO mismatch marketings

See merge request mbugroup/lti-api!434
2026-04-22 11:49:44 +07:00
Adnan Zahir adabd43f38 fix: DTO mismatch marketings 2026-04-22 11:41:27 +07:00
Adnan Zahir 640b6b382b Merge branch 'codex/bulk-approve-marketings-expenses' into 'development'
fix: DTO adjustment for bulk approve

See merge request mbugroup/lti-api!433
2026-04-22 10:11:56 +07:00
giovanni ca6e9ef0d2 adjust name migration 2026-04-22 10:11:10 +07:00
Adnan Zahir c8ea370e4b fix: DTO adjustment for bulk approve 2026-04-22 09:58:54 +07:00
giovanni fec7bb5825 adjust 2026-04-22 01:44:37 +07:00
giovanni 091f706276 init adjustment recording 2026-04-21 22:43:18 +07:00
Adnan Zahir 5594c27108 Merge branch 'codex/export-progress' into 'development'
fix: internal server error because of date parsing

See merge request mbugroup/lti-api!432
2026-04-21 21:43:25 +07:00
Adnan Zahir e91c45ee50 fix: internal server error because of date parsing 2026-04-21 21:41:54 +07:00
Adnan Zahir 5b2766676b Merge branch 'codex/export-progress' into 'development'
feat: export input progress report for expenses, marketings, purchases, and recordings

See merge request mbugroup/lti-api!431
2026-04-21 21:24:58 +07:00
Adnan Zahir 5e7c51e9c2 feat: export input progress report for expenses, marketings, purchases, and recordings 2026-04-21 21:24:19 +07:00
Adnan Zahir a98a709766 Merge branch 'codex/bulk-approve-marketings-expenses' into 'development'
feat: bulk approve endpoint for marketings and expenses

See merge request mbugroup/lti-api!430
2026-04-21 20:08:43 +07:00
Adnan Zahir 0d04397bd5 feat: bulk approve endpoint for marketings and expenses 2026-04-21 20:06:37 +07:00
giovanni ded8be198a fix perhitunga sapronak 2026-04-21 19:30:03 +07:00
Giovanni Gabriel Septriadi 1e34a0e7b2 Merge branch 'fix/bulk' into 'development'
[FIX][BE]: adjust bulk update daily checklist

See merge request mbugroup/lti-api!429
2026-04-21 08:57:31 +00:00
giovanni c5bb0ef577 adjust bulk update daily checklist 2026-04-21 13:47:20 +07:00
Adnan Zahir 5b2f66c0c7 Merge branch 'feat/bulk' into 'development'
[FEAT][BE]: add api bulk update status daily checklist; change hpp real to estimate

See merge request mbugroup/lti-api!428
2026-04-21 11:37:57 +07:00
giovanni 916f1980e9 add api bulk update status daily checklist; change hpp real to estimate 2026-04-20 16:08:43 +07:00
Adnan Zahir 5355fe0729 Merge branch 'fix/record' into 'development'
fix

See merge request mbugroup/lti-api!426
2026-04-20 10:11:12 +07:00
giovanni e679193f18 fix 2026-04-20 10:09:40 +07:00
Adnan Zahir 36a740d330 Merge branch 'codex/depresiasi' into 'development'
add command normalize data seed standar and price adjustment stocks

See merge request mbugroup/lti-api!424
2026-04-20 00:05:18 +07:00
Adnan Zahir 75d42354e9 Merge branch 'codex/depresiasi' into 'development'
Codex/depresiasi

See merge request mbugroup/lti-api!423
2026-04-19 21:29:26 +07:00
89 changed files with 12059 additions and 583 deletions
+1
View File
@@ -30,3 +30,4 @@ coverage/
.idea/ .idea/
*.swp *.swp
.DS_Store .DS_Store
.gemini/
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"text/tabwriter"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
const (
outputModeTable = "table"
outputModeJSON = "json"
reportUsage = "usage"
reportWarehouses = "warehouses"
)
type options struct {
Output string
Report string
AreaName string
KandangLocationName string
WrongWarehouseName string
CorrectWarehouseName string
UsableType string
DBSSLMode string
}
type usageRow struct {
UsableType string `gorm:"column:usable_type" json:"usable_type"`
UsableID uint `gorm:"column:usable_id" json:"usable_id"`
AreaName string `gorm:"column:area_name" json:"area_name"`
LokasiName string `gorm:"column:lokasi_name" json:"lokasi_name"`
KandangName string `gorm:"column:kandang_name" json:"kandang_name"`
WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"`
WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"`
CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"`
CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"`
ProductNames string `gorm:"column:product_names" json:"product_names"`
SourcePurchaseNumbers string `gorm:"column:source_purchase_numbers" json:"source_purchase_numbers"`
SourcePurchaseItemIDs string `gorm:"column:source_purchase_item_ids" json:"source_purchase_item_ids"`
QtyFromWrongStock float64 `gorm:"column:qty_from_wrong_stock" json:"qty_from_wrong_stock"`
RecordingID *uint `gorm:"column:recording_id" json:"recording_id,omitempty"`
RecordingDate *string `gorm:"column:recording_date" json:"recording_date,omitempty"`
SoNumber *string `gorm:"column:so_number" json:"so_number,omitempty"`
SoDate *string `gorm:"column:so_date" json:"so_date,omitempty"`
}
type warehouseMismatchRow struct {
AreaName string `gorm:"column:area_name" json:"area_name"`
WrongLocationName string `gorm:"column:wrong_location_name" json:"wrong_location_name"`
KandangLocationName string `gorm:"column:kandang_location_name" json:"kandang_location_name"`
KandangID uint `gorm:"column:kandang_id" json:"kandang_id"`
KandangName string `gorm:"column:kandang_name" json:"kandang_name"`
WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"`
WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"`
WrongWarehouseType string `gorm:"column:wrong_warehouse_type" json:"wrong_warehouse_type"`
CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"`
CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"`
}
type summary struct {
Rows int `json:"rows"`
TotalQty float64 `json:"total_qty,omitempty"`
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
if opts.DBSSLMode != "" {
config.DBSSLMode = opts.DBSSLMode
}
db := database.Connect(config.DBHost, config.DBName)
switch opts.Report {
case reportUsage:
rows, err := loadUsageRows(ctx, db, opts)
if err != nil {
log.Fatalf("failed loading usage rows: %v", err)
}
renderUsageReport(opts.Output, rows)
case reportWarehouses:
rows, err := loadWarehouseMismatchRows(ctx, db, opts)
if err != nil {
log.Fatalf("failed loading warehouse mismatch rows: %v", err)
}
renderWarehouseReport(opts.Output, rows)
default:
log.Fatalf("unsupported --report=%s", opts.Report)
}
}
func parseFlags() (*options, error) {
var opts options
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
flag.StringVar(&opts.Report, "report", reportUsage, "Report type: usage or warehouses")
flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter")
flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter")
flag.StringVar(&opts.WrongWarehouseName, "wrong-warehouse-name", "", "Optional exact wrong warehouse name filter")
flag.StringVar(&opts.CorrectWarehouseName, "correct-warehouse-name", "", "Optional exact correct warehouse name filter")
flag.StringVar(&opts.UsableType, "usable-type", "", "Optional usage type filter: RECORDING_STOCK or MARKETING_DELIVERY")
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require")
flag.Parse()
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
opts.Report = strings.ToLower(strings.TrimSpace(opts.Report))
opts.AreaName = strings.TrimSpace(opts.AreaName)
opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName)
opts.WrongWarehouseName = strings.TrimSpace(opts.WrongWarehouseName)
opts.CorrectWarehouseName = strings.TrimSpace(opts.CorrectWarehouseName)
opts.UsableType = strings.ToUpper(strings.TrimSpace(opts.UsableType))
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
if opts.Output == "" {
opts.Output = outputModeTable
}
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.Report == "" {
opts.Report = reportUsage
}
if opts.Report != reportUsage && opts.Report != reportWarehouses {
return nil, fmt.Errorf("unsupported --report=%s", opts.Report)
}
if opts.UsableType != "" && opts.UsableType != "RECORDING_STOCK" && opts.UsableType != "MARKETING_DELIVERY" {
return nil, fmt.Errorf("unsupported --usable-type=%s", opts.UsableType)
}
return &opts, nil
}
func loadUsageRows(ctx context.Context, db *gorm.DB, opts *options) ([]usageRow, error) {
warehouseFilters, warehouseArgs := buildWarehouseFilters(opts)
usageFilters := make([]string, 0, 1)
usageArgs := make([]any, 0, 1)
if opts.UsableType != "" {
usageFilters = append(usageFilters, "sa.usable_type = ?")
usageArgs = append(usageArgs, opts.UsableType)
}
args := append([]any{}, warehouseArgs...)
args = append(args, usageArgs...)
query := fmt.Sprintf(`
WITH wrong_warehouses AS (
SELECT
w.id AS wrong_warehouse_id,
w.name AS wrong_warehouse_name,
k.id AS kandang_id,
k.name AS kandang_name,
a.name AS area_name,
kl.name AS kandang_location_name,
correct_w.id AS correct_warehouse_id,
correct_w.name AS correct_warehouse_name
FROM warehouses w
JOIN kandangs k
ON k.id = w.kandang_id
AND k.deleted_at IS NULL
JOIN locations kl
ON kl.id = k.location_id
JOIN areas a
ON a.id = kl.area_id
JOIN LATERAL (
SELECT w2.id, w2.name
FROM warehouses w2
WHERE w2.kandang_id = w.kandang_id
AND w2.location_id = k.location_id
AND w2.deleted_at IS NULL
AND w2.id <> w.id
ORDER BY w2.id ASC
LIMIT 1
) AS correct_w ON TRUE
WHERE w.deleted_at IS NULL
AND w.kandang_id IS NOT NULL
AND w.location_id IS DISTINCT FROM k.location_id
%s
),
wrong_allocs AS (
SELECT
sa.usable_type,
sa.usable_id,
sa.qty,
pi.id AS purchase_item_id,
COALESCE(p.po_number, p.pr_number) AS purchase_number,
pr.name AS product_name,
ww.area_name,
ww.kandang_location_name,
ww.kandang_name,
ww.wrong_warehouse_id,
ww.wrong_warehouse_name,
ww.correct_warehouse_id,
ww.correct_warehouse_name
FROM stock_allocations sa
JOIN purchase_items pi
ON pi.id = sa.stockable_id
JOIN purchases p
ON p.id = pi.purchase_id
AND p.deleted_at IS NULL
JOIN products pr
ON pr.id = pi.product_id
JOIN wrong_warehouses ww
ON ww.wrong_warehouse_id = pi.warehouse_id
WHERE sa.stockable_type = 'PURCHASE_ITEMS'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
%s
)
SELECT
wa.usable_type,
wa.usable_id,
wa.area_name,
wa.kandang_location_name AS lokasi_name,
wa.kandang_name,
wa.wrong_warehouse_id,
wa.wrong_warehouse_name,
wa.correct_warehouse_id,
wa.correct_warehouse_name,
STRING_AGG(DISTINCT wa.product_name, ' | ') AS product_names,
STRING_AGG(DISTINCT wa.purchase_number, ', ') AS source_purchase_numbers,
STRING_AGG(DISTINCT wa.purchase_item_id::text, ', ') AS source_purchase_item_ids,
SUM(wa.qty) AS qty_from_wrong_stock,
rs.recording_id,
TO_CHAR(r.record_datetime::date, 'YYYY-MM-DD') AS recording_date,
m.so_number,
TO_CHAR(m.so_date::date, 'YYYY-MM-DD') AS so_date
FROM wrong_allocs wa
LEFT JOIN recording_stocks rs
ON wa.usable_type = 'RECORDING_STOCK'
AND rs.id = wa.usable_id
LEFT JOIN recordings r
ON r.id = rs.recording_id
LEFT JOIN marketing_delivery_products mdp
ON wa.usable_type = 'MARKETING_DELIVERY'
AND mdp.id = wa.usable_id
LEFT JOIN marketing_products mp
ON mp.id = mdp.marketing_product_id
LEFT JOIN marketings m
ON m.id = mp.marketing_id
GROUP BY
wa.usable_type,
wa.usable_id,
wa.area_name,
wa.kandang_location_name,
wa.kandang_name,
wa.wrong_warehouse_id,
wa.wrong_warehouse_name,
wa.correct_warehouse_id,
wa.correct_warehouse_name,
rs.recording_id,
r.record_datetime,
m.so_number,
m.so_date
ORDER BY
wa.area_name ASC,
wa.kandang_location_name ASC,
wa.wrong_warehouse_name ASC,
wa.usable_type ASC,
wa.usable_id ASC
`, andClause(warehouseFilters), andClause(usageFilters))
rows := make([]usageRow, 0)
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadWarehouseMismatchRows(ctx context.Context, db *gorm.DB, opts *options) ([]warehouseMismatchRow, error) {
warehouseFilters, args := buildWarehouseFilters(opts)
query := fmt.Sprintf(`
SELECT
a.name AS area_name,
wl.name AS wrong_location_name,
kl.name AS kandang_location_name,
k.id AS kandang_id,
k.name AS kandang_name,
w.id AS wrong_warehouse_id,
w.name AS wrong_warehouse_name,
w.type AS wrong_warehouse_type,
correct_w.id AS correct_warehouse_id,
correct_w.name AS correct_warehouse_name
FROM warehouses w
JOIN kandangs k
ON k.id = w.kandang_id
AND k.deleted_at IS NULL
JOIN locations kl
ON kl.id = k.location_id
JOIN areas a
ON a.id = kl.area_id
LEFT JOIN locations wl
ON wl.id = w.location_id
JOIN LATERAL (
SELECT w2.id, w2.name
FROM warehouses w2
WHERE w2.kandang_id = w.kandang_id
AND w2.location_id = k.location_id
AND w2.deleted_at IS NULL
AND w2.id <> w.id
ORDER BY w2.id ASC
LIMIT 1
) AS correct_w ON TRUE
WHERE w.deleted_at IS NULL
AND w.kandang_id IS NOT NULL
AND w.location_id IS DISTINCT FROM k.location_id
%s
ORDER BY a.name ASC, kl.name ASC, k.name ASC, w.id ASC
`, andClause(warehouseFilters))
rows := make([]warehouseMismatchRow, 0)
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func buildWarehouseFilters(opts *options) ([]string, []any) {
filters := make([]string, 0, 4)
args := make([]any, 0, 4)
if opts == nil {
return filters, args
}
if opts.AreaName != "" {
filters = append(filters, "a.name = ?")
args = append(args, opts.AreaName)
}
if opts.KandangLocationName != "" {
filters = append(filters, "kl.name = ?")
args = append(args, opts.KandangLocationName)
}
if opts.WrongWarehouseName != "" {
filters = append(filters, "w.name = ?")
args = append(args, opts.WrongWarehouseName)
}
if opts.CorrectWarehouseName != "" {
filters = append(filters, "correct_w.name = ?")
args = append(args, opts.CorrectWarehouseName)
}
return filters, args
}
func andClause(filters []string) string {
if len(filters) == 0 {
return ""
}
return " AND " + strings.Join(filters, " AND ")
}
func renderUsageReport(mode string, rows []usageRow) {
if mode == outputModeJSON {
payload := map[string]any{
"report": reportUsage,
"rows": rows,
"summary": summarizeUsage(rows),
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "USABLE_TYPE\tUSABLE_ID\tAREA\tLOKASI\tKANDANG\tWRONG_WAREHOUSE\tCORRECT_WAREHOUSE\tPRODUCTS\tQTY_FROM_WRONG_STOCK\tRECORDING_ID\tRECORDING_DATE\tSO_NUMBER\tSO_DATE\tSOURCE_PURCHASES\tSOURCE_PURCHASE_ITEM_IDS")
for _, row := range rows {
fmt.Fprintf(
w,
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\t%s\n",
row.UsableType,
row.UsableID,
row.AreaName,
row.LokasiName,
row.KandangName,
row.WrongWarehouseName,
row.CorrectWarehouseName,
row.ProductNames,
row.QtyFromWrongStock,
displayOptionalUint(row.RecordingID),
displayOptionalString(row.RecordingDate),
displayOptionalString(row.SoNumber),
displayOptionalString(row.SoDate),
row.SourcePurchaseNumbers,
row.SourcePurchaseItemIDs,
)
}
_ = w.Flush()
s := summarizeUsage(rows)
fmt.Printf("\nSummary: rows=%d total_qty=%.3f\n", s.Rows, s.TotalQty)
}
func renderWarehouseReport(mode string, rows []warehouseMismatchRow) {
if mode == outputModeJSON {
payload := map[string]any{
"report": reportWarehouses,
"rows": rows,
"summary": summary{Rows: len(rows)},
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "AREA\tKANDANG_LOCATION\tKANDANG_ID\tKANDANG\tWRONG_LOCATION\tWRONG_WAREHOUSE_ID\tWRONG_WAREHOUSE\tWRONG_WAREHOUSE_TYPE\tCORRECT_WAREHOUSE_ID\tCORRECT_WAREHOUSE")
for _, row := range rows {
fmt.Fprintf(
w,
"%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\t%d\t%s\n",
row.AreaName,
row.KandangLocationName,
row.KandangID,
row.KandangName,
row.WrongLocationName,
row.WrongWarehouseID,
row.WrongWarehouseName,
row.WrongWarehouseType,
row.CorrectWarehouseID,
row.CorrectWarehouseName,
)
}
_ = w.Flush()
fmt.Printf("\nSummary: rows=%d\n", len(rows))
}
func summarizeUsage(rows []usageRow) summary {
out := summary{Rows: len(rows)}
for _, row := range rows {
out.TotalQty += row.QtyFromWrongStock
}
return out
}
func displayOptionalUint(value *uint) string {
if value == nil || *value == 0 {
return "-"
}
return fmt.Sprintf("%d", *value)
}
func displayOptionalString(value *string) string {
if value == nil || strings.TrimSpace(*value) == "" {
return "-"
}
return *value
}
File diff suppressed because it is too large Load Diff
+505
View File
@@ -0,0 +1,505 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"text/tabwriter"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
const (
outputTable = "table"
outputJSON = "json"
caseA = "A"
caseB = "B"
caseAll = "all"
)
type options struct {
Output string
AreaName string
KandangLocationName string
DBSSLMode string
VerifyCase string
}
type sourceWarehouseCheck struct {
AreaName string `json:"area_name"`
KandangLocationName string `json:"kandang_location_name"`
KandangID uint `json:"kandang_id"`
KandangName string `json:"kandang_name"`
SourceWarehouseID uint `json:"source_warehouse_id"`
SourceWarehouseName string `json:"source_warehouse_name"`
Case string `json:"case"`
DeletedAt *string `json:"deleted_at"`
StockInProductWH float64 `json:"stock_in_product_wh"`
ActivePurchaseItems int64 `json:"active_purchase_items"`
Status string `json:"status"`
}
type destinationWarehouseCheck struct {
AreaName string `json:"area_name"`
KandangLocationName string `json:"kandang_location_name"`
FarmWarehouseID uint `json:"farm_warehouse_id"`
FarmWarehouseName string `json:"farm_warehouse_name"`
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
CurrentQty float64 `json:"current_qty"`
StockLogsTotal float64 `json:"stock_logs_total"`
StockLogsCount int64 `json:"stock_logs_count"`
Status string `json:"status"`
}
type orphanedReferenceCheck struct {
Table string `json:"table"`
Column string `json:"column"`
ReferenceCount int64 `json:"reference_count"`
DeletedWarehouseIDs string `json:"deleted_warehouse_ids"`
}
type verificationSummary struct {
TotalSourceWarehouses int `json:"total_source_warehouses"`
CleanSourceWarehouses int `json:"clean_source_warehouses"`
DirtySourceWarehouses int `json:"dirty_source_warehouses"`
TotalDestinationWarehouses int `json:"total_destination_warehouses"`
MatchingDestinations int `json:"matching_destinations"`
DiscrepancyDestinations int `json:"discrepancy_destinations"`
TotalOrphanedReferences int64 `json:"total_orphaned_references"`
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)
// Verify source warehouses
sourceChecks, err := verifySourceWarehouses(ctx, db, opts)
if err != nil {
log.Fatalf("failed to verify source warehouses: %v", err)
}
// Verify destination warehouses
destChecks, err := verifyDestinationWarehouses(ctx, db, opts, sourceChecks)
if err != nil {
log.Fatalf("failed to verify destination warehouses: %v", err)
}
// Verify no orphaned references
orphanedRefs, err := verifyOrphanedReferences(ctx, db, sourceChecks)
if err != nil {
log.Fatalf("failed to verify orphaned references: %v", err)
}
// Render results
summary := buildSummary(sourceChecks, destChecks, orphanedRefs)
renderVerification(opts.Output, sourceChecks, destChecks, orphanedRefs, summary)
}
func parseFlags() (*options, error) {
var opts options
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter")
flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter")
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require")
flag.StringVar(&opts.VerifyCase, "verify-case", caseAll, "Verify specific case: A, B, or all")
flag.Parse()
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
opts.AreaName = strings.TrimSpace(opts.AreaName)
opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName)
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
opts.VerifyCase = strings.ToUpper(strings.TrimSpace(opts.VerifyCase))
if opts.Output == "" {
opts.Output = outputTable
}
if opts.Output != outputTable && opts.Output != outputJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.VerifyCase == "" {
opts.VerifyCase = caseAll
}
if opts.VerifyCase != caseA && opts.VerifyCase != caseB && opts.VerifyCase != caseAll {
return nil, fmt.Errorf("unsupported --verify-case=%s", opts.VerifyCase)
}
return &opts, nil
}
func verifySourceWarehouses(ctx context.Context, db *gorm.DB, opts *options) ([]sourceWarehouseCheck, error) {
filters := buildFilters(opts)
query := fmt.Sprintf(`
WITH case_a_warehouses AS (
-- Case A: Kandang-level warehouses (type != 'LOKASI' or NULL)
SELECT
w.id,
a.name AS area_name,
kl.name AS kandang_location_name,
k.id,
k.name,
'A'::text AS case_type
FROM warehouses w
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
JOIN locations kl ON kl.id = k.location_id
JOIN areas a ON a.id = kl.area_id
WHERE w.deleted_at IS NOT NULL
AND w.kandang_id IS NOT NULL
AND UPPER(COALESCE(w.type, '')) <> 'LOKASI'
),
case_b_warehouses AS (
-- Case B: Wrong-location warehouses (location_id != kandang.location_id)
SELECT
w.id,
a.name AS area_name,
kl.name AS kandang_location_name,
k.id,
k.name,
'B'::text AS case_type
FROM warehouses w
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
JOIN locations kl ON kl.id = k.location_id
JOIN areas a ON a.id = kl.area_id
WHERE w.deleted_at IS NOT NULL
AND w.kandang_id IS NOT NULL
AND w.location_id IS DISTINCT FROM k.location_id
),
all_source_warehouses AS (
SELECT id, area_name, kandang_location_name, id AS kandang_id, name, case_type FROM case_a_warehouses
UNION ALL
SELECT id, area_name, kandang_location_name, id AS kandang_id, name, case_type FROM case_b_warehouses
)
SELECT
asw.area_name,
asw.kandang_location_name,
asw.kandang_id,
asw.name AS kandang_name,
w.id AS source_warehouse_id,
w.name AS source_warehouse_name,
asw.case_type,
TO_CHAR(w.deleted_at, 'YYYY-MM-DD') AS deleted_at,
COALESCE(SUM(pw.qty), 0) AS stock_in_product_wh,
COUNT(DISTINCT pi.id) AS active_purchase_items
FROM all_source_warehouses asw
JOIN warehouses w ON w.id = asw.id
LEFT JOIN product_warehouses pw ON pw.warehouse_id = w.id
LEFT JOIN purchase_items pi ON pi.warehouse_id = w.id
WHERE true
%s
GROUP BY
asw.area_name,
asw.kandang_location_name,
asw.kandang_id,
asw.name,
w.id,
w.name,
asw.case_type,
w.deleted_at
ORDER BY asw.area_name ASC, asw.kandang_location_name ASC, w.name ASC
`, andClause(filters))
rows := make([]sourceWarehouseCheck, 0)
if err := db.WithContext(ctx).Raw(query).Scan(&rows).Error; err != nil {
return nil, err
}
// Determine status for each row
for i := range rows {
if rows[i].StockInProductWH == 0 && rows[i].ActivePurchaseItems == 0 {
rows[i].Status = "CLEAN"
} else {
rows[i].Status = "DIRTY"
}
// Filter by case if requested
if opts.VerifyCase != caseAll && rows[i].Case != opts.VerifyCase {
rows = append(rows[:i], rows[i+1:]...)
i--
}
}
return rows, nil
}
func verifyDestinationWarehouses(ctx context.Context, db *gorm.DB, opts *options, sourceChecks []sourceWarehouseCheck) ([]destinationWarehouseCheck, error) {
filters := buildFilters(opts)
query := fmt.Sprintf(`
SELECT
a.name AS area_name,
kl.name AS kandang_location_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(sl.stock), 0) AS stock_logs_total,
COUNT(DISTINCT sl.id) AS stock_logs_count
FROM warehouses fw
JOIN locations loc ON loc.id = fw.location_id
JOIN areas a ON a.id = loc.area_id
JOIN kandangs k ON k.location_id = fw.location_id AND k.deleted_at IS NULL
JOIN locations kl ON kl.id = k.location_id
JOIN products p ON UPPER(COALESCE(p.type, '')) IN ('PAKAN', 'OVK')
LEFT JOIN product_warehouses pw ON pw.warehouse_id = fw.id AND pw.product_id = p.id
LEFT JOIN stock_logs sl ON sl.product_warehouse_id = pw.id
WHERE fw.deleted_at IS NULL
AND UPPER(COALESCE(fw.type, '')) = 'LOKASI'
%s
GROUP BY
a.name,
kl.name,
fw.id,
fw.name,
p.id,
p.name,
pw.qty
ORDER BY a.name ASC, kl.name ASC, fw.name ASC, p.name ASC
`, andClause(filters))
rows := make([]destinationWarehouseCheck, 0)
if err := db.WithContext(ctx).Raw(query).Scan(&rows).Error; err != nil {
return nil, err
}
// Determine status: check if current_qty matches stock_logs
for i := range rows {
if rows[i].CurrentQty > 0 {
// Allow small floating point discrepancies
if abs(rows[i].CurrentQty-rows[i].StockLogsTotal) < 0.001 {
rows[i].Status = "MATCHED"
} else {
rows[i].Status = "DISCREPANCY"
}
} else {
rows[i].Status = "EMPTY"
}
}
return rows, nil
}
func verifyOrphanedReferences(ctx context.Context, db *gorm.DB, sourceChecks []sourceWarehouseCheck) ([]orphanedReferenceCheck, error) {
if len(sourceChecks) == 0 {
return []orphanedReferenceCheck{}, nil
}
// Get unique warehouse IDs from source checks
warehouseIDs := make([]uint, 0)
for _, check := range sourceChecks {
warehouseIDs = append(warehouseIDs, check.SourceWarehouseID)
}
// Check common references
var results []orphanedReferenceCheck
refChecks := []struct {
table string
column string
}{
{"purchase_items", "warehouse_id"},
{"stock_transfers", "from_warehouse_id"},
{"stock_transfers", "to_warehouse_id"},
{"fifo_stock_v2_operation_log", "warehouse_id"},
}
for _, ref := range refChecks {
var count int64
if err := db.Table(ref.table).
Where(fmt.Sprintf("%s IN ?", ref.column), warehouseIDs).
Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
// Get the specific warehouse IDs
var ids []uint
if err := db.Table(ref.table).
Where(fmt.Sprintf("%s IN ?", ref.column), warehouseIDs).
Pluck(ref.column, &ids).Error; err != nil {
return nil, err
}
idStrs := make([]string, len(ids))
for i, id := range ids {
idStrs[i] = fmt.Sprintf("%d", id)
}
results = append(results, orphanedReferenceCheck{
Table: ref.table,
Column: ref.column,
ReferenceCount: count,
DeletedWarehouseIDs: strings.Join(idStrs, ", "),
})
}
}
return results, nil
}
func buildFilters(opts *options) []string {
filters := make([]string, 0, 2)
if opts.AreaName != "" {
filters = append(filters, fmt.Sprintf("a.name = '%s'", opts.AreaName))
}
if opts.KandangLocationName != "" {
filters = append(filters, fmt.Sprintf("kl.name = '%s'", opts.KandangLocationName))
}
return filters
}
func andClause(filters []string) string {
if len(filters) == 0 {
return ""
}
return " AND " + strings.Join(filters, " AND ")
}
func buildSummary(sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck) verificationSummary {
summary := verificationSummary{
TotalSourceWarehouses: len(sourceChecks),
OverallStatus: "PASS",
}
for _, check := range sourceChecks {
if check.Status == "CLEAN" {
summary.CleanSourceWarehouses++
} else {
summary.DirtySourceWarehouses++
summary.OverallStatus = "FAIL"
}
}
summary.TotalDestinationWarehouses = len(destChecks)
for _, check := range destChecks {
if check.Status == "MATCHED" || check.Status == "EMPTY" {
summary.MatchingDestinations++
} else if check.Status == "DISCREPANCY" {
summary.DiscrepancyDestinations++
summary.OverallStatus = "FAIL"
}
}
for _, ref := range orphanedRefs {
summary.TotalOrphanedReferences += ref.ReferenceCount
summary.OverallStatus = "FAIL"
}
return summary
}
func renderVerification(mode string, sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck, summary verificationSummary) {
if mode == outputJSON {
payload := map[string]any{
"source_warehouses": sourceChecks,
"destination_warehouses": destChecks,
"orphaned_references": orphanedRefs,
"summary": summary,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
// Table mode
fmt.Println("\n=== SOURCE WAREHOUSES VERIFICATION ===")
if len(sourceChecks) == 0 {
fmt.Println("No deleted warehouses found")
} else {
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "AREA\tLOKASI\tKANDANG\tWAREHOUSE\tCASE\tDELETED_AT\tSTOCK_IN_PW\tPURCHASE_ITEMS\tSTATUS")
for _, check := range sourceChecks {
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%d\t%s\n",
check.AreaName,
check.KandangLocationName,
check.KandangName,
check.SourceWarehouseName,
check.Case,
displayOptionalString(check.DeletedAt),
check.StockInProductWH,
check.ActivePurchaseItems,
check.Status,
)
}
_ = w.Flush()
}
fmt.Println("\n=== DESTINATION WAREHOUSES VERIFICATION ===")
if len(destChecks) == 0 {
fmt.Println("No destination warehouses found")
} else {
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "AREA\tLOKASI\tFARM_WAREHOUSE\tPRODUCT\tCURRENT_QTY\tSTOCK_LOGS_TOTAL\tLOGS_COUNT\tSTATUS")
for _, check := range destChecks {
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%.3f\t%.3f\t%d\t%s\n",
check.AreaName,
check.KandangLocationName,
check.FarmWarehouseName,
check.ProductName,
check.CurrentQty,
check.StockLogsTotal,
check.StockLogsCount,
check.Status,
)
}
_ = w.Flush()
}
if len(orphanedRefs) > 0 {
fmt.Println("\n=== ORPHANED REFERENCES (ERRORS) ===")
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "TABLE\tCOLUMN\tCOUNT\tWAREHOUSE_IDS")
for _, ref := range orphanedRefs {
fmt.Fprintf(
w,
"%s\t%s\t%d\t%s\n",
ref.Table,
ref.Column,
ref.ReferenceCount,
ref.DeletedWarehouseIDs,
)
}
_ = w.Flush()
}
fmt.Printf("\n=== SUMMARY ===\n")
fmt.Printf("Source Warehouses: %d total, %d clean, %d dirty\n", summary.TotalSourceWarehouses, summary.CleanSourceWarehouses, summary.DirtySourceWarehouses)
fmt.Printf("Destination Warehouses: %d total, %d matching, %d discrepancies\n", summary.TotalDestinationWarehouses, summary.MatchingDestinations, summary.DiscrepancyDestinations)
fmt.Printf("Orphaned References: %d\n", summary.TotalOrphanedReferences)
fmt.Printf("Overall Status: %s\n", summary.OverallStatus)
}
func displayOptionalString(value *string) string {
if value == nil || strings.TrimSpace(*value) == "" {
return "-"
}
return *value
}
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
+460
View File
@@ -0,0 +1,460 @@
# Stock Consolidation Operations Guide
This guide explains how to use the warehouse consolidation commands to fix misplaced PAKAN/OVK stocks and migrate them to the correct farm-level warehouses.
## Overview
The stock consolidation system handles two main scenarios:
| Case | Scenario | Root Cause | Solution |
|------|----------|-----------|----------|
| **Case B** | Invalid kandang references | Purchases pointed to warehouses with location mismatch | Move unused stocks to correct farm-level warehouse |
| **Case A** | General kandang cleanup | Any kandang-level warehouse with unused PAKAN/OVK stocks | Consolidate to farm-level warehouse |
## Recommended Execution Order
For a complete stock consolidation operation, follow this sequence:
```
1. find-wrong-warehouse-records ← Diagnose issues
2. repoint-wrong-warehouse-relations ← Fix Case B (invalid references)
3. consolidate-kandang-to-farm-stocks ← Fix Case A (general cleanup)
4. verify-stock-consolidation ← Audit and verify results
```
---
## Command Reference
### 1. `find-wrong-warehouse-records` — Diagnostic Tool
**Purpose:** Identify problematic warehouses and their associated stocks before making any changes.
**Applies to:** Both Case A and Case B scenarios
**What it does:**
- Lists warehouses with location mismatches (Case B)
- Shows stock allocations that reference wrong warehouses
- Helps identify scope of work needed
#### Usage:
```bash
# Report 1: Find warehouses with location mismatches (Case B issues)
./find-wrong-warehouse-records --report=warehouses
# Report 2: Find stock allocations in wrong warehouses (Case B impact)
./find-wrong-warehouse-records --report=usage
# Filter by area
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
# Filter by kandang location
./find-wrong-warehouse-records --report=usage --kandang-location-name "Location 1"
# Filter by product type
./find-wrong-warehouse-records --report=usage --usable-type=RECORDING_STOCK
# JSON output for analysis
./find-wrong-warehouse-records --report=usage --output=json > analysis.json
```
#### Output Columns (Warehouses Report):
- **AREA**: Geographic area
- **KANDANG_LOCATION**: Kandang's intended location
- **KANDANG**: Kandang name
- **WRONG_LOCATION**: Where the warehouse actually is
- **WRONG_WAREHOUSE**: Problematic warehouse name
- **CORRECT_WAREHOUSE**: Where stocks should be
#### Output Columns (Usage Report):
- **USABLE_TYPE**: RECORDING_STOCK or MARKETING_DELIVERY
- **PRODUCTS**: Which products are affected
- **QTY_FROM_WRONG_STOCK**: How much stock is misplaced
- **SOURCE_PURCHASES**: Which purchase orders are affected
**When to use:**
- Before starting any consolidation
- To understand the scope of issues
- To get metrics on how much stock needs moving
- To identify which areas are most affected
---
### 2. `repoint-wrong-warehouse-relations` — Fix Case B (Invalid References)
**Purpose:** Fix purchases pointed to invalid kandang warehouses (location mismatch).
**Applies to:** Case B only
**Cases it handles:**
- ✅ Warehouses with `location_id ≠ kandang.location_id` (location mismatch)
- ✅ Only PAKAN/OVK products
- ✅ Only unused/leftover stocks (no active allocations)
- ✅ Moves to farm-level warehouse at correct location
**What it does:**
1. Finds product_warehouses in wrong locations
2. Consolidates duplicates into survivor warehouses
3. Updates all references across the system
4. Recalculates FIFO stocks if needed
5. Optionally soft-deletes the wrong warehouse
#### Usage:
```bash
# Dry-run: See what would be moved (always run first!)
./repoint-wrong-warehouse-relations
# Dry-run with specific filters
./repoint-wrong-warehouse-relations --area-name "East Region"
./repoint-wrong-warehouse-relations --kandang-location-name "Location 1"
# Actually apply the migration
./repoint-wrong-warehouse-relations --apply
# Apply but keep the wrong warehouses (for audit trail)
./repoint-wrong-warehouse-relations --apply --delete-wrong-warehouses=false
# JSON output for automation/logging
./repoint-wrong-warehouse-relations --apply --output=json > migration.json
```
#### Flags:
- `--apply`: Apply changes (omit for dry-run)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--delete-wrong-warehouses`: Soft-delete wrong warehouses (default: true)
- `--db-sslmode`: PostgreSQL SSL mode override (e.g., `require`)
#### Output:
**Table mode shows:**
- AREA, LOCATION, KANDANG: Where the issue is
- WRONG_WAREHOUSE: Source (will be deleted)
- TARGET_WAREHOUSE: Destination (farm-level)
- PRODUCT: What's being moved
- SURVIVOR_PW / ABSORBED_PW: Consolidation details
- NEEDS_REFLOW: Whether FIFO recalculation is needed
**Summary shows:**
```
Summary: plan_rows=15 wrong_warehouses=3 survivor_pws=12 absorbed_pws=5
needs_reflow_pws=3 deleted_product_warehouses=5 soft_deleted_warehouses=3
Updated product_warehouse refs:
fifo_stock_v2_operation_log.product_warehouse_id=8
fifo_stock_v2_reflow_checkpoints.product_warehouse_id=3
purchase_items.warehouse_id=12
Updated warehouse refs:
purchase_items.warehouse_id=12
```
#### Safety Features:
- **Dry-run first**: Always preview before applying
- **Prechecks**: Verifies no blocked references or FIFO conflicts
- **Atomic transactions**: All-or-nothing database updates
- **Reference verification**: Confirms all references were updated
- **Stock log recalculation**: Ensures FIFO accuracy after moves
---
### 3. `consolidate-kandang-to-farm-stocks` — Fix Case A (General Cleanup)
**Purpose:** Consolidate ALL kandang-level PAKAN/OVK stocks to farm-level warehouse.
**Applies to:** Case A only
**Cases it handles:**
- ✅ ALL kandang-level warehouses (type ≠ 'LOKASI')
- ✅ Only PAKAN/OVK products
- ✅ Only unused/leftover stocks (no active allocations)
- ✅ Moves to farm-level warehouse regardless of warehouse validity
- ✅ No location validation (processes all kandang warehouses)
**What it does:**
1. Finds all kandang-level warehouses with unused stocks
2. Consolidates duplicates into survivor warehouses
3. Updates all references across the system
4. Recalculates FIFO stocks if needed
5. Optionally soft-deletes the kandang warehouse
#### Usage:
```bash
# Dry-run: See what would be consolidated
./consolidate-kandang-to-farm-stocks
# Dry-run with filters
./consolidate-kandang-to-farm-stocks --area-name "East Region"
./consolidate-kandang-to-farm-stocks --kandang-location-name "Location 1"
# Actually apply the consolidation
./consolidate-kandang-to-farm-stocks --apply
# Apply but keep kandang warehouses
./consolidate-kandang-to-farm-stocks --apply --delete-kandang-warehouses=false
# JSON output for logging
./consolidate-kandang-to-farm-stocks --apply --output=json > consolidation.json
```
#### Flags:
- `--apply`: Apply changes (omit for dry-run)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--delete-kandang-warehouses`: Soft-delete kandang warehouses (default: true)
- `--db-sslmode`: PostgreSQL SSL mode override
#### Output Format:
Similar to Case B, shows:
- Source kandang warehouse → Destination farm warehouse
- Product and quantity details
- Consolidation and FIFO reflow information
#### Key Differences from Case B:
| Aspect | Case B | Case A |
|--------|--------|--------|
| Scope | Wrong-location warehouses only | ALL kandang-level warehouses |
| Validation | Checks location mismatch | No validation checks |
| When to use | After finding mismatches | General cleanup/consolidation |
| Risk level | Lower (targeted fix) | Higher (broader scope) |
---
### 4. `verify-stock-consolidation` — Audit and Verify
**Purpose:** Verify that stock consolidations were successful and no stocks were lost.
**Applies to:** Both Case A and Case B (post-migration verification)
**What it checks:**
#### ✅ Source Warehouse Verification
Ensures deleted warehouses are clean:
- **CLEAN**: No remaining stock or purchase references
- **DIRTY**: Still has orphaned data (migration incomplete)
#### ✅ Destination Warehouse Verification
Ensures farm-level warehouses received stocks correctly:
- **MATCHED**: Quantity in product_warehouse matches stock_logs
- **DISCREPANCY**: Quantity mismatch (data integrity issue!)
- **EMPTY**: No stocks (correct if nothing was supposed to move)
#### ✅ Orphaned Reference Detection
Finds any remaining references to deleted warehouses in:
- `purchase_items.warehouse_id`
- `stock_transfers.from/to_warehouse_id`
- `fifo_stock_v2_operation_log.warehouse_id`
#### Usage:
```bash
# Verify all consolidations (Case A + B together)
./verify-stock-consolidation
# Verify only Case B results
./verify-stock-consolidation --verify-case=B
# Verify only Case A results
./verify-stock-consolidation --verify-case=A
# Filter by area
./verify-stock-consolidation --area-name "East Region"
# Filter by location
./verify-stock-consolidation --kandang-location-name "Location 1"
# JSON output for reporting
./verify-stock-consolidation --output=json > verification_report.json
```
#### Flags:
- `--verify-case`: `A`, `B`, or `all` (default)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--db-sslmode`: PostgreSQL SSL mode override
#### Output Sections:
**1. Source Warehouses**
```
AREA LOKASI KANDANG WAREHOUSE CASE DELETED_AT STOCK PURCHASES STATUS
Area A Location 1 Kandang A KWH-A-01 A 2026-04-23 0.000 0 CLEAN
Area A Location 1 Kandang B WH-WRONG-001 B 2026-04-23 2.500 1 DIRTY ❌
```
**2. Destination Warehouses**
```
AREA LOKASI FARM_WAREHOUSE PRODUCT QTY LOGS_TOTAL LOGS STATUS
Area A Location 1 FWH-LOC-001 PAKAN A 2.500 2.500 3 MATCHED ✅
Area A Location 1 FWH-LOC-001 OVK B 5.000 4.999 5 DISCREPANCY ❌
```
**3. Orphaned References** (if any)
```
TABLE COLUMN COUNT WAREHOUSE_IDS
purchase_items warehouse_id 3 1001, 1002, 1003
stock_transfers from_warehouse_id 1 1001
```
**4. Summary**
```
Source Warehouses: 10 total, 8 clean, 2 dirty
Destination Warehouses: 15 total, 14 matching, 1 discrepancy
Orphaned References: 4
Overall Status: FAIL ❌
```
#### Interpreting Results:
| Scenario | Meaning | Action |
|----------|---------|--------|
| ✅ Overall Status: PASS | All migrations successful | No action needed |
| ❌ Dirty Source Warehouses | Stocks not fully moved | Re-run repoint/consolidate |
| ❌ Discrepancy Destinations | Quantity mismatch | Investigate data integrity |
| ❌ Orphaned References | Broken references remain | Manual cleanup needed |
---
## Complete Workflow Example
### Scenario: Consolidate East Region stocks
```bash
# Step 1: Understand the scope (Case B issues)
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
./find-wrong-warehouse-records --report=usage --area-name "East Region"
# Review the output to understand:
# - How many wrong warehouses
# - How much stock needs moving
# - Which products are affected
# Step 2: Fix Case B (invalid kandang references)
./repoint-wrong-warehouse-relations --area-name "East Region"
# Review dry-run output
./repoint-wrong-warehouse-relations --apply --area-name "East Region"
# Watch for summary - should show successful updates
# Step 3: Fix Case A (general kandang cleanup)
./consolidate-kandang-to-farm-stocks --area-name "East Region"
# Review dry-run output
./consolidate-kandang-to-farm-stocks --apply --area-name "East Region"
# Watch for summary - should show consolidation complete
# Step 4: Verify everything worked
./verify-stock-consolidation --area-name "East Region"
# Should show:
# - All source warehouses: CLEAN
# - All destination warehouses: MATCHED
# - Orphaned references: 0
# - Overall Status: PASS ✅
```
---
## Flags Reference
### Common Flags (All Commands)
| Flag | Description | Example |
|------|-------------|---------|
| `--output` | Output format | `--output=json` |
| `--area-name` | Filter by area | `--area-name "East Region"` |
| `--kandang-location-name` | Filter by location | `--kandang-location-name "Location 1"` |
| `--db-sslmode` | PostgreSQL SSL mode | `--db-sslmode=require` |
### Migration-Specific Flags
| Command | Flag | Description |
|---------|------|-------------|
| `repoint-wrong-warehouse-relations` | `--apply` | Apply changes |
| `repoint-wrong-warehouse-relations` | `--delete-wrong-warehouses` | Delete wrong warehouses (default: true) |
| `consolidate-kandang-to-farm-stocks` | `--apply` | Apply changes |
| `consolidate-kandang-to-farm-stocks` | `--delete-kandang-warehouses` | Delete kandang warehouses (default: true) |
| `verify-stock-consolidation` | `--verify-case` | Verify specific case (A, B, or all) |
---
## Best Practices
### Before Running Any Command
1. **Back up the database** — These operations modify stock data
2. **Run in dry-run mode first** — Always preview changes before applying
3. **Check during low-traffic periods** — Avoid peak hours
4. **Have a rollback plan** — Know how to restore from backup if needed
### When Running Migrations
1. **Start small** — Use `--area-name` to test on one area first
2. **Check the summary** — Verify numbers make sense
3. **Watch for errors** — Stop if you see unexpected error messages
4. **Run verification immediately after** — Don't wait to verify
### Red Flags (Stop and Investigate)
- ❌ More rows affected than expected
- ❌ Negative quantities or zero counts where expecting data
- ❌ Errors about blocked references
- ❌ FIFO conflicts or in-flight artifacts
- ❌ Very large numbers in NEEDS_REFLOW
### JSON Output for Automation
All commands support `--output=json` for:
- Piping to other tools
- Parsing in scripts
- Generating reports
- Integration with monitoring systems
```bash
# Example: Extract all affected warehouses to CSV
./find-wrong-warehouse-records --report=warehouses --output=json \
| jq -r '.rows[] | [.area_name, .kandang_name, .wrong_warehouse_name] | @csv' \
> affected_warehouses.csv
```
---
## Troubleshooting
### Issue: "No wrong warehouse relations found"
- **Cause**: No matching Case B issues in the filter scope
- **Solution**: Remove filters or use different criteria
### Issue: "found X rows still point to wrong warehouses"
- **Cause**: References not fully migrated
- **Solution**: Check for blocked references, re-run command
### Issue: "discrepancy_destinations > 0" in verification
- **Cause**: Quantity mismatch in farm warehouse
- **Solution**: Investigate manually or rollback and retry
### Issue: "DIRTY source warehouses" in verification
- **Cause**: Deleted warehouses still have stock/references
- **Solution**: May need manual cleanup or re-run migrations
---
## Performance Notes
- Commands use efficient SQL queries with proper filtering
- Large operations (100K+ rows) may take a few minutes
- Use area/location filters to reduce scope for testing
- Dry-runs don't modify database and complete quickly
## Support
For issues or questions:
1. Review the relevant section of this guide
2. Check the command output for specific error messages
3. Run verification to diagnose state issues
4. Contact the development team with JSON outputs from failed operations
+604
View File
@@ -0,0 +1,604 @@
package exportprogress
import (
"fmt"
"sort"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const (
UnassignedKandangName = "Farm-level / Unassigned"
jakartaTZ = "Asia/Jakarta"
)
type Query struct {
StartDate time.Time
EndDate time.Time
StartDateRaw string
EndDateRaw string
}
type Row struct {
Module string
FarmName string
KandangName string
ActivityDate time.Time
Count int
}
type monthBlock struct {
Start time.Time
Weeks int
}
func IsProgressExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "progress")
}
func ParseQuery(c *fiber.Ctx) (*Query, error) {
location, err := time.LoadLocation(jakartaTZ)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
}
startRaw := strings.TrimSpace(c.Query("start_date"))
endRaw := strings.TrimSpace(c.Query("end_date"))
if startRaw == "" || endRaw == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "start_date and end_date are required")
}
startDate, err := time.ParseInLocation("2006-01-02", startRaw, location)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "start_date must use format YYYY-MM-DD")
}
endDate, err := time.ParseInLocation("2006-01-02", endRaw, location)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "end_date must use format YYYY-MM-DD")
}
if endDate.Before(startDate) {
return nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date")
}
return &Query{
StartDate: startDate,
EndDate: endDate,
StartDateRaw: startRaw,
EndDateRaw: endRaw,
}, nil
}
func BuildWorkbook(moduleTitle string, query *Query, rows []Row) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
sheetName := moduleTitle
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
location, err := time.LoadLocation(jakartaTZ)
if err != nil {
return nil, err
}
titleStyle, metaStyle, monthStyle, weekStyle, dayHeaderStyle, farmStyle, textStyle, numberStyle, subtotalStyle, err := buildStyles(file)
if err != nil {
return nil, err
}
months := monthBlocksBetween(query.StartDate, query.EndDate)
maxWeeks := 4
for _, block := range months {
if block.Weeks > maxWeeks {
maxWeeks = block.Weeks
}
}
lastColName, err := excelize.ColumnNumberToName(1 + (maxWeeks * 7) + 1)
if err != nil {
return nil, err
}
if err := file.MergeCell(sheetName, "A1", lastColName+"1"); err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, "A1", moduleTitle); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A1", lastColName+"1", titleStyle); err != nil {
return nil, err
}
metaValue := fmt.Sprintf(
"Range: %s to %s | Generated at: %s",
query.StartDateRaw,
query.EndDateRaw,
time.Now().In(location).Format("2006-01-02 15:04:05 MST"),
)
if err := file.MergeCell(sheetName, "A2", lastColName+"2"); err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, "A2", metaValue); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A2", lastColName+"2", metaStyle); err != nil {
return nil, err
}
if err := applyColumnWidths(file, sheetName, maxWeeks); err != nil {
return nil, err
}
grouped := groupRows(rows)
currentRow := 4
for _, month := range months {
lastColIndex := 1 + (month.Weeks * 7) + 1
monthLastCol, err := excelize.ColumnNumberToName(lastColIndex)
if err != nil {
return nil, err
}
if err := renderMonthHeader(file, sheetName, currentRow, month, monthLastCol, monthStyle, weekStyle, dayHeaderStyle); err != nil {
return nil, err
}
currentRow += 4
monthData := grouped[month.Start.Format("2006-01")]
if len(monthData) == 0 {
if err := file.MergeCell(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow)); err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), "No progress data"); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), textStyle); err != nil {
return nil, err
}
currentRow += 2
continue
}
farms := sortedKeys(monthData)
for _, farm := range farms {
if err := file.MergeCell(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow)); err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), farm); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), farmStyle); err != nil {
return nil, err
}
currentRow++
kandangs := sortedKeys(monthData[farm])
farmTotals := make(map[string]int)
farmGrandTotal := 0
for _, kandang := range kandangs {
rowCounts := monthData[farm][kandang]
rowTotal := 0
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), kandang); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), "A"+fmt.Sprint(currentRow), textStyle); err != nil {
return nil, err
}
for dayKey, count := range rowCounts {
activityDate, err := time.ParseInLocation("2006-01-02", dayKey, location)
if err != nil {
return nil, err
}
colIndex := dayColumnIndex(month, activityDate)
colName, err := excelize.ColumnNumberToName(colIndex)
if err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, colName+fmt.Sprint(currentRow), count); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, colName+fmt.Sprint(currentRow), colName+fmt.Sprint(currentRow), numberStyle); err != nil {
return nil, err
}
rowTotal += count
farmTotals[dayKey] += count
farmGrandTotal += count
}
if err := file.SetCellValue(sheetName, monthLastCol+fmt.Sprint(currentRow), rowTotal); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, monthLastCol+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), subtotalStyle); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "B"+fmt.Sprint(currentRow), prevColumn(monthLastCol)+fmt.Sprint(currentRow), numberStyle); err != nil {
return nil, err
}
currentRow++
}
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), "Subtotal"); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), "A"+fmt.Sprint(currentRow), subtotalStyle); err != nil {
return nil, err
}
for dayKey, count := range farmTotals {
activityDate, err := time.ParseInLocation("2006-01-02", dayKey, location)
if err != nil {
return nil, err
}
colIndex := dayColumnIndex(month, activityDate)
colName, err := excelize.ColumnNumberToName(colIndex)
if err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, colName+fmt.Sprint(currentRow), count); err != nil {
return nil, err
}
}
if err := file.SetCellValue(sheetName, monthLastCol+fmt.Sprint(currentRow), farmGrandTotal); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), subtotalStyle); err != nil {
return nil, err
}
currentRow += 2
}
}
if err := file.SetPanes(sheetName, &excelize.Panes{
Freeze: true,
YSplit: 2,
TopLeftCell: "A3",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func ParseActivityDate(value string) (time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}, fmt.Errorf("empty activity date")
}
layouts := []string{
"2006-01-02",
time.RFC3339,
time.RFC3339Nano,
"2006-01-02 15:04:05Z07:00",
"2006-01-02 15:04:05.999999999Z07:00",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, trimmed); err == nil {
return parsed, nil
}
}
if len(trimmed) >= len("2006-01-02") {
if parsed, err := time.Parse("2006-01-02", trimmed[:10]); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("unsupported activity date format: %s", value)
}
func buildStyles(file *excelize.File) (int, int, int, int, int, int, int, int, int, error) {
titleStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 18, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
metaStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Italic: true, Color: "4B5563"},
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
monthStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"1D4ED8"}},
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
weekStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DBEAFE"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{{Type: "bottom", Color: "93C5FD", Style: 1}},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
dayHeaderStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "374151"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"EFF6FF"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "BFDBFE", Style: 1},
{Type: "top", Color: "BFDBFE", Style: 1},
{Type: "bottom", Color: "BFDBFE", Style: 1},
{Type: "right", Color: "BFDBFE", Style: 1},
},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
farmStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "111827"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E5E7EB"}},
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
textStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "left", 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 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
numberStyle, err := file.NewStyle(&excelize.Style{
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 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
subtotalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"F3F4F6"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "9CA3AF", Style: 1},
{Type: "top", Color: "9CA3AF", Style: 1},
{Type: "bottom", Color: "9CA3AF", Style: 1},
{Type: "right", Color: "9CA3AF", Style: 1},
},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
return titleStyle, metaStyle, monthStyle, weekStyle, dayHeaderStyle, farmStyle, textStyle, numberStyle, subtotalStyle, nil
}
func applyColumnWidths(file *excelize.File, sheet string, maxWeeks int) error {
if err := file.SetColWidth(sheet, "A", "A", 28); err != nil {
return err
}
for col := 2; col <= 1+(maxWeeks*7); col++ {
colName, err := excelize.ColumnNumberToName(col)
if err != nil {
return err
}
if err := file.SetColWidth(sheet, colName, colName, 6); err != nil {
return err
}
}
totalCol, err := excelize.ColumnNumberToName(1 + (maxWeeks * 7) + 1)
if err != nil {
return err
}
return file.SetColWidth(sheet, totalCol, totalCol, 10)
}
func renderMonthHeader(file *excelize.File, sheet string, startRow int, block monthBlock, monthLastCol string, monthStyle, weekStyle, dayHeaderStyle int) error {
if err := file.MergeCell(sheet, "A"+fmt.Sprint(startRow), monthLastCol+fmt.Sprint(startRow)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+fmt.Sprint(startRow), block.Start.Format("January 2006")); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+fmt.Sprint(startRow), monthLastCol+fmt.Sprint(startRow), monthStyle); err != nil {
return err
}
if err := file.MergeCell(sheet, "A"+fmt.Sprint(startRow+1), "A"+fmt.Sprint(startRow+3)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+fmt.Sprint(startRow+1), "Kandang"); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+fmt.Sprint(startRow+1), "A"+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
return err
}
totalColIndex := 1 + (block.Weeks * 7) + 1
totalColName, err := excelize.ColumnNumberToName(totalColIndex)
if err != nil {
return err
}
if err := file.MergeCell(sheet, totalColName+fmt.Sprint(startRow+1), totalColName+fmt.Sprint(startRow+3)); err != nil {
return err
}
if err := file.SetCellValue(sheet, totalColName+fmt.Sprint(startRow+1), "Total"); err != nil {
return err
}
if err := file.SetCellStyle(sheet, totalColName+fmt.Sprint(startRow+1), totalColName+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
return err
}
weekdayNames := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
for week := 0; week < block.Weeks; week++ {
startCol := 2 + (week * 7)
endCol := startCol + 6
startColName, err := excelize.ColumnNumberToName(startCol)
if err != nil {
return err
}
endColName, err := excelize.ColumnNumberToName(endCol)
if err != nil {
return err
}
if err := file.MergeCell(sheet, startColName+fmt.Sprint(startRow+1), endColName+fmt.Sprint(startRow+1)); err != nil {
return err
}
if err := file.SetCellValue(sheet, startColName+fmt.Sprint(startRow+1), fmt.Sprintf("Week %d", week+1)); err != nil {
return err
}
if err := file.SetCellStyle(sheet, startColName+fmt.Sprint(startRow+1), endColName+fmt.Sprint(startRow+1), weekStyle); err != nil {
return err
}
for weekday := 0; weekday < 7; weekday++ {
colIndex := startCol + weekday
colName, err := excelize.ColumnNumberToName(colIndex)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+fmt.Sprint(startRow+2), weekdayNames[weekday]); err != nil {
return err
}
if err := file.SetCellStyle(sheet, colName+fmt.Sprint(startRow+2), colName+fmt.Sprint(startRow+2), dayHeaderStyle); err != nil {
return err
}
}
}
daysInMonth := time.Date(block.Start.Year(), block.Start.Month()+1, 0, 0, 0, 0, 0, block.Start.Location()).Day()
for day := 1; day <= daysInMonth; day++ {
date := time.Date(block.Start.Year(), block.Start.Month(), day, 0, 0, 0, 0, block.Start.Location())
colIndex := dayColumnIndex(block, date)
colName, err := excelize.ColumnNumberToName(colIndex)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+fmt.Sprint(startRow+3), day); err != nil {
return err
}
if err := file.SetCellStyle(sheet, colName+fmt.Sprint(startRow+3), colName+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
return err
}
}
return nil
}
func groupRows(rows []Row) map[string]map[string]map[string]map[string]int {
grouped := make(map[string]map[string]map[string]map[string]int)
for _, row := range rows {
monthKey := row.ActivityDate.Format("2006-01")
if _, exists := grouped[monthKey]; !exists {
grouped[monthKey] = make(map[string]map[string]map[string]int)
}
farmName := strings.TrimSpace(row.FarmName)
if farmName == "" {
farmName = "Unknown Farm"
}
if _, exists := grouped[monthKey][farmName]; !exists {
grouped[monthKey][farmName] = make(map[string]map[string]int)
}
kandangName := strings.TrimSpace(row.KandangName)
if kandangName == "" {
kandangName = UnassignedKandangName
}
if _, exists := grouped[monthKey][farmName][kandangName]; !exists {
grouped[monthKey][farmName][kandangName] = make(map[string]int)
}
dayKey := row.ActivityDate.Format("2006-01-02")
grouped[monthKey][farmName][kandangName][dayKey] += row.Count
}
return grouped
}
func monthBlocksBetween(startDate, endDate time.Time) []monthBlock {
location := startDate.Location()
current := time.Date(startDate.Year(), startDate.Month(), 1, 0, 0, 0, 0, location)
last := time.Date(endDate.Year(), endDate.Month(), 1, 0, 0, 0, 0, location)
blocks := make([]monthBlock, 0)
for !current.After(last) {
blocks = append(blocks, monthBlock{
Start: current,
Weeks: monthWeeks(current),
})
current = current.AddDate(0, 1, 0)
}
return blocks
}
func monthWeeks(monthStart time.Time) int {
daysInMonth := time.Date(monthStart.Year(), monthStart.Month()+1, 0, 0, 0, 0, 0, monthStart.Location()).Day()
offset := mondayIndex(monthStart.Weekday())
totalSlots := offset + daysInMonth
weeks := totalSlots / 7
if totalSlots%7 != 0 {
weeks++
}
if weeks < 4 {
return 4
}
return weeks
}
func dayColumnIndex(block monthBlock, date time.Time) int {
day := date.Day()
offset := mondayIndex(block.Start.Weekday())
position := offset + (day - 1)
return 2 + position
}
func mondayIndex(weekday time.Weekday) int {
switch weekday {
case time.Sunday:
return 6
default:
return int(weekday) - 1
}
}
func sortedKeys[V any](input map[string]V) []string {
keys := make([]string, 0, len(input))
for key := range input {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func prevColumn(col string) string {
index, err := excelize.ColumnNameToNumber(col)
if err != nil || index <= 1 {
return col
}
result, err := excelize.ColumnNumberToName(index - 1)
if err != nil {
return col
}
return result
}
@@ -0,0 +1,126 @@
package exportprogress
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
func TestParseQuery(t *testing.T) {
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
query, err := ParseQuery(c)
if err != nil {
return err
}
return c.JSON(fiber.Map{
"start": query.StartDateRaw,
"end": query.EndDateRaw,
})
})
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/?export=excel&type=progress&start_date=2026-06-01&end_date=2026-07-15", nil))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var payload map[string]string
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("failed decoding payload: %v", err)
}
if payload["start"] != "2026-06-01" || payload["end"] != "2026-07-15" {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestParseQueryInvalid(t *testing.T) {
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
_, err := ParseQuery(c)
return err
})
cases := []string{
"/?export=excel&type=progress",
"/?export=excel&type=progress&start_date=2026-06-01&end_date=bad",
"/?export=excel&type=progress&start_date=2026-07-01&end_date=2026-06-01",
}
for _, target := range cases {
resp, err := app.Test(httptest.NewRequest(http.MethodGet, target, nil))
if err != nil {
t.Fatalf("request failed for %s: %v", target, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for %s, got %d", target, resp.StatusCode)
}
}
}
func TestBuildWorkbook(t *testing.T) {
location, err := time.LoadLocation(jakartaTZ)
if err != nil {
t.Fatalf("failed loading location: %v", err)
}
query := &Query{
StartDate: time.Date(2026, 6, 1, 0, 0, 0, 0, location),
EndDate: time.Date(2026, 7, 31, 0, 0, 0, 0, location),
StartDateRaw: "2026-06-01",
EndDateRaw: "2026-07-31",
}
rows := []Row{
{Module: "Expenses", FarmName: "Farm A", KandangName: "Kandang 1", ActivityDate: time.Date(2026, 6, 1, 0, 0, 0, 0, location), Count: 3},
{Module: "Expenses", FarmName: "Farm A", KandangName: "Kandang 1", ActivityDate: time.Date(2026, 7, 15, 0, 0, 0, 0, location), Count: 2},
}
content, err := BuildWorkbook("Expenses", query, rows)
if err != nil {
t.Fatalf("BuildWorkbook failed: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed opening workbook: %v", err)
}
defer file.Close()
if got := file.GetSheetName(file.GetActiveSheetIndex()); got != "Expenses" {
t.Fatalf("unexpected sheet name: %s", got)
}
title, err := file.GetCellValue("Expenses", "A1")
if err != nil {
t.Fatalf("failed reading title: %v", err)
}
if title != "Expenses" {
t.Fatalf("unexpected title: %s", title)
}
monthTitle, err := file.GetCellValue("Expenses", "A4")
if err != nil {
t.Fatalf("failed reading first month title: %v", err)
}
if monthTitle != "June 2026" {
t.Fatalf("unexpected first month title: %s", monthTitle)
}
firstCount, err := file.GetCellValue("Expenses", "B9")
if err != nil {
t.Fatalf("failed reading representative count cell: %v", err)
}
if firstCount != "3" {
t.Fatalf("unexpected representative count: %s", firstCount)
}
}
@@ -151,7 +151,7 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
). ).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan). Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error Scan(&total).Error
@@ -202,7 +202,7 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
). ).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error Scan(&total).Error
@@ -103,6 +103,7 @@ type HppV2CostRepository interface {
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)
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)
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) GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error)
@@ -249,6 +250,82 @@ func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID(
return &row, nil return &row, nil
} }
func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
ctx context.Context,
projectFlockID uint,
periodDate time.Time,
) (float64, error) {
if projectFlockID == 0 || periodDate.IsZero() {
return 0, nil
}
flags := []utils.FlagType{
utils.FlagPakan,
utils.FlagOVK,
utils.FlagObat,
utils.FlagVitamin,
utils.FlagKimia,
}
transferExistsCondition := `
EXISTS (
SELECT 1
FROM laying_transfer_targets AS ltt
JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id
WHERE ltt.deleted_at IS NULL
AND lt.deleted_at IS NULL
AND lt.executed_at IS NOT NULL
AND ltt.target_project_flock_kandang_id = r.project_flock_kandangs_id
AND COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) <= DATE(?)
AND (
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = lt.id
ORDER BY a.id DESC
LIMIT 1
) = ?
)
`
var total float64
err := r.db.WithContext(ctx).
Table("recording_stocks AS rs").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
Joins("JOIN project_flock_kandangs AS pfk_rec ON pfk_rec.id = r.project_flock_kandangs_id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pfk_rec.project_flock_id = ?", projectFlockID).
Where("DATE(r.record_datetime) <= DATE(?)", periodDate).
Where(
fmt.Sprintf(
"((%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id) OR (NOT (%s) AND rs.project_flock_kandang_id IS NULL)",
transferExistsCondition,
transferExistsCondition,
),
periodDate,
string(utils.ApprovalWorkflowTransferToLaying),
entity.ApprovalActionApproved,
periodDate,
string(utils.ApprovalWorkflowTransferToLaying),
entity.ApprovalActionApproved,
).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod( func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(
ctx context.Context, ctx context.Context,
projectFlockID uint, projectFlockID uint,
@@ -393,7 +470,7 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id"). Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id").
Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id"). Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id").
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames).
Group(` Group(`
@@ -755,7 +832,7 @@ func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlock
). ).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan). Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error Scan(&total).Error
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
"math" "math"
"testing" "testing"
"time" "time"
@@ -96,6 +97,116 @@ func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(
assertFloatEquals(t, totalWeightKg, 1.4) assertFloatEquals(t, totalWeightKg, 1.4)
} }
func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
approvalType := utils.ApprovalWorkflowTransferToLaying.String()
mustExecHppV2(t, db,
`INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES
(101, 1, 1),
(102, 2, 1),
(103, 3, 1),
(104, 4, 1),
(105, 5, 1),
(201, 6, 2)`,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES
(1, 101, '2026-04-10 08:00:00', NULL),
(2, 101, '2026-04-10 08:05:00', NULL),
(3, 101, '2026-04-10 08:10:00', NULL),
(4, 102, '2026-04-10 08:15:00', NULL),
(5, 102, '2026-04-10 08:20:00', NULL),
(6, 103, '2026-04-12 08:00:00', NULL),
(7, 103, '2026-04-12 08:05:00', NULL),
(8, 104, '2026-04-12 08:10:00', NULL),
(9, 104, '2026-04-12 08:15:00', NULL),
(10, 105, '2026-04-12 08:20:00', NULL),
(11, 105, '2026-04-12 08:25:00', NULL)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES
(501, 201, 10, NULL),
(502, 201, 10, NULL),
(503, 201, 10, NULL),
(504, 201, 10, NULL),
(505, 201, 10, NULL),
(506, 201, 10, NULL),
(507, 201, 10, NULL),
(508, 201, 10, NULL),
(509, 201, 10, NULL),
(510, 201, 10, NULL),
(511, 201, 10, NULL)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES
(10, 'products', 10, 'PAKAN')`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, project_flock_kandang_id) VALUES
(101, 1, 501, NULL),
(102, 2, 502, 201),
(103, 3, 503, 101),
(104, 4, 504, NULL),
(105, 5, 505, 201),
(106, 6, 506, NULL),
(107, 7, 507, 201),
(108, 8, 508, NULL),
(109, 9, 509, 201),
(110, 10, 510, NULL),
(111, 11, 511, 201)`,
`INSERT INTO purchase_items (id, product_id, price) VALUES
(601, 10, 100),
(602, 10, 110),
(603, 10, 120),
(604, 10, 130),
(605, 10, 140),
(606, 10, 150),
(607, 10, 160),
(608, 10, 170),
(609, 10, 180),
(610, 10, 190),
(611, 10, 200)`,
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose, qty) VALUES
(9001, 'RECORDING_STOCK', 101, 'PURCHASE_ITEMS', 601, 'ACTIVE', 'CONSUME', 2),
(9002, 'RECORDING_STOCK', 102, 'PURCHASE_ITEMS', 602, 'ACTIVE', 'CONSUME', 1),
(9003, 'RECORDING_STOCK', 103, 'PURCHASE_ITEMS', 603, 'ACTIVE', 'CONSUME', 1),
(9004, 'RECORDING_STOCK', 104, 'PURCHASE_ITEMS', 604, 'ACTIVE', 'CONSUME', 1),
(9005, 'RECORDING_STOCK', 105, 'PURCHASE_ITEMS', 605, 'ACTIVE', 'CONSUME', 1),
(9006, 'RECORDING_STOCK', 106, 'PURCHASE_ITEMS', 606, 'ACTIVE', 'CONSUME', 1),
(9007, 'RECORDING_STOCK', 107, 'PURCHASE_ITEMS', 607, 'ACTIVE', 'CONSUME', 1),
(9008, 'RECORDING_STOCK', 108, 'PURCHASE_ITEMS', 608, 'ACTIVE', 'CONSUME', 1),
(9009, 'RECORDING_STOCK', 109, 'PURCHASE_ITEMS', 609, 'ACTIVE', 'CONSUME', 1),
(9010, 'RECORDING_STOCK', 110, 'PURCHASE_ITEMS', 610, 'ACTIVE', 'CONSUME', 1),
(9011, 'RECORDING_STOCK', 111, 'PURCHASE_ITEMS', 611, 'ACTIVE', 'CONSUME', 1)`,
`INSERT INTO laying_transfers (id, transfer_date, effective_move_date, economic_cutoff_date, executed_at, deleted_at) VALUES
(1001, '2026-04-04', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
(1002, '2026-05-01', '2026-05-01', NULL, '2026-05-01 00:00:00', NULL),
(1003, '2026-04-03', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
(1004, '2026-04-03', '2026-04-05', NULL, NULL, NULL)`,
`INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES
(2001, 1001, 101, NULL),
(2002, 1002, 103, NULL),
(2003, 1003, 104, NULL),
(2004, 1004, 105, NULL)`,
fmt.Sprintf(`INSERT INTO approvals (id, approvable_type, approvable_id, action) VALUES
(3001, '%s', 1001, 'APPROVED'),
(3002, '%s', 1002, 'APPROVED'),
(3003, '%s', 1003, 'APPROVED'),
(3004, '%s', 1003, 'REJECTED'),
(3005, '%s', 1004, 'APPROVED')`,
approvalType, approvalType, approvalType, approvalType, approvalType),
)
repo := &HppV2RepositoryImpl{db: db}
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, total, 750)
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, earlyTotal, 240)
}
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
@@ -111,6 +222,12 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
record_datetime DATETIME NULL, record_datetime DATETIME NULL,
deleted_at DATETIME NULL deleted_at DATETIME NULL
)`, )`,
`CREATE TABLE recording_stocks (
id INTEGER PRIMARY KEY,
recording_id INTEGER NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE recording_eggs ( `CREATE TABLE recording_eggs (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
recording_id INTEGER NULL, recording_id INTEGER NULL,
@@ -174,6 +291,11 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NULL product_warehouse_id INTEGER NULL
)`, )`,
`CREATE TABLE purchase_items (
id INTEGER PRIMARY KEY,
product_id INTEGER NULL,
price NUMERIC(15,3) NULL
)`,
`CREATE TABLE marketing_delivery_products ( `CREATE TABLE marketing_delivery_products (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
marketing_product_id INTEGER NULL, marketing_product_id INTEGER NULL,
@@ -187,6 +309,26 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
flagable_id INTEGER NULL, flagable_id INTEGER NULL,
name TEXT NULL name TEXT NULL
)`, )`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
transfer_date DATETIME NULL,
effective_move_date DATETIME NULL,
economic_cutoff_date DATETIME NULL,
executed_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NULL,
target_project_flock_kandang_id INTEGER NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE approvals (
id INTEGER PRIMARY KEY,
approvable_type TEXT NULL,
approvable_id INTEGER NULL,
action TEXT NULL
)`,
) )
return db return db
@@ -16,6 +16,7 @@ const (
hppV2ComponentBopRegular = "BOP_REGULAR" hppV2ComponentBopRegular = "BOP_REGULAR"
hppV2ComponentBopEksp = "BOP_EKSPEDISI" hppV2ComponentBopEksp = "BOP_EKSPEDISI"
hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST" hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST"
hppV2ComponentRecordingStockRoute = "RECORDING_STOCK_ROUTE"
hppV2ComponentDepreciation = "DEPRECIATION" hppV2ComponentDepreciation = "DEPRECIATION"
hppV2PartGrowingNormal = "growing_normal" hppV2PartGrowingNormal = "growing_normal"
hppV2PartGrowingCutover = "growing_cutover" hppV2PartGrowingCutover = "growing_cutover"
@@ -26,6 +27,7 @@ const (
hppV2PartLayingDirect = "laying_direct" hppV2PartLayingDirect = "laying_direct"
hppV2PartLayingFarm = "laying_farm" hppV2PartLayingFarm = "laying_farm"
hppV2PartManualCutover = "manual_cutover" hppV2PartManualCutover = "manual_cutover"
hppV2PartRecordingStockRoute = "recording_stock_route"
hppV2PartDepreciationNormal = "normal_transfer" hppV2PartDepreciationNormal = "normal_transfer"
hppV2PartDepreciationCutover = "manual_cutover" hppV2PartDepreciationCutover = "manual_cutover"
hppV2PartDepreciationFarmSnapshot = "farm_snapshot" hppV2PartDepreciationFarmSnapshot = "farm_snapshot"
@@ -190,6 +192,12 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t
} }
appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent) appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent)
recordingStockRouteComponent, err := s.getRecordingStockRouteComponent(projectFlockKandangId, contextRow, startOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentRecordingStockRoute, recordingStockRouteComponent)
depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost) depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1064,6 +1072,100 @@ func (s *hppV2Service) getManualPulletCostComponent(
}, nil }, nil
} }
func (s *hppV2Service) getRecordingStockRouteComponent(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
periodDate time.Time,
) (*HppV2Component, error) {
if s.hppRepo == nil || contextRow == nil || periodDate.IsZero() {
return nil, nil
}
farmTotalCost, err := s.hppRepo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
context.Background(),
contextRow.ProjectFlockID,
periodDate,
)
if err != nil {
return nil, err
}
if farmTotalCost <= 0 {
return nil, nil
}
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if len(farmPFKIDs) == 0 {
return nil, nil
}
totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs)
if err != nil {
return nil, err
}
if totalPopulation <= 0 {
return nil, nil
}
targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return nil, err
}
if targetPopulation <= 0 {
return nil, nil
}
ratio := targetPopulation / totalPopulation
if ratio <= 0 {
return nil, nil
}
appliedTotal := farmTotalCost * ratio
if appliedTotal <= 0 {
return nil, nil
}
part := HppV2ComponentPart{
Code: hppV2PartRecordingStockRoute,
Title: "Recording Stock Route",
Scopes: []string{hppV2ScopePulletCost},
Total: appliedTotal,
Proration: &HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: targetPopulation,
Denominator: totalPopulation,
Ratio: ratio,
},
Details: map[string]any{
"period_date": formatDateOnly(periodDate),
"farm_total_cost": farmTotalCost,
"target_population": targetPopulation,
"farm_population": totalPopulation,
"project_flock_id": contextRow.ProjectFlockID,
"project_flock_kandang_id": projectFlockKandangId,
},
References: []HppV2Reference{
{
Type: "recording_stock_route",
Date: formatDateOnly(periodDate),
Qty: 1,
Total: farmTotalCost,
AppliedTotal: appliedTotal,
},
},
}
return &HppV2Component{
Code: hppV2ComponentRecordingStockRoute,
Title: "Recording Stock Route",
Scopes: []string{hppV2ScopePulletCost},
Total: appliedTotal,
Parts: []HppV2ComponentPart{part},
}, nil
}
func (s *hppV2Service) getDepreciationComponent( func (s *hppV2Service) getDepreciationComponent(
projectFlockKandangId uint, projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext, contextRow *commonRepo.HppV2ProjectFlockKandangContext,
@@ -25,6 +25,7 @@ type hppV2RepoStub struct {
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
routeCostByProject map[uint]float64
totalPopulationByKey map[string]float64 totalPopulationByKey map[string]float64
transferSummaryByPFK map[uint]struct { transferSummaryByPFK map[uint]struct {
projectFlockID uint projectFlockID uint
@@ -60,6 +61,10 @@ func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Con
return s.manualInputByProject[projectFlockID], nil return s.manualInputByProject[projectFlockID], nil
} }
func (s *hppV2RepoStub) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(_ context.Context, projectFlockID uint, _ time.Time) (float64, error) {
return s.routeCostByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) { func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) {
if s.snapshotByProjectKey == nil { if s.snapshotByProjectKey == nil {
return nil, nil return nil, nil
@@ -0,0 +1,17 @@
BEGIN;
DROP INDEX IF EXISTS idx_recording_stocks_project_flock_kandang_id;
ALTER TABLE recording_stocks
DROP CONSTRAINT IF EXISTS fk_recording_stocks_project_flock_kandang_id;
ALTER TABLE recording_stocks
DROP COLUMN IF EXISTS project_flock_kandang_id;
ALTER TABLE house_depreciation_standards
DROP CONSTRAINT IF EXISTS chk_house_depreciation_standards_standard_week_positive;
ALTER TABLE house_depreciation_standards
DROP COLUMN IF EXISTS standard_week;
COMMIT;
@@ -0,0 +1,52 @@
BEGIN;
ALTER TABLE recording_stocks
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_recording_stocks_project_flock_kandang_id'
) THEN
ALTER TABLE recording_stocks
ADD CONSTRAINT fk_recording_stocks_project_flock_kandang_id
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_recording_stocks_project_flock_kandang_id
ON recording_stocks(project_flock_kandang_id);
ALTER TABLE house_depreciation_standards
ADD COLUMN IF NOT EXISTS standard_week INT;
UPDATE house_depreciation_standards
SET standard_week = CASE house_type::text
WHEN 'close_house' THEN 22
WHEN 'open_house' THEN 25
ELSE standard_week
END
WHERE standard_week IS NULL OR standard_week <= 0;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_house_depreciation_standards_standard_week_positive'
) THEN
ALTER TABLE house_depreciation_standards
ADD CONSTRAINT chk_house_depreciation_standards_standard_week_positive
CHECK (standard_week > 0);
END IF;
END $$;
ALTER TABLE house_depreciation_standards
ALTER COLUMN standard_week SET NOT NULL;
COMMIT;
@@ -0,0 +1,21 @@
BEGIN;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
DROP INDEX IF EXISTS idx_daily_checklists_deleted_at;
DROP INDEX IF EXISTS idx_daily_checklists_deleted_by;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_deleted_by;
ALTER TABLE daily_checklists
DROP COLUMN IF EXISTS deleted_at,
DROP COLUMN IF EXISTS deleted_by;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED');
COMMIT;
@@ -0,0 +1,27 @@
BEGIN;
ALTER TABLE daily_checklists
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS deleted_by BIGINT;
CREATE INDEX IF NOT EXISTS idx_daily_checklists_deleted_at
ON daily_checklists (deleted_at);
CREATE INDEX IF NOT EXISTS idx_daily_checklists_deleted_by
ON daily_checklists (deleted_by);
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_deleted_by,
ADD CONSTRAINT fk_daily_checklists_deleted_by
FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED')
AND deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,41 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM daily_checklists
WHERE category::text = 'empty_kandang'
) THEN
RAISE EXCEPTION 'Cannot rollback category_code enum: daily_checklists still contains empty_kandang';
END IF;
IF EXISTS (
SELECT 1
FROM phases
WHERE category::text = 'empty_kandang'
) THEN
RAISE EXCEPTION 'Cannot rollback category_code enum: phases still contains empty_kandang';
END IF;
END $$;
ALTER TYPE category_code RENAME TO category_code_old;
CREATE TYPE category_code AS ENUM (
'pullet_open',
'pullet_close',
'produksi_open',
'produksi_close'
);
ALTER TABLE phases
ALTER COLUMN category TYPE category_code
USING category::text::category_code;
ALTER TABLE daily_checklists
ALTER COLUMN category TYPE category_code
USING category::text::category_code;
DROP TYPE category_code_old;
COMMIT;
@@ -0,0 +1,12 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'category_code'
AND e.enumlabel = 'empty_kandang'
) THEN
ALTER TYPE category_code ADD VALUE 'empty_kandang';
END IF;
END $$;
+10 -3
View File
@@ -1,6 +1,10 @@
package entities package entities
import "time" import (
"time"
"gorm.io/gorm"
)
type DailyChecklist struct { type DailyChecklist struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
@@ -14,12 +18,15 @@ type DailyChecklist struct {
DocumentPath *string DocumentPath *string
RejectReason *string RejectReason *string
CreatedBy *uint CreatedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"` DeletedBy *uint
UpdatedAt time.Time `gorm:"autoUpdateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"` Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
} }
@@ -6,6 +6,7 @@ type HouseDepreciationStandard struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"` HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"`
DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"` DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"`
StandardWeek int `gorm:"column:standard_week;not null"`
DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"` DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
+6 -5
View File
@@ -1,11 +1,12 @@
package entities package entities
type RecordingStock struct { type RecordingStock struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"` RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
UsageQty *float64 `gorm:"column:usage_qty"` ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"`
PendingQty *float64 `gorm:"column:pending_qty"` UsageQty *float64 `gorm:"column:usage_qty"`
PendingQty *float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
@@ -153,6 +153,20 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
return normalized return normalized
} }
normalizeCutOverToken := func(raw string) string {
normalized := strings.ToUpper(strings.TrimSpace(raw))
normalized = strings.ReplaceAll(normalized, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
return normalized
}
containsCutOver := func(values ...string) bool {
for _, value := range values {
if strings.Contains(normalizeCutOverToken(value), "CUTOVER") {
return true
}
}
return false
}
filter := normalizeFlag(flag) filter := normalizeFlag(flag)
byFlag := map[string]**SapronakCategoryDTO{} byFlag := map[string]**SapronakCategoryDTO{}
@@ -258,6 +272,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
UnitPrice: item.Harga, UnitPrice: item.Harga,
Notes: "-", Notes: "-",
} }
isCutOver := containsCutOver(baseRow.ProductCategory, baseRow.Description, item.ProductName)
row := getOrCreateRow(productKey, baseRow) row := getOrCreateRow(productKey, baseRow)
@@ -289,11 +304,21 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
row.QtyUsed += item.QtyKeluar row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price row.TotalAmount += item.QtyKeluar * price
case "adjustment keluar", "mutasi keluar", "penjualan": case "adjustment keluar":
price := row.UnitPrice price := row.UnitPrice
if price == 0 { if price == 0 {
price = item.Harga price = item.Harga
} }
if row.UnitPrice == 0 {
row.UnitPrice = price
}
if isCutOver {
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
continue
}
row.QtyOut += item.QtyKeluar
case "mutasi keluar", "penjualan":
row.QtyOut += item.QtyKeluar row.QtyOut += item.QtyKeluar
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" { if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi)) ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
@@ -0,0 +1,124 @@
package dto
import "testing"
func TestToSapronakProjectAggregatedFromReportMovesCutOverAdjustmentOutToQtyUsed(t *testing.T) {
tests := []struct {
name string
groupFlag string
filter string
productFlag string
}{
{
name: "pakan cut-over",
groupFlag: "PAKAN",
filter: "PAKAN",
productFlag: "PAKAN CUT-OVER",
},
{
name: "ovk cut over",
groupFlag: "OVK",
filter: "OVK",
productFlag: "OVK CUT OVER",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
report := &SapronakReportDTO{
Groups: []SapronakGroupDTO{
{
Flag: tc.groupFlag,
Items: []SapronakDetailDTO{
{
ProductID: 1,
ProductName: "CUTOVER ITEM",
NoReferensi: "ADJ-CUT-01",
JenisTransaksi: "Adjustment Keluar",
QtyKeluar: 5,
Harga: 15000,
},
},
},
},
}
result := ToSapronakProjectAggregatedFromReport(
report,
tc.filter,
map[uint][]string{
1: {tc.productFlag},
},
)
var cat *SapronakCategoryDTO
if tc.groupFlag == "PAKAN" {
cat = result.Pakan
} else {
cat = result.Ovk
}
if cat == nil {
t.Fatalf("expected category payload for %s", tc.groupFlag)
}
if len(cat.Rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(cat.Rows))
}
row := cat.Rows[0]
if row.QtyOut != 0 {
t.Fatalf("expected qty_out 0 for cut-over adjustment, got %.2f", row.QtyOut)
}
if row.QtyUsed != 5 {
t.Fatalf("expected qty_used 5 for cut-over adjustment, got %.2f", row.QtyUsed)
}
if row.UnitPrice != 15000 {
t.Fatalf("expected unit_price 15000, got %.2f", row.UnitPrice)
}
if row.TotalAmount != 75000 {
t.Fatalf("expected total_amount 75000, got %.2f", row.TotalAmount)
}
})
}
}
func TestToSapronakProjectAggregatedFromReportKeepsNonCutOverAdjustmentOutInQtyOut(t *testing.T) {
report := &SapronakReportDTO{
Groups: []SapronakGroupDTO{
{
Flag: "PAKAN",
Items: []SapronakDetailDTO{
{
ProductID: 7,
ProductName: "PAKAN REGULER",
NoReferensi: "ADJ-REG-01",
JenisTransaksi: "Adjustment Keluar",
QtyKeluar: 3,
Harga: 12000,
},
},
},
},
}
result := ToSapronakProjectAggregatedFromReport(
report,
"PAKAN",
map[uint][]string{
7: {"PAKAN"},
},
)
if result.Pakan == nil || len(result.Pakan.Rows) != 1 {
t.Fatalf("expected 1 pakan row, got %+v", result.Pakan)
}
row := result.Pakan.Rows[0]
if row.QtyOut != 3 {
t.Fatalf("expected qty_out 3 for non cut-over adjustment, got %.2f", row.QtyOut)
}
if row.QtyUsed != 0 {
t.Fatalf("expected qty_used 0 for non cut-over adjustment, got %.2f", row.QtyUsed)
}
if row.TotalAmount != 0 {
t.Fatalf("expected total_amount 0 for non cut-over adjustment, got %.2f", row.TotalAmount)
}
}
@@ -98,6 +98,9 @@ func sapronakIncomingPurchaseQueryParts(params SapronakQueryParams) (string, []a
fifo.StockableKeyPurchaseItems.String(), fifo.StockableKeyPurchaseItems.String(),
entity.StockAllocationStatusActive, entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume, entity.StockAllocationPurposeConsume,
fifo.UsableKeyRecordingStock.String(),
params.ProjectFlockKandangIDs,
fifo.UsableKeyProjectChickin.String(),
params.ProjectFlockKandangIDs, params.ProjectFlockKandangIDs,
params.ProjectFlockKandangIDs, params.ProjectFlockKandangIDs,
params.WarehouseIDs, params.WarehouseIDs,
@@ -323,7 +326,7 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Where("rec.project_flock_kandangs_id IN ?", projectFlockKandangIDs). Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("f.name = ?", "PAKAN"). Where("f.name = ?", "PAKAN").
Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used"). Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used").
Scan(&usageAgg).Error Scan(&usageAgg).Error
@@ -905,7 +908,11 @@ WITH scoped_farm_allocations AS (
WHERE sa.stockable_type = ? WHERE sa.stockable_type = ?
AND sa.status = ? AND sa.status = ?
AND sa.allocation_purpose = ? AND sa.allocation_purpose = ?
AND COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) IN ? AND (
(sa.usable_type = ? AND rs.project_flock_kandang_id IN ?)
OR
(sa.usable_type = ? AND pc.project_flock_kandang_id IN ?)
)
GROUP BY sa.stockable_id GROUP BY sa.stockable_id
) )
SELECT SELECT
@@ -1167,7 +1174,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui
"recording_stocks rs", "recording_stocks rs",
"pw.id = rs.product_warehouse_id", "pw.id = rs.product_warehouse_id",
[]string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"}, []string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"},
"r.project_flock_kandangs_id = ? AND f.name IN ?", "rs.project_flock_kandang_id = ? AND f.name IN ?",
pfkID, pfkID,
sapronakFlagsUsage, sapronakFlagsUsage,
) )
@@ -1208,7 +1215,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p
COALESCE(rs.usage_qty,0) AS qty_out, COALESCE(rs.usage_qty,0) AS qty_out,
COALESCE(p.product_price,0) AS price COALESCE(p.product_price,0) AS price
`, `,
"r.project_flock_kandangs_id = ? AND f.name IN ?", "rs.project_flock_kandang_id = ? AND f.name IN ?",
pfkID, pfkID,
sapronakFlagsUsage, sapronakFlagsUsage,
) )
@@ -1294,7 +1301,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where(` Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?) (sa.usable_type = ? AND rs.project_flock_kandang_id = ? AND f.name IN ?)
OR OR
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?) (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?)
`, `,
@@ -1347,7 +1354,12 @@ func (r *ClosingRepositoryImpl) incomingFarmPurchaseAllocationBase(ctx context.C
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id IS NULL"). Where("w.kandang_id IS NULL").
Where("COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) = ?", projectFlockKandangID). Where("(sa.usable_type = ? AND rs.project_flock_kandang_id = ?) OR (sa.usable_type = ? AND pc.project_flock_kandang_id = ?)",
fifo.UsableKeyRecordingStock.String(),
projectFlockKandangID,
fifo.UsableKeyProjectChickin.String(),
projectFlockKandangID,
).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL") Where("pi.received_date IS NOT NULL")
db = applyDateRange(db, "pi.received_date", start, end) db = applyDateRange(db, "pi.received_date", start, end)
@@ -1576,11 +1588,20 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
poByWarehouse := r.DB(). poByWarehouse := r.DB().
Table("purchase_items pi"). Table("(?) AS ranked_po",
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). r.DB().
Joins("JOIN purchases po ON po.id = pi.purchase_id"). Table("purchase_items pi").
Where("pi.received_date IS NOT NULL"). Select(`
Order("pi.product_warehouse_id, pi.received_date ASC") pi.product_warehouse_id,
po.po_number,
pi.received_date,
ROW_NUMBER() OVER (PARTITION BY pi.product_warehouse_id ORDER BY pi.received_date ASC, pi.id ASC) AS rn
`).
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL"),
).
Select("ranked_po.product_warehouse_id, ranked_po.po_number, ranked_po.received_date").
Where("ranked_po.rn = 1")
incomingQuery := r.withCtx(ctx). incomingQuery := r.withCtx(ctx).
Table("adjustment_stocks AS ast"). Table("adjustment_stocks AS ast").
@@ -1589,10 +1610,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
ast.created_at AS date, ast.created_at AS date,
CONCAT('ADJ-', ast.id) AS reference, 'ADJ-' || CAST(ast.id AS TEXT) AS reference,
COALESCE(ast.total_qty, 0) AS qty_in, COALESCE(ast.total_qty, 0) AS qty_in,
0 AS qty_out, 0 AS qty_out,
COALESCE(p.product_price, 0) AS price COALESCE(ast.price, p.product_price, 0) AS price
`). `).
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
@@ -1615,10 +1636,18 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date, COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference, COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
pfp_po.po_number,
CASE WHEN ast_in.id IS NOT NULL THEN 'ADJ-' || CAST(ast_in.id AS TEXT) END,
CASE WHEN ast.id IS NOT NULL THEN 'ADJ-' || CAST(ast.id AS TEXT) END,
CASE WHEN pc.id IS NOT NULL THEN 'CHICKIN-' || CAST(pc.id AS TEXT) END
) AS reference,
0 AS qty_in, 0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price COALESCE(pi.price, ast_in.price, ast.price, p.product_price, 0) AS price
`). `).
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()). Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
@@ -1639,7 +1668,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, pi.price, ast_in.price, ast.price, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end) outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery) outgoing, err := scanAndGroupDetails(outgoingQuery)
@@ -20,8 +20,8 @@ func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlo
if sql != sapronakIncomingPurchasesScopedSQL() { if sql != sapronakIncomingPurchasesScopedSQL() {
t.Fatalf("expected scoped purchase SQL, got %q", sql) t.Fatalf("expected scoped purchase SQL, got %q", sql)
} }
if len(args) != 8 { if len(args) != 11 {
t.Fatalf("expected 8 argument groups, got %d", len(args)) t.Fatalf("expected 11 argument groups, got %d", len(args))
} }
} }
@@ -42,7 +42,7 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
(2, 10, 'products', 'OBAT')`, (2, 10, 'products', 'OBAT')`,
`INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`, `INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`,
`INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`, `INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`, `INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty, project_flock_kandang_id) VALUES (21, 11, 501, 150, 101), (22, 12, 502, 10, 999)`,
`INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES `INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES
(1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'), (1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
(2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'), (2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'),
@@ -89,6 +89,63 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
} }
} }
func TestFetchSapronakAdjustmentsUsesAdjustmentReferenceAndPrice(t *testing.T) {
db := setupClosingRepositoryTestDB(t)
repo := NewClosingRepository(db)
ctx := context.Background()
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (5, 5)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'OBT')`,
`INSERT INTO products (id, name, product_category_id, product_price) VALUES (17, 'OVK CUT-OVER', 1, 1)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES (1, 17, 'products', 'OVK')`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id) VALUES (1365, 17, 5, 66)`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, usage_qty, price) VALUES
(1139, 1365, 1, 0, 298594487),
(1140, 1365, 0, 1, 298594487)`,
fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES
(25990, 1365, '%s', 1139, '%s', 1140, 1, 'CONSUME', 'ACTIVE')`,
fifo.StockableKeyAdjustmentIn.String(),
fifo.UsableKeyAdjustmentOut.String(),
),
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding schema: %v", err)
}
}
incoming, outgoing, err := repo.FetchSapronakAdjustments(ctx, 5, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
incomingRows := incoming[17]
if len(incomingRows) != 1 {
t.Fatalf("expected 1 incoming row for product 17, got %d", len(incomingRows))
}
if incomingRows[0].Reference != "ADJ-1139" {
t.Fatalf("expected incoming reference ADJ-1139, got %q", incomingRows[0].Reference)
}
if incomingRows[0].Price != 298594487 {
t.Fatalf("expected incoming price 298594487 from adjustment_stocks.price, got %.3f", incomingRows[0].Price)
}
outgoingRows := outgoing[17]
if len(outgoingRows) != 1 {
t.Fatalf("expected 1 outgoing row for product 17, got %d", len(outgoingRows))
}
if outgoingRows[0].Reference != "ADJ-1139" {
t.Fatalf("expected outgoing reference ADJ-1139, got %q", outgoingRows[0].Reference)
}
if outgoingRows[0].Reference == "CHICKIN-" {
t.Fatalf("expected outgoing reference to avoid CHICKIN- placeholder")
}
if outgoingRows[0].Price != 298594487 {
t.Fatalf("expected outgoing price 298594487 from adjustment_stocks.price, got %.3f", outgoingRows[0].Price)
}
}
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB { func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
@@ -134,6 +191,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
purchase_id INTEGER NOT NULL, purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL, product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL, warehouse_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL, project_flock_kandang_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0, price NUMERIC(15,3) NOT NULL DEFAULT 0,
@@ -145,14 +203,21 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
deleted_at TIMESTAMP NULL deleted_at TIMESTAMP NULL
)`, )`,
`CREATE TABLE recording_stocks ( `CREATE TABLE recording_stocks (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL, recording_id INTEGER NOT NULL,
product_warehouse_id INTEGER NOT NULL, product_warehouse_id INTEGER NOT NULL,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0 usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
)`, project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE project_chickins ( `CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NULL
)`,
`CREATE TABLE project_flock_populations (
id INTEGER PRIMARY KEY,
project_chickin_id INTEGER NULL,
product_warehouse_id INTEGER NULL
)`, )`,
`CREATE TABLE stock_allocations ( `CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -179,6 +244,16 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
movement_number TEXT NULL, movement_number TEXT NULL,
reason TEXT NULL reason TEXT NULL
)`, )`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
transfer_date TIMESTAMP NULL,
transfer_number TEXT NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_transfer_details ( `CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NOT NULL, stock_transfer_id INTEGER NOT NULL,
@@ -193,6 +268,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
product_warehouse_id INTEGER NOT NULL, product_warehouse_id INTEGER NOT NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0, usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
adj_number TEXT NULL, adj_number TEXT NULL,
created_at TIMESTAMP NULL created_at TIMESTAMP NULL
)`, )`,
@@ -573,17 +573,21 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return nil, nil, err return nil, nil, err
} }
var minChickin *time.Time var firstChickin struct {
ChickInDate time.Time `gorm:"column:chick_in_date"`
}
if err := db.Table("project_chickins"). if err := db.Table("project_chickins").
Select("MIN(chick_in_date)"). Select("chick_in_date").
Where("project_flock_kandang_id = ?", pfk.Id). Where("project_flock_kandang_id = ? AND chick_in_date IS NOT NULL", pfk.Id).
Scan(&minChickin).Error; err != nil { Order("chick_in_date ASC").
Limit(1).
Scan(&firstChickin).Error; err != nil {
return nil, nil, err return nil, nil, err
} }
start := pfk.CreatedAt start := pfk.CreatedAt
if minChickin != nil && !minChickin.IsZero() { if !firstChickin.ChickInDate.IsZero() {
start = *minChickin start = firstChickin.ChickInDate
} }
startDate := dateOnlyUTC(start) startDate := dateOnlyUTC(start)
@@ -596,26 +600,34 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return &startDate, endDate, nil return &startDate, endDate, nil
} }
var minCreated time.Time var firstPFK entity.ProjectFlockKandang
if err := db.Model(&entity.ProjectFlockKandang{}). if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MIN(created_at)"). Select("created_at").
Where("project_flock_id = ?", projectFlockID). Where("project_flock_id = ?", projectFlockID).
Scan(&minCreated).Error; err != nil { Order("created_at ASC").
First(&firstPFK).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil
}
return nil, nil, err return nil, nil, err
} }
var minChickin *time.Time var firstChickin struct {
ChickInDate time.Time `gorm:"column:chick_in_date"`
}
if err := db.Table("project_chickins pc"). if err := db.Table("project_chickins pc").
Select("MIN(pc.chick_in_date)"). Select("pc.chick_in_date").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id"). Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pfk.project_flock_id = ?", projectFlockID). Where("pfk.project_flock_id = ? AND pc.chick_in_date IS NOT NULL", projectFlockID).
Scan(&minChickin).Error; err != nil { Order("pc.chick_in_date ASC").
Limit(1).
Scan(&firstChickin).Error; err != nil {
return nil, nil, err return nil, nil, err
} }
start := minCreated start := firstPFK.CreatedAt
if minChickin != nil && !minChickin.IsZero() { if !firstChickin.ChickInDate.IsZero() {
start = *minChickin start = firstChickin.ChickInDate
} }
startDate := dateOnlyUTC(start) startDate := dateOnlyUTC(start)
@@ -627,15 +639,19 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return nil, nil, err return nil, nil, err
} }
if openCount == 0 { if openCount == 0 {
var maxClosed *time.Time var latestClosed entity.ProjectFlockKandang
if err := db.Model(&entity.ProjectFlockKandang{}). if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MAX(closed_at)"). Select("closed_at").
Where("project_flock_id = ?", projectFlockID). Where("project_flock_id = ? AND closed_at IS NOT NULL", projectFlockID).
Scan(&maxClosed).Error; err != nil { Order("closed_at DESC").
First(&latestClosed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &startDate, nil, nil
}
return nil, nil, err return nil, nil, err
} }
if maxClosed != nil && !maxClosed.IsZero() { if latestClosed.ClosedAt != nil && !latestClosed.ClosedAt.IsZero() {
d := dateOnlyUTC(*maxClosed) d := dateOnlyUTC(*latestClosed.ClosedAt)
endDate = &d endDate = &d
} }
} }
@@ -0,0 +1,94 @@
package service
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
"gorm.io/gorm"
)
func TestGetSapronakDateRange_ProjectWithoutChickin_DoesNotError(t *testing.T) {
db := setupClosingServiceTestDB(t)
repo := repository.NewClosingRepository(db)
svc := closingService{Repository: repo}
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
}
start, end, err := svc.getSapronakDateRange(context.Background(), 47, nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if start == nil {
t.Fatalf("expected non-nil start date")
}
expected := dateOnlyUTC(createdAt)
if !start.Equal(expected) {
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
}
if end != nil {
t.Fatalf("expected nil end date for open kandang, got %v", end)
}
}
func TestGetSapronakDateRange_KandangWithoutChickin_DoesNotError(t *testing.T) {
db := setupClosingServiceTestDB(t)
repo := repository.NewClosingRepository(db)
svc := closingService{Repository: repo}
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
}
pfkID := uint(66)
start, end, err := svc.getSapronakDateRange(context.Background(), 47, &pfkID)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if start == nil {
t.Fatalf("expected non-nil start date")
}
expected := dateOnlyUTC(createdAt)
if !start.Equal(expected) {
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
}
if end != nil {
t.Fatalf("expected nil end date for open kandang, got %v", end)
}
}
func setupClosingServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
stmts := []string{
`CREATE TABLE project_flock_kandangs (
id INTEGER PRIMARY KEY,
project_flock_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
closed_at TIMESTAMP NULL
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NULL
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -371,7 +371,9 @@ func buildSapronakDetails(
addRows(result.Incoming, incomingRows, "Pembelian", true) addRows(result.Incoming, incomingRows, "Pembelian", true)
addRows(result.Usage, usageRows, "Pemakaian", false) addRows(result.Usage, usageRows, "Pemakaian", false)
addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true) addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true)
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) // Outgoing adjustment rows here are sourced from stock allocation
// consume flow (adjustment_stocks.usage_qty), so treat them as usage.
addRows(result.AdjOutgoing, adjOutgoingRows, "Pemakaian", false)
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
addRows(result.SalesOut, salesOutRows, "Penjualan", false) addRows(result.SalesOut, salesOutRows, "Penjualan", false)
@@ -0,0 +1,45 @@
package service
import (
"testing"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
)
func TestBuildSapronakDetailsMapsAdjustmentOutgoingAsUsage(t *testing.T) {
res := buildSapronakDetails(
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{
17: {
{
ProductID: 17,
ProductName: "PAKAN GROWING CRUMBLE 8603 MALINDO",
Flag: "PAKAN",
QtyOut: 9000,
Price: 6450,
},
},
},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
)
rows := res.AdjOutgoing[17]
if len(rows) != 1 {
t.Fatalf("expected 1 adjustment outgoing row, got %d", len(rows))
}
row := rows[0]
if row.JenisTransaksi != "Pemakaian" {
t.Fatalf("expected jenis_transaksi Pemakaian, got %q", row.JenisTransaksi)
}
if row.QtyKeluar != 9000 {
t.Fatalf("expected qty_keluar 9000, got %.3f", row.QtyKeluar)
}
if row.Nilai != 58050000 {
t.Fatalf("expected nilai 58050000, got %.3f", row.Nilai)
}
}
@@ -351,6 +351,31 @@ func (u *DailyChecklistController) CreateOne(c *fiber.Ctx) error {
}) })
} }
func (u *DailyChecklistController) BulkUpdate(c *fiber.Ctx) error {
req := new(validation.BulkStatusUpdate)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
results, err := u.DailyChecklistService.BulkUpdate(c, req)
if err != nil {
return err
}
responseData := make([]dto.DailyChecklistListDTO, len(results))
for i, item := range results {
responseData[i] = dto.ToDailyChecklistListDTO(item)
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Bulk update dailyChecklist successfully",
Data: responseData,
})
}
func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update) req := new(validation.Update)
param := c.Params("idDailyChecklist") param := c.Params("idDailyChecklist")
@@ -1,13 +1,22 @@
package repository package repository
import ( import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "context"
"time"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"github.com/gofiber/fiber/v2"
"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"
"gorm.io/gorm" "gorm.io/gorm"
) )
type DailyChecklistRepository interface { type DailyChecklistRepository interface {
repository.BaseRepository[entity.DailyChecklist] repository.BaseRepository[entity.DailyChecklist]
ListScopedChecklistIDs(c *fiber.Ctx, ids []uint) ([]uint, error)
BulkUpdateStatus(ctx context.Context, ids []uint, status string, rejectReason *string) error
ListByIDsWithKandang(ctx context.Context, ids []uint) ([]entity.DailyChecklist, error)
} }
type DailyChecklistRepositoryImpl struct { type DailyChecklistRepositoryImpl struct {
@@ -19,3 +28,71 @@ func NewDailyChecklistRepository(db *gorm.DB) DailyChecklistRepository {
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklist](db), BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklist](db),
} }
} }
func (r *DailyChecklistRepositoryImpl) ListScopedChecklistIDs(c *fiber.Ctx, ids []uint) ([]uint, error) {
if len(ids) == 0 {
return []uint{}, nil
}
db := r.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Select("dc.id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id IN ?", ids).
Where("dc.deleted_at IS NULL")
db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return nil, err
}
var scopedIDs []uint
if err := db.Pluck("dc.id", &scopedIDs).Error; err != nil {
return nil, err
}
return scopedIDs, nil
}
func (r *DailyChecklistRepositoryImpl) BulkUpdateStatus(ctx context.Context, ids []uint, status string, rejectReason *string) error {
if len(ids) == 0 {
return nil
}
updateBody := map[string]any{
"status": status,
"reject_reason": rejectReason,
"updated_at": time.Now(),
}
return r.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&entity.DailyChecklist{}).
Where("id IN ?", ids).
Updates(updateBody)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != int64(len(ids)) {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (r *DailyChecklistRepositoryImpl) ListByIDsWithKandang(ctx context.Context, ids []uint) ([]entity.DailyChecklist, error) {
if len(ids) == 0 {
return []entity.DailyChecklist{}, nil
}
var items []entity.DailyChecklist
if err := r.DB().WithContext(ctx).
Where("id IN ?", ids).
Preload("Kandang").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
@@ -58,6 +58,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.
*/ */
route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment) route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment)
route.Patch("/bulk-update", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.BulkUpdate)
route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne) route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne)
route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne) route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne)
} }
@@ -29,6 +29,7 @@ type DailyChecklistService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error)
BulkUpdate(ctx *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error
AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error
@@ -121,6 +122,15 @@ type DailyChecklistReportCategory struct {
Baik int Baik int
} }
const (
dailyChecklistDateLayout = "2006-01-02"
dailyChecklistCategoryEmptyKandang = "empty_kandang"
dailyChecklistStatusRejected = "REJECTED"
dailyChecklistStatusDraft = "DRAFT"
dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range"
dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist"
)
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
return &dailyChecklistService{ return &dailyChecklistService{
Log: utils.Log, Log: utils.Log,
@@ -145,7 +155,8 @@ func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID u
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id = ?", checklistID) Where("dc.id = ?", checklistID).
Where("dc.deleted_at IS NULL")
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil { if err != nil {
@@ -195,7 +206,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_tasks t"). Table("daily_checklist_activity_tasks t").
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id"). Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id AND dc.deleted_at IS NULL").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
@@ -227,7 +238,8 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
Table("daily_checklists dc"). Table("daily_checklists dc").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id") Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.deleted_at IS NULL")
var scopeErr error var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
@@ -500,66 +512,50 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
return nil, err return nil, err
} }
date, err := time.Parse("2006-01-02", req.Date) date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date))
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
} }
status := req.Status status := req.Status
category := req.Category category := req.Category
endDate := date
if req.EmptyKandang {
if strings.TrimSpace(req.EmptyKandangEndDate) == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date is required when empty_kandang is true")
}
endDate, err = time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.EmptyKandangEndDate))
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if endDate.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date must be greater than or equal to date")
}
category = dailyChecklistCategoryEmptyKandang
}
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 {
existing := new(entity.DailyChecklist) if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
if err == nil { if req.EmptyKandang {
if err := tx.Model(&entity.DailyChecklist{}). if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, endDate); err != nil {
Where("id = ?", existing.Id).
Update("updated_at", time.Now()).Error; err != nil {
return err return err
} }
return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
targetID = existing.Id
return nil
} }
createStatus := status if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, endDate); err != nil {
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ?", date, req.KandangId, category, "REJECTED").
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = "DRAFT"
}
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist with same key.
if findErr := tx.
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error; findErr == nil {
targetID = existing.Id
return nil
}
return err return err
} }
targetID = createBody.Id return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
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)
@@ -569,6 +565,159 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
return s.GetOne(c, targetID) return s.GetOne(c, targetID)
} }
func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kandangID uint) error {
if kandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
}
var lockedKandangID uint
query := tx.Table("kandang_groups").Select("id").Where("id = ?", kandangID)
if tx.Dialector.Name() != "sqlite" {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
if err := query.Take(&lockedKandangID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
return err
}
return nil
}
func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", kandangID, startDate, endDate).
Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist)
}
return nil
}
func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang).
Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangExist)
}
return nil
}
func (s *dailyChecklistService) createOrReuseSingleDailyChecklist(tx *gorm.DB, kandangID uint, date time.Time, category, status string, targetID *uint) error {
existing := new(entity.DailyChecklist)
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected).
Take(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
if err := tx.Model(&entity.DailyChecklist{}).
Where("id = ?", existing.Id).
Update("updated_at", time.Now()).Error; err != nil {
return err
}
*targetID = existing.Id
return nil
}
createStatus := status
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ? AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected).
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = dailyChecklistStatusDraft
}
createBody := &entity.DailyChecklist{
KandangId: kandangID,
Date: date,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist with same key.
if findErr := tx.
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected).
Take(existing).Error; findErr == nil {
*targetID = existing.Id
return nil
}
return err
}
*targetID = createBody.Id
return nil
}
func (s *dailyChecklistService) createBulkDailyChecklists(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, category, status string, targetID *uint) error {
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND category = ? AND date BETWEEN ? AND ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", kandangID, category, startDate, endDate, dailyChecklistStatusRejected).
Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists for at least one date in range")
}
for currentDate := startDate; !currentDate.After(endDate); currentDate = currentDate.AddDate(0, 0, 1) {
createStatus := status
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ? AND deleted_at IS NULL", currentDate, kandangID, category, dailyChecklistStatusRejected).
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = dailyChecklistStatusDraft
}
createBody := &entity.DailyChecklist{
KandangId: kandangID,
Date: currentDate,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist in same date range.
var existingActiveCount int64
checkErr := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", currentDate, kandangID, category, dailyChecklistStatusRejected).
Count(&existingActiveCount).Error
if checkErr == nil && existingActiveCount > 0 {
return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists for at least one date in range")
}
return err
}
if currentDate.Equal(startDate) {
*targetID = createBody.Id
}
}
return nil
}
func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) { func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
@@ -646,11 +795,100 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
return s.GetOne(c, id) return s.GetOne(c, id)
} }
func (s dailyChecklistService) BulkUpdate(c *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
status := strings.ToUpper(strings.TrimSpace(req.Status))
if status != "APPROVED" && status != "REJECTED" {
return nil, fiber.NewError(fiber.StatusBadRequest, "status must be APPROVED or REJECTED")
}
ids, err := parseChecklistIDs(req.IDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if len(ids) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "ids cannot be empty")
}
scopedIDs, err := s.Repository.ListScopedChecklistIDs(c, ids)
if err != nil {
s.Log.Errorf("Failed to validate daily checklist scope for bulk update: %+v", err)
return nil, err
}
if len(scopedIDs) != len(ids) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
var rejectReason *string
if status == "REJECTED" {
rejectReason = req.RejectReason
}
if err := s.Repository.BulkUpdateStatus(c.Context(), ids, status, rejectReason); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
s.Log.Errorf("Failed to bulk update daily checklist status: %+v", err)
return nil, err
}
updated, err := s.Repository.ListByIDsWithKandang(c.Context(), ids)
if err != nil {
s.Log.Errorf("Failed to fetch updated daily checklists: %+v", err)
return nil, err
}
if len(updated) != len(ids) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
orderByID := make(map[uint]int, len(ids))
for idx, id := range ids {
orderByID[id] = idx
}
sort.Slice(updated, func(i, j int) bool {
return orderByID[updated[i].Id] < orderByID[updated[j].Id]
})
return updated, nil
}
func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil { if err := s.ensureChecklistAccess(c, id); err != nil {
return err return err
} }
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { actorID, err := m.ActorIDFromContext(c)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
updateResult := tx.Model(&entity.DailyChecklist{}).
Where("id = ?", id).
Updates(map[string]any{
"deleted_by": actorID,
"updated_at": time.Now(),
})
if updateResult.Error != nil {
return updateResult.Error
}
if updateResult.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
deleteResult := tx.Delete(&entity.DailyChecklist{}, id)
if deleteResult.Error != nil {
return deleteResult.Error
}
if deleteResult.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
} }
@@ -908,6 +1146,33 @@ func parsePhaseIDs(raw string) ([]uint, error) {
return result, nil return result, nil
} }
func parseChecklistIDs(raw string) ([]uint, error) {
parts := strings.Split(raw, ",")
result := make([]uint, 0, len(parts))
seen := make(map[uint]struct{})
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
num, err := strconv.ParseUint(value, 10, 64)
if err != nil || num == 0 {
return nil, errors.New("invalid daily checklist id: " + value)
}
u := uint(num)
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
result = append(result, u)
}
return result, nil
}
func parseIDs(raw string) ([]uint, error) { func parseIDs(raw string) ([]uint, error) {
parts := strings.Split(raw, ",") parts := strings.Split(raw, ",")
result := make([]uint, 0, len(parts)) result := make([]uint, 0, len(parts))
@@ -1063,7 +1328,7 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa
SUM(CASE WHEN NOT a.checked THEN 1 ELSE 0 END) AS activity_left, SUM(CASE WHEN NOT a.checked THEN 1 ELSE 0 END) AS activity_left,
MAX(a.updated_at) AS last_activity`). MAX(a.updated_at) AS last_activity`).
Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id"). Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id").
Joins("JOIN daily_checklists d ON d.id = t.checklist_id"). Joins("JOIN daily_checklists d ON d.id = t.checklist_id AND d.deleted_at IS NULL").
Joins("JOIN kandang_groups k ON k.id = d.kandang_id"). Joins("JOIN kandang_groups k ON k.id = d.kandang_id").
Joins("JOIN employees e ON e.id = a.employee_id"). Joins("JOIN employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
@@ -1135,7 +1400,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_task_assignments AS dca"). Table("daily_checklist_activity_task_assignments AS dca").
Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id"). Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id").
Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id"). Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id AND dc.deleted_at IS NULL").
Joins("JOIN employees e ON e.id = dca.employee_id"). Joins("JOIN employees e ON e.id = dca.employee_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
@@ -0,0 +1,409 @@
package service
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
"gorm.io/gorm"
)
func TestCreateOneRejectsWhenSameDateHasActiveEmptyKandang(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr("DRAFT"), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-10",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
}
func TestCreateOneRejectsWhenSameDateHasRejectedEmptyKandang(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr(dailyChecklistStatusRejected), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-10",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
}
func TestCreateOneAllowsWhenOnlySoftDeletedEmptyKandangExists(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
deletedAt := mustDateTime(t, "2026-01-11 10:00:00")
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr("DRAFT"), &deletedAt)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-10",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
})
if serviceErr != nil {
t.Fatalf("expected no error, got %v", serviceErr)
}
if resp.StatusCode != fiber.StatusCreated {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Category != "cleaning" {
t.Fatalf("expected category cleaning, got %s", result.Category)
}
var activeCount int64
if err := db.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-10"), "cleaning").
Count(&activeCount).Error; err != nil {
t.Fatalf("failed counting active checklists: %v", err)
}
if activeCount != 1 {
t.Fatalf("expected 1 active cleaning checklist, got %d", activeCount)
}
}
func TestCreateOneRejectsBulkEmptyKandangWhenDateRangeHasConflict(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), dailyChecklistCategoryEmptyKandang, strPtr("APPROVED"), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-01",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
EmptyKandang: true,
EmptyKandangEndDate: "2026-01-05",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
var activeInRange int64
if err := db.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err)
}
if activeInRange != 1 {
t.Fatalf("expected only pre-existing row to remain in range, got %d rows", activeInRange)
}
}
func TestCreateOneRejectsBulkEmptyKandangWhenRangeHasNonEmptyChecklist(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr("APPROVED"), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-01",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
EmptyKandang: true,
EmptyKandangEndDate: "2026-01-05",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
}
func TestCreateOneRejectsBulkEmptyKandangWhenRangeHasRejectedChecklist(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr(dailyChecklistStatusRejected), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-01",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
EmptyKandang: true,
EmptyKandangEndDate: "2026-01-05",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
}
func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
deletedAt := mustDateTime(t, "2026-01-11 10:00:00")
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr("APPROVED"), &deletedAt)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-01",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
EmptyKandang: true,
EmptyKandangEndDate: "2026-01-05",
})
if serviceErr != nil {
t.Fatalf("expected no error, got %v", serviceErr)
}
if resp.StatusCode != fiber.StatusCreated {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Category != dailyChecklistCategoryEmptyKandang {
t.Fatalf("expected category %s, got %s", dailyChecklistCategoryEmptyKandang, result.Category)
}
var activeInRange int64
if err := db.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err)
}
if activeInRange != 5 {
t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange)
}
}
func TestCreateOneReusesExistingChecklistWhenNoEmptyKandangConflict(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
existingID := insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), "cleaning", strPtr("APPROVED"), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-10",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
})
if serviceErr != nil {
t.Fatalf("expected no error, got %v", serviceErr)
}
if resp.StatusCode != fiber.StatusCreated {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Id != existingID {
t.Fatalf("expected existing checklist id %d to be reused, got %d", existingID, result.Id)
}
var activeCount int64
if err := db.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-10"), "cleaning").
Count(&activeCount).Error; err != nil {
t.Fatalf("failed counting active checklists: %v", err)
}
if activeCount != 1 {
t.Fatalf("expected 1 active cleaning checklist, got %d", activeCount)
}
}
func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE areas (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE locations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL,
area_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE kandang_groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL,
location_id INTEGER NOT NULL,
pic_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE daily_checklists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kandang_id INTEGER NOT NULL,
checklist_id INTEGER NULL,
date DATE NOT NULL,
name TEXT NULL,
status TEXT NULL,
category TEXT NOT NULL,
total_score INTEGER NULL,
document_path TEXT NULL,
reject_reason TEXT 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 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)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
repo := repository.NewDailyChecklistRepository(db)
svc := NewDailyChecklistService(repo, nil, validator.New(), nil)
return svc, db
}
func runCreateOneRequest(t *testing.T, svc DailyChecklistService, req *validation.Create) (*entity.DailyChecklist, error, *http.Response) {
t.Helper()
app := fiber.New()
var (
result *entity.DailyChecklist
serviceErr error
)
app.Post("/", func(c *fiber.Ctx) error {
result, serviceErr = svc.CreateOne(c, req)
if serviceErr != nil {
return serviceErr
}
return c.SendStatus(fiber.StatusCreated)
})
resp, err := app.Test(httptest.NewRequest(http.MethodPost, "/", nil))
if err != nil {
t.Fatalf("failed running fiber request: %v", err)
}
return result, serviceErr, resp
}
func insertDailyChecklistRow(t *testing.T, db *gorm.DB, kandangID uint, date time.Time, category string, status *string, deletedAt *time.Time) uint {
t.Helper()
row := &entity.DailyChecklist{
KandangId: kandangID,
Date: date,
Category: category,
Status: status,
}
if deletedAt != nil {
row.DeletedAt = gorm.DeletedAt{
Time: *deletedAt,
Valid: true,
}
}
if err := db.Create(row).Error; err != nil {
t.Fatalf("failed inserting daily checklist row: %v", err)
}
return row.Id
}
func assertFiberErrorCode(t *testing.T, err error, expectedCode int) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var fiberErr *fiber.Error
if !errors.As(err, &fiberErr) {
t.Fatalf("expected *fiber.Error, got %T (%v)", err, err)
}
if fiberErr.Code != expectedCode {
t.Fatalf("expected fiber error code %d, got %d", expectedCode, fiberErr.Code)
}
}
func mustDate(t *testing.T, raw string) time.Time {
t.Helper()
value, err := time.Parse(dailyChecklistDateLayout, raw)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
func mustDateTime(t *testing.T, raw string) time.Time {
t.Helper()
value, err := time.Parse("2006-01-02 15:04:05", raw)
if err != nil {
t.Fatalf("failed parsing datetime %q: %v", raw, err)
}
return value
}
func strPtr(value string) *string {
v := value
return &v
}
@@ -5,10 +5,12 @@ import (
) )
type Create struct { type Create struct {
Date string `json:"date" validate:"required"` Date string `json:"date" validate:"required"`
KandangId uint `json:"kandang_id" validate:"required"` KandangId uint `json:"kandang_id" validate:"required"`
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"`
EmptyKandangEndDate string `json:"empty_kandang_end_date"`
} }
type Update struct { type Update struct {
@@ -18,6 +20,12 @@ type Update struct {
DeletedDocumentIDs *string `form:"deleted_document_ids" json:"deleted_document_ids"` DeletedDocumentIDs *string `form:"deleted_document_ids" json:"deleted_document_ids"`
} }
type BulkStatusUpdate struct {
IDs string `form:"ids" json:"ids" validate:"required_strict"`
Status string `form:"status" json:"status" validate:"required,oneof=APPROVED REJECTED"`
RejectReason *string `form:"reject_reason" json:"reject_reason"`
}
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,max=100,gt=0"`
@@ -6,11 +6,16 @@ import (
"math" "math"
"strconv" "strconv"
"strings" "strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/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"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -19,6 +24,8 @@ type ExpenseController struct {
ExpenseService service.ExpenseService ExpenseService service.ExpenseService
} }
const expenseExcelExportFetchLimit = 100
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController { func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
return &ExpenseController{ return &ExpenseController{
ExpenseService: expenseService, ExpenseService: expenseService,
@@ -26,10 +33,46 @@ func NewExpenseController(expenseService service.ExpenseService) *ExpenseControl
} }
func (u *ExpenseController) GetAll(c *fiber.Ctx) error { func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
if exportprogress.IsProgressExportRequest(c) {
query, err := exportprogress.ParseQuery(c)
if err != nil {
return err
}
rows, err := u.ExpenseService.GetProgressRows(c, query)
if err != nil {
return err
}
content, err := exportprogress.BuildWorkbook("Expenses", query, rows)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
}
filename := fmt.Sprintf("expenses_progress_%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)
}
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: strings.TrimSpace(c.Query("search", "")),
TransactionDate: strings.TrimSpace(c.Query("transaction_date", "")),
RealizationDate: strings.TrimSpace(c.Query("realization_date", "")),
LocationID: uint64(c.QueryInt("location_id", 0)),
VendorID: uint64(c.QueryInt("vendor_id", 0)),
Category: strings.TrimSpace(c.Query("category", "")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status", "")),
RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")),
ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)),
}
if isAllExpenseExcelExportRequest(c) {
allResults, err := u.getAllExpensesForExcel(c, query)
if err != nil {
return err
}
return exportExpenseListExcel(c, allResults)
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -56,6 +99,33 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
}) })
} }
func (u *ExpenseController) getAllExpensesForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]dto.ExpenseListDTO, error) {
query := *baseQuery
query.Page = 1
query.Limit = expenseExcelExportFetchLimit
results := make([]dto.ExpenseListDTO, 0)
for {
pageResults, total, err := u.ExpenseService.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 *ExpenseController) GetOne(c *fiber.Ctx) error { func (u *ExpenseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -264,6 +334,51 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error {
}) })
} }
func (u *ExpenseController) BulkApproveToStatus(c *fiber.Ctx) error {
req := new(validation.BulkApprovalRequest)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
targetStep, err := req.ResolveTarget()
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if req.RequiresDate(targetStep) && strings.TrimSpace(req.Date) == "" {
return fiber.NewError(fiber.StatusBadRequest, "date is required for REALISASI bulk approval")
}
if err := ensureExpenseBulkApprovalPermission(c, targetStep); err != nil {
return err
}
results, err := u.ExpenseService.BulkApproveToStatus(c, req, targetStep)
if err != nil {
return err
}
var (
data interface{}
message = "Bulk approve expense successfully"
)
if len(results) == 1 {
data = results[0]
} else {
message = "Bulk approve expenses successfully"
data = results
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: message,
Data: data,
})
}
func (u *ExpenseController) CreateRealization(c *fiber.Ctx) error { func (u *ExpenseController) CreateRealization(c *fiber.Ctx) error {
expenseID := c.Params("id") expenseID := c.Params("id")
id, err := strconv.Atoi(expenseID) id, err := strconv.Atoi(expenseID)
@@ -366,6 +481,31 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error {
}) })
} }
func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error {
requiredPerms := []string{}
switch targetStep {
case utils.ExpenseStepHeadArea:
requiredPerms = []string{m.P_ExpenseApprovalHeadArea}
case utils.ExpenseStepUnitVicePresident:
requiredPerms = []string{m.P_ExpenseApprovalUnitVicePresident}
case utils.ExpenseStepFinance:
requiredPerms = []string{m.P_ExpenseApprovalFinance}
case utils.ExpenseStepRealisasi:
requiredPerms = []string{m.P_ExpenseApprovalFinance, m.P_ExpenseCreateRealizations}
default:
return fiber.NewError(fiber.StatusBadRequest, "Invalid approval target")
}
for _, perm := range requiredPerms {
if !m.HasPermission(c, perm) {
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
}
}
return nil
}
func (u *ExpenseController) DeleteDocument(c *fiber.Ctx) error { func (u *ExpenseController) DeleteDocument(c *fiber.Ctx) error {
expenseID, err := strconv.Atoi(c.Params("id")) expenseID, err := strconv.Atoi(c.Params("id"))
if err != nil { if err != nil {
@@ -0,0 +1,225 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type expenseServiceStub struct {
getAllCalls []validation.Query
}
var _ service.ExpenseService = (*expenseServiceStub)(nil)
func (s *expenseServiceStub) GetAll(_ *fiber.Ctx, params *validation.Query) ([]dto.ExpenseListDTO, int64, error) {
callCopy := *params
s.getAllCalls = append(s.getAllCalls, callCopy)
switch params.Page {
case 1:
return []dto.ExpenseListDTO{
buildExpenseListForControllerTest("EXP-00001"),
buildExpenseListForControllerTest("EXP-00002"),
}, 3, nil
case 2:
return []dto.ExpenseListDTO{
buildExpenseListForControllerTest("EXP-00003"),
}, 3, nil
default:
return []dto.ExpenseListDTO{}, 3, nil
}
}
func (s *expenseServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.Create) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) UpdateOne(_ *fiber.Ctx, _ *validation.Update, _ uint) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) DeleteOne(_ *fiber.Ctx, _ uint64) error {
return nil
}
func (s *expenseServiceStub) CreateRealization(_ *fiber.Ctx, _ uint, _ *validation.CreateRealization) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) CompleteExpense(_ *fiber.Ctx, _ uint, _ *string) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) UpdateRealization(_ *fiber.Ctx, _ uint, _ *validation.UpdateRealization) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) DeleteDocument(_ *fiber.Ctx, _ uint, _ uint64, _ bool) error {
return nil
}
func (s *expenseServiceStub) Approval(_ *fiber.Ctx, _ *validation.ApprovalRequest, _ string) ([]dto.ExpenseDetailDTO, error) {
return nil, nil
}
func (s *expenseServiceStub) BulkApproveToStatus(_ *fiber.Ctx, _ *validation.BulkApprovalRequest, _ approvalutils.ApprovalStep) ([]dto.ExpenseDetailDTO, error) {
return nil, nil
}
func (s *expenseServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
return nil, nil
}
func TestExpenseControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/expenses?export=excel&type=all&page=9&limit=1&search=operasional",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !strings.Contains(disposition, "expenses_all_") {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getAllCalls) != 2 {
t.Fatalf("expected 2 GetAll calls, got %d", len(stub.getAllCalls))
}
firstCall := stub.getAllCalls[0]
secondCall := stub.getAllCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal paging page 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != expenseExcelExportFetchLimit || secondCall.Limit != expenseExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", expenseExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Search != "operasional" {
t.Fatalf("expected search filter to be forwarded, got %q", firstCall.Search)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
if got, _ := file.GetCellValue(expenseExportSheetName, "A1"); got != "No" {
t.Fatalf("expected A1 header to be No, got %q", got)
}
if got, _ := file.GetCellValue(expenseExportSheetName, "C2"); got != "EXP-00001" {
t.Fatalf("expected first row reference EXP-00001, got %q", got)
}
}
func TestExpenseControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(http.MethodGet, "/expenses?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func TestExpenseControllerGetAllProgressExportUnchanged(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/expenses?export=excel&type=progress&start_date=2026-04-01&end_date=2026-04-22&limit=0",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
if len(stub.getAllCalls) != 0 {
t.Fatalf("expected list GetAll not to be called for progress export, got %d calls", len(stub.getAllCalls))
}
}
func buildExpenseListForControllerTest(referenceNumber string) dto.ExpenseListDTO {
approvedAction := string(entity.ApprovalActionApproved)
return dto.ExpenseListDTO{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
ReferenceNumber: referenceNumber,
PoNumber: "PO-" + strings.TrimPrefix(referenceNumber, "EXP-"),
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Category: "BOP",
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
Location: &locationDTO.LocationRelationDTO{
Name: "Farm A",
},
},
GrandTotal: 1500000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Finance",
Action: &approvedAction,
},
}
}
@@ -0,0 +1,295 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const expenseExportSheetName = "Expenses"
func isAllExpenseExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportExpenseListExcel(c *fiber.Ctx, items []dto.ExpenseListDTO) error {
content, err := buildExpenseExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("expenses_all_%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 buildExpenseExportWorkbook(items []dto.ExpenseListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != expenseExportSheetName {
if err := file.SetSheetName(defaultSheet, expenseExportSheetName); err != nil {
return nil, err
}
}
if err := setExpenseExportColumns(file, expenseExportSheetName); err != nil {
return nil, err
}
if err := setExpenseExportHeaders(file, expenseExportSheetName); err != nil {
return nil, err
}
if err := setExpenseExportRows(file, expenseExportSheetName, items); err != nil {
return nil, err
}
if err := file.SetPanes(expenseExportSheetName, &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 setExpenseExportColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 8,
"B": 16,
"C": 20,
"D": 18,
"E": 18,
"F": 16,
"G": 24,
"H": 22,
"I": 16,
"J": 24,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setExpenseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No",
"No. PO",
"No. Referensi",
"Tanggal Realisasi",
"Tanggal Transaksi",
"Kategori",
"Produk",
"Lokasi",
"Grand Total",
"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", "J1", headerStyle)
}
func setExpenseExportRows(file *excelize.File, sheet string, items []dto.ExpenseListDTO) error {
if len(items) == 0 {
return nil
}
for i, item := range items {
row := strconv.Itoa(i + 2)
if err := file.SetCellValue(sheet, "A"+row, i+1); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+row, safeExpenseExportText(item.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+row, safeExpenseExportText(item.ReferenceNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+row, formatExpenseExportDate(item.RealizationDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, formatExpenseExportDate(&item.TransactionDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, safeExpenseExportText(item.Category)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, safeExpenseSupplierName(item.Supplier)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, safeExpenseLocationName(item.Location)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, safeExpenseExportNumber(item.GrandTotal)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, formatExpenseExportStatus(item.LatestApproval)); err != nil {
return err
}
}
lastRow := len(items) + 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: false,
},
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", "J"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
ordinalStyle, err := file.NewStyle(&excelize.Style{
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
}
if err := file.SetCellStyle(sheet, "A2", "A"+strconv.Itoa(lastRow), ordinalStyle); err != nil {
return err
}
numberFormat := "#,##0.##"
numberStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
CustomNumFmt: &numberFormat,
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, "I2", "I"+strconv.Itoa(lastRow), numberStyle)
}
func formatExpenseExportDate(value *time.Time) string {
if value == nil || value.IsZero() {
return "-"
}
t := *value
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(location)
}
return t.Format("02-01-2006")
}
func formatExpenseExportStatus(latestApproval *approvalDTO.ApprovalRelationDTO) string {
if latestApproval == nil {
return "-"
}
if latestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*latestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
return safeExpenseExportText(latestApproval.StepName)
}
func safeExpenseSupplierName(value *supplierDTO.SupplierRelationDTO) string {
if value == nil {
return "-"
}
return safeExpenseExportText(value.Name)
}
func safeExpenseLocationName(value *locationDTO.LocationRelationDTO) string {
if value == nil {
return "-"
}
return safeExpenseExportText(value.Name)
}
func safeExpenseExportNumber(value float64) float64 {
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return value
}
func safeExpenseExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,137 @@
package controller
import (
"bytes"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildExpenseExportWorkbookHeadersAndRows(t *testing.T) {
realizationDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
content, err := buildExpenseExportWorkbook([]dto.ExpenseListDTO{
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
PoNumber: "PO-00011",
ReferenceNumber: "EXP-00011",
RealizationDate: &realizationDate,
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Category: "BOP",
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
Location: &locationDTO.LocationRelationDTO{
Name: "Farm A",
},
},
GrandTotal: 1234567,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Finance",
},
},
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
PoNumber: "",
ReferenceNumber: "",
Category: "",
},
GrandTotal: 75000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Head Area",
Action: expenseStrPtr("REJECTED"),
},
},
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{},
GrandTotal: 0,
LatestApproval: nil,
},
})
if err != nil {
t.Fatalf("buildExpenseExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
sheets := file.GetSheetList()
if len(sheets) != 1 || sheets[0] != expenseExportSheetName {
t.Fatalf("expected single sheet %q, got %+v", expenseExportSheetName, sheets)
}
expectedHeaders := map[string]string{
"A1": "No",
"B1": "No. PO",
"C1": "No. Referensi",
"D1": "Tanggal Realisasi",
"E1": "Tanggal Transaksi",
"F1": "Kategori",
"G1": "Supplier",
"H1": "Lokasi",
"I1": "Grand Total",
"J1": "Status",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(expenseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
assertExpenseCellEquals(t, file, "A2", "1")
assertExpenseCellEquals(t, file, "B2", "PO-00011")
assertExpenseCellEquals(t, file, "C2", "EXP-00011")
assertExpenseCellEquals(t, file, "D2", "22-04-2026")
assertExpenseCellEquals(t, file, "E2", "22-04-2026")
assertExpenseCellEquals(t, file, "F2", "BOP")
assertExpenseCellEquals(t, file, "G2", "Supplier A")
assertExpenseCellEquals(t, file, "H2", "Farm A")
assertExpenseCellEquals(t, file, "J2", "Finance")
rawGrandTotal, err := file.GetCellValue(expenseExportSheetName, "I2", excelize.Options{RawCellValue: true})
if err != nil {
t.Fatalf("GetCellValue(I2, RawCellValue) failed: %v", err)
}
if rawGrandTotal != "1234567" {
t.Fatalf("expected raw I2 grand total 1234567, got %q", rawGrandTotal)
}
assertExpenseCellEquals(t, file, "B3", "-")
assertExpenseCellEquals(t, file, "C3", "-")
assertExpenseCellEquals(t, file, "D3", "-")
assertExpenseCellEquals(t, file, "E3", "-")
assertExpenseCellEquals(t, file, "F3", "-")
assertExpenseCellEquals(t, file, "G3", "-")
assertExpenseCellEquals(t, file, "H3", "-")
assertExpenseCellEquals(t, file, "J3", "Ditolak")
assertExpenseCellEquals(t, file, "J4", "-")
}
func assertExpenseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(expenseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
func expenseStrPtr(value string) *string {
return &value
}
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"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"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -20,6 +21,7 @@ type ExpenseRepository interface {
WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB
CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error)
DeleteOne(ctx context.Context, id uint) error DeleteOne(ctx context.Context, id uint) error
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
} }
type ExpenseRepositoryImpl struct { type ExpenseRepositoryImpl struct {
@@ -130,3 +132,64 @@ func (r *ExpenseRepositoryImpl) DeleteOne(ctx context.Context, id uint) error {
} }
return nil return nil
} }
func (r *ExpenseRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
query := r.DB().WithContext(ctx).
Table("expenses AS e").
Select(`
'Expenses' AS module,
COALESCE(pf.flock_name, loc.name, fallback_loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
CAST(DATE(e.transaction_date) AS TEXT) AS activity_date,
COUNT(*) AS count
`).
Joins("LEFT JOIN (SELECT DISTINCT expense_id, project_flock_kandang_id, kandang_id FROM expense_nonstocks) en ON en.expense_id = e.id").
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)").
Joins("LEFT JOIN locations loc ON loc.id = k.location_id").
Joins("LEFT JOIN locations fallback_loc ON fallback_loc.id = e.location_id").
Where("e.deleted_at IS NULL").
Where("DATE(e.transaction_date) >= DATE(?)", startDate).
Where("DATE(e.transaction_date) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
query = query.Where("e.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := query.
Group("DATE(e.transaction_date), COALESCE(pf.flock_name, loc.name, fallback_loc.name, 'Unknown Farm'), COALESCE(k.name, " + unassignedSQL + ", 'Unknown Kandang')").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := exportprogress.ParseActivityDate(item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
@@ -0,0 +1,72 @@
package repository
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestExpenseRepositoryGetProgressRows(t *testing.T) {
db := openExpenseProgressTestDB(t)
repo := NewExpenseRepository(db)
mustExec(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExec(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`)
mustExec(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExec(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`)
mustExec(t, db, `CREATE TABLE expenses (id INTEGER PRIMARY KEY, location_id INTEGER, transaction_date DATE, deleted_at DATETIME)`)
mustExec(t, db, `CREATE TABLE expense_nonstocks (id INTEGER PRIMARY KEY, expense_id INTEGER, project_flock_kandang_id INTEGER, kandang_id INTEGER)`)
mustExec(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Farm Location')`)
mustExec(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`)
mustExec(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1), (2, 'Kandang 2', 1)`)
mustExec(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1), (2, 1, 2)`)
mustExec(t, db, `INSERT INTO expenses (id, location_id, transaction_date, deleted_at) VALUES (1, 1, '2026-06-10', NULL), (2, 1, '2026-06-10', NULL)`)
mustExec(t, db, `INSERT INTO expense_nonstocks (id, expense_id, project_flock_kandang_id, kandang_id) VALUES
(1, 1, 1, NULL),
(2, 1, 1, NULL),
(3, 1, 2, NULL)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 grouped rows, got %d", len(rows))
}
assertProgressRow(t, rows, "Farm A", "Kandang 1", "2026-06-10", 1)
assertProgressRow(t, rows, "Farm A", "Kandang 2", "2026-06-10", 1)
assertProgressRow(t, rows, "Farm Location", "Farm-level / Unassigned", "2026-06-10", 1)
}
func openExpenseProgressTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
return db
}
func mustExec(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRow(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
+1
View File
@@ -31,6 +31,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService
route.Post("/approvals/head-area", m.RequirePermissions(m.P_ExpenseApprovalHeadArea), ctrl.Approval) route.Post("/approvals/head-area", m.RequirePermissions(m.P_ExpenseApprovalHeadArea), ctrl.Approval)
route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval)
route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval) route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval)
route.Post("/approvals/bulk", ctrl.BulkApproveToStatus)
route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization)
route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
@@ -5,8 +5,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -37,6 +39,8 @@ type ExpenseService interface {
UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error
Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error) Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error)
BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
} }
type expenseService struct { type expenseService struct {
@@ -83,6 +87,25 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
}) })
} }
func normalizeExpenseApprovalStatusFilter(raw string) string {
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(raw), " ", "_")) {
case "HEAD_AREA", "APPROVAL_HEAD_AREA":
return "Approval Head Area"
case "UNIT_VICE_PRESIDENT", "APPROVAL_UNIT_VICE_PRESIDENT", "BUSINESS_UNIT_VICE_PRESIDENT", "APPROVAL_BUSINESS_UNIT_VICE_PRESIDENT":
return "Approval Unit Vice President"
case "FINANCE", "APPROVAL_FINANCE":
return "Approval Finance"
case "REALISASI":
return "Realisasi"
case "SELESAI":
return "Selesai"
case "DITOLAK", "REJECTED":
return "REJECTED"
default:
return ""
}
}
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
@@ -95,10 +118,177 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id") db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
if params.Search != "" { db = db.Where("expenses.deleted_at IS NULL")
return db.Where("category ILIKE ?", "%"+params.Search+"%")
if params.TransactionDate != "" {
db = db.Where("DATE(expenses.transaction_date) = DATE(?)", params.TransactionDate)
} }
return db.Order("created_at DESC").Order("updated_at DESC") if params.RealizationDate != "" {
db = db.Where("DATE(expenses.realization_date) = DATE(?)", params.RealizationDate)
}
if params.LocationID > 0 {
db = db.Where("expenses.location_id = ?", params.LocationID)
}
if params.VendorID > 0 {
db = db.Where("expenses.supplier_id = ?", params.VendorID)
}
if params.Category != "" {
db = db.Where("expenses.category = ?", params.Category)
}
if params.ProjectFlockID > 0 {
projectFlockJSON := fmt.Sprintf("[%d]", params.ProjectFlockID)
db = db.Where(`(
EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
WHERE en.expense_id = expenses.id
AND (
pfk.project_flock_id = ? OR
en.kandang_id IN (
SELECT kandang_id
FROM project_flock_kandangs
WHERE project_flock_id = ?
)
)
) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, params.ProjectFlockID, params.ProjectFlockID, projectFlockJSON)
}
if params.ProjectFlockKandangID > 0 {
db = db.Where(`EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs selected_pfk ON selected_pfk.id = ?
WHERE en.expense_id = expenses.id
AND (
en.project_flock_kandang_id = ? OR
(selected_pfk.kandang_id IS NOT NULL AND en.kandang_id = selected_pfk.kandang_id)
)
)`, params.ProjectFlockKandangID, params.ProjectFlockKandangID)
}
latestApprovalSubQuery := s.Repository.DB().
WithContext(c.Context()).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, step_name, action, step_number").
Where("approvable_type = ?", utils.ApprovalWorkflowExpense.String()).
Order("approvable_id, action_at DESC, id DESC")
if approvalStatus := normalizeExpenseApprovalStatusFilter(params.ApprovalStatus); approvalStatus != "" {
if approvalStatus == "REJECTED" {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
} else {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND LOWER(latest_approval.step_name) = LOWER(?)
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
)`, latestApprovalSubQuery, approvalStatus, string(entity.ApprovalActionRejected))
}
}
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(params.RealizationStatus), " ", "_")) {
case "REALIZED", "SUDAH_REALISASI":
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
AND latest_approval.step_number >= 5
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
case "NOT_REALIZED", "BELUM_REALISASI":
db = db.Where(`NOT EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
AND latest_approval.step_number >= 5
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
db = db.Where(`NOT EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
case "REJECTED", "DITOLAK":
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
}
if search := strings.ToLower(strings.TrimSpace(params.Search)); search != "" {
like := "%" + search + "%"
db = db.Where(`(
LOWER(COALESCE(expenses.reference_number, '')) LIKE ?
OR LOWER(COALESCE(expenses.po_number, '')) LIKE ?
OR LOWER(COALESCE(expenses.category, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = expenses.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM locations l
WHERE l.id = expenses.location_id
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = expenses.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id
LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)
WHERE en.expense_id = expenses.id
AND (
LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
LOWER(COALESCE(k.name, '')) LIKE ?
)
)
OR EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = expenses.id
AND (
LOWER(COALESCE(a.step_name, '')) LIKE ? OR
LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR
LOWER(COALESCE(a.notes, '')) LIKE ?
)
)
)`,
like,
like,
like,
like,
like,
like,
like,
like,
utils.ApprovalWorkflowExpense.String(),
like,
like,
like,
)
}
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
}) })
if scopeErr != nil { if scopeErr != nil {
@@ -155,6 +345,14 @@ func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetail
return &responseDTO, nil return &responseDTO, nil
} }
func (s expenseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
locationScope, err := middleware.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, err
}
return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, locationScope.IDs, locationScope.Restrict)
}
func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) { func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
@@ -742,8 +940,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
return nil, err return nil, err
} }
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
realizationRepoTx := repository.NewExpenseRealizationRepository(tx) realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
@@ -780,12 +982,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
} }
} }
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
"realization_date": realizationDate,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
}
if s.DocumentSvc != nil && len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
for idx, file := range req.Documents { for idx, file := range req.Documents {
@@ -795,7 +991,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
Index: &idx, Index: &idx,
}) })
} }
actorID := uint(1) // TODO: replace with authenticated user id
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpenseRealization), DocumentableType: string(utils.DocumentableTypeExpenseRealization),
DocumentableID: uint64(expenseID), DocumentableID: uint64(expenseID),
@@ -807,6 +1002,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
} }
} }
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
"realization_date": realizationDate,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
}
approvalAction := entity.ApprovalActionCreated approvalAction := entity.ApprovalActionCreated
if _, err := approvalSvc.CreateApproval( if _, err := approvalSvc.CreateApproval(
c.Context(), c.Context(),
@@ -814,9 +1015,9 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
expenseID, expenseID,
utils.ExpenseStepRealisasi, utils.ExpenseStepRealisasi,
&approvalAction, &approvalAction,
uint(1), // TODO: replace with authenticated user id actorID,
nil); err != nil { nil,
); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
} }
@@ -834,6 +1035,205 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
return responseDTO, nil return responseDTO, nil
} }
func (s *expenseService) BulkApproveToStatus(c *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
approvableIDs := utils.UniqueUintSlice(req.ApprovableIds)
if len(approvableIDs) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
}
actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
var realizationDate time.Time
if req.RequiresDate(target) {
realizationDate, err = utils.ParseDateString(req.Date)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
}
}
invalidateFromDateByExpenseID := make(map[uint]time.Time, len(approvableIDs))
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
expenseRepoTx := repository.NewExpenseRepository(tx)
for _, id := range approvableIDs {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: expenseRepoTx.IdExists},
); err != nil {
return err
}
expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Preload("Nonstocks")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
}
latestApproval, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
}
if latestApproval == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Expense %d", id))
}
if latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionRejected {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is rejected and cannot be bulk approved", id))
}
currentStep := approvalutils.ApprovalStep(latestApproval.StepNumber)
if currentStep >= target {
currentStepName := utils.ExpenseApprovalSteps[currentStep]
targetStepName := utils.ExpenseApprovalSteps[target]
if currentStep == target {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already at %s step", id, targetStepName))
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already beyond %s step (current step: %s)", id, targetStepName, currentStepName))
}
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
for step := currentStep + 1; step <= target; step++ {
if step == utils.ExpenseStepRealisasi {
if err := s.createRealizationFromExpenseLines(c.Context(), tx, expense, realizationDate, actorID, req.Notes); err != nil {
return err
}
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate, realizationDate)
break
}
approvalAction := entity.ApprovalActionApproved
if _, err := approvalSvcTx.CreateApproval(
c.Context(),
utils.ApprovalWorkflowExpense,
id,
step,
&approvalAction,
actorID,
req.Notes,
); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
}
if step == utils.ExpenseStepFinance && expense.PoNumber == "" {
poNumber, err := s.generatePoNumber(tx, id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number")
}
if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{"po_number": poNumber}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number")
}
expense.PoNumber = poNumber
}
}
invalidateFromDateByExpenseID[id] = invalidateFromDate
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to bulk approve expenses")
}
results := make([]expenseDto.ExpenseDetailDTO, 0, len(approvableIDs))
for _, id := range approvableIDs {
responseDTO, err := s.GetOne(c, id)
if err != nil {
return nil, err
}
results = append(results, *responseDTO)
}
for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID {
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
}
return results, nil
}
func (s *expenseService) createRealizationFromExpenseLines(
ctx context.Context,
tx *gorm.DB,
expense *entity.Expense,
realizationDate time.Time,
actorID uint,
notes *string,
) error {
if expense == nil {
return fiber.NewError(fiber.StatusBadRequest, "Expense not found")
}
if tx == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Transaction is required")
}
if err := s.ensureProjectFlockNotClosedForExpense(ctx, expense); err != nil {
return err
}
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
expenseRepoTx := repository.NewExpenseRepository(tx)
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
for _, expenseNonstock := range expense.Nonstocks {
expenseNonstockID := expenseNonstock.Id
_, err := realizationRepoTx.GetByExpenseNonstockID(ctx, expenseNonstockID)
if err == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Realization already exists for expense nonstock %d", expenseNonstockID))
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization")
}
realization := &entity.ExpenseRealization{
ExpenseNonstockId: &expenseNonstockID,
Qty: expenseNonstock.Qty,
Price: expenseNonstock.Price,
Notes: expenseNonstock.Notes,
}
if err := realizationRepoTx.CreateOne(ctx, realization, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization")
}
}
if err := expenseRepoTx.PatchOne(ctx, uint(expense.Id), map[string]interface{}{
"realization_date": realizationDate,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
}
approvalAction := entity.ApprovalActionCreated
if _, err := approvalSvc.CreateApproval(
ctx,
utils.ApprovalWorkflowExpense,
uint(expense.Id),
utils.ExpenseStepRealisasi,
&approvalAction,
actorID,
notes,
); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
}
expense.RealizationDate = realizationDate
return nil
}
func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) { func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) {
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
@@ -1,7 +1,12 @@
package validation package validation
import ( import (
"errors"
"mime/multipart" "mime/multipart"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
) )
type Create struct { type Create struct {
@@ -37,9 +42,18 @@ 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,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=100"`
TransactionDate string `query:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
RealizationDate string `query:"realization_date" validate:"omitempty,datetime=2006-01-02"`
LocationID uint64 `query:"location_id" validate:"omitempty,gt=0"`
VendorID uint64 `query:"vendor_id" validate:"omitempty,gt=0"`
Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=100"`
RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"`
ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
} }
type CreateRealization struct { type CreateRealization struct {
@@ -66,3 +80,31 @@ type ApprovalRequest struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"` ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
Notes *string `json:"notes" form:"notes"` Notes *string `json:"notes" form:"notes"`
} }
type BulkApprovalRequest struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
Status string `json:"status" validate:"required,max=100"`
Date string `json:"date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
func (r *BulkApprovalRequest) ResolveTarget() (approvalutils.ApprovalStep, error) {
status := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(r.Status), " ", "_"))
switch status {
case "HEAD_AREA", "APPROVAL_HEAD_AREA":
return utils.ExpenseStepHeadArea, nil
case "UNIT_VICE_PRESIDENT", "APPROVAL_UNIT_VICE_PRESIDENT", "BUSINESS_UNIT_VICE_PRESIDENT", "APPROVAL_BUSINESS_UNIT_VICE_PRESIDENT":
return utils.ExpenseStepUnitVicePresident, nil
case "FINANCE", "APPROVAL_FINANCE":
return utils.ExpenseStepFinance, nil
case "REALISASI":
return utils.ExpenseStepRealisasi, nil
default:
return 0, errors.New("status must be one of HEAD_AREA, UNIT_VICE_PRESIDENT, FINANCE, or REALISASI")
}
}
func (r *BulkApprovalRequest) RequiresDate(target approvalutils.ApprovalStep) bool {
return target == utils.ExpenseStepRealisasi
}
@@ -1,14 +1,20 @@
package controller package controller
import ( import (
"fmt"
"math" "math"
"strconv" "strconv"
"strings" "strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/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"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -17,6 +23,8 @@ type DeliveryOrdersController struct {
DeliveryOrdersService service.DeliveryOrdersService DeliveryOrdersService service.DeliveryOrdersService
} }
const marketingExcelExportFetchLimit = 100
func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController { func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController {
return &DeliveryOrdersController{ return &DeliveryOrdersController{
DeliveryOrdersService: deliveryOrdersService, DeliveryOrdersService: deliveryOrdersService,
@@ -24,24 +32,23 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer
} }
func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
parseUintListParam := func(param string) ([]uint, error) { if exportprogress.IsProgressExportRequest(c) {
if param == "" { query, err := exportprogress.ParseQuery(c)
return nil, nil if err != nil {
return err
} }
parts := strings.Split(param, ",") rows, err := u.DeliveryOrdersService.GetProgressRows(c, query)
ids := make([]uint, 0, len(parts)) if err != nil {
for _, part := range parts { return err
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
} }
return ids, nil content, err := exportprogress.BuildWorkbook("Marketings", query, rows)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
}
filename := fmt.Sprintf("marketings_progress_%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)
} }
productIDs, err := parseUintListParam(c.Query("product_ids", "")) productIDs, err := parseUintListParam(c.Query("product_ids", ""))
@@ -50,13 +57,23 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
} }
query := &validation.DeliveryOrderQuery{ query := &validation.DeliveryOrderQuery{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search", "")), Search: strings.TrimSpace(c.Query("search", "")),
ProductIDs: productIDs, ProductIDs: productIDs,
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "), Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
CustomerId: uint(c.QueryInt("customer_id", 0)), CustomerId: uint(c.QueryInt("customer_id", 0)),
MarketingId: uint(c.QueryInt("marketing_id", 0)), MarketingId: uint(c.QueryInt("marketing_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
}
if isAllExcelExportRequest(c) {
allResults, err := u.getAllMarketingRowsForExcel(c, query)
if err != nil {
return err
}
return exportMarketingListExcel(c, allResults)
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -83,6 +100,56 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
}) })
} }
func (u *DeliveryOrdersController) getAllMarketingRowsForExcel(c *fiber.Ctx, baseQuery *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, error) {
query := *baseQuery
query.Page = 1
query.Limit = marketingExcelExportFetchLimit
results := make([]dto.MarketingListDTO, 0)
for {
pageResults, total, err := u.DeliveryOrdersService.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 parseUintListParam(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error { func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -152,3 +219,64 @@ func (u *DeliveryOrdersController) UpdateOne(c *fiber.Ctx) error {
Data: result, Data: result,
}) })
} }
func (u *DeliveryOrdersController) BulkApproveToStatus(c *fiber.Ctx) error {
req := new(validation.BulkApprovalRequest)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
targetStep, err := req.ResolveTarget()
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if req.RequiresDate(targetStep) && strings.TrimSpace(req.Date) == "" {
return fiber.NewError(fiber.StatusBadRequest, "date is required for DELIVERY bulk approval")
}
if err := ensureMarketingBulkApprovalPermission(c, targetStep); err != nil {
return err
}
results, err := u.DeliveryOrdersService.BulkApproveToStatus(c, req, targetStep)
if err != nil {
return err
}
var (
data interface{}
message = "Bulk approve marketing successfully"
)
if len(results) == 1 {
data = results[0]
} else {
message = "Bulk approve marketings successfully"
data = results
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: message,
Data: data,
})
}
func ensureMarketingBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error {
requiredPerms := []string{m.P_SalesOrderApproval}
if targetStep == utils.MarketingDeliveryOrder {
requiredPerms = append(requiredPerms, m.P_DeliveryUpdateOne)
}
for _, perm := range requiredPerms {
if !m.HasPermission(c, perm) {
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
}
}
return nil
}
@@ -0,0 +1,181 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type deliveryOrdersServiceStub struct {
getAllCalls []validation.DeliveryOrderQuery
}
var _ service.DeliveryOrdersService = (*deliveryOrdersServiceStub)(nil)
func (s *deliveryOrdersServiceStub) GetAll(_ *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) {
callCopy := *params
callCopy.ProductIDs = append([]uint(nil), params.ProductIDs...)
s.getAllCalls = append(s.getAllCalls, callCopy)
switch params.Page {
case 1:
return []dto.MarketingListDTO{
buildMarketingListForControllerTest("SO-00001"),
buildMarketingListForControllerTest("SO-00002"),
}, 3, nil
case 2:
return []dto.MarketingListDTO{
buildMarketingListForControllerTest("SO-00003"),
}, 3, nil
default:
return []dto.MarketingListDTO{}, 3, nil
}
}
func (s *deliveryOrdersServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) UpdateOne(_ *fiber.Ctx, _ *validation.DeliveryOrderUpdate, _ uint) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) BulkApproveToStatus(_ *fiber.Ctx, _ *validation.BulkApprovalRequest, _ approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
return nil, nil
}
func TestDeliveryOrdersControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &deliveryOrdersServiceStub{}
ctrl := NewDeliveryOrdersController(stub)
app.Get("/marketing", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/marketing?export=excel&type=all&page=9&limit=1&search=delivery&status=delivery_order&product_ids=1,2&customer_id=7&marketing_id=99",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !strings.Contains(disposition, "marketings_all_") {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getAllCalls) != 2 {
t.Fatalf("expected 2 GetAll calls, got %d", len(stub.getAllCalls))
}
firstCall := stub.getAllCalls[0]
secondCall := stub.getAllCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal paging to use pages 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != marketingExcelExportFetchLimit || secondCall.Limit != marketingExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", marketingExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Status != "delivery order" {
t.Fatalf("expected status to normalize underscore to space, got %q", firstCall.Status)
}
if firstCall.Search != "delivery" {
t.Fatalf("expected search to be forwarded, got %q", firstCall.Search)
}
if !reflect.DeepEqual(firstCall.ProductIDs, []uint{1, 2}) {
t.Fatalf("unexpected product_ids: %+v", firstCall.ProductIDs)
}
if firstCall.CustomerId != 7 || firstCall.MarketingId != 99 {
t.Fatalf("expected customer_id=7 and marketing_id=99, got customer_id=%d marketing_id=%d", firstCall.CustomerId, firstCall.MarketingId)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
if got, _ := file.GetCellValue(marketingExportSheetName, "A1"); got != "No. Order" {
t.Fatalf("expected A1 header to be No. Order, got %q", got)
}
if got, _ := file.GetCellValue(marketingExportSheetName, "A2"); got != "SO-00001" {
t.Fatalf("expected first row order number SO-00001, got %q", got)
}
}
func TestDeliveryOrdersControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &deliveryOrdersServiceStub{}
ctrl := NewDeliveryOrdersController(stub)
app.Get("/marketing", ctrl.GetAll)
req := httptest.NewRequest(http.MethodGet, "/marketing?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func buildMarketingListForControllerTest(orderNumber string) dto.MarketingListDTO {
return dto.MarketingListDTO{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: orderNumber,
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "tes",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "AJAT",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
{TotalPrice: 5206200000},
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Pengajuan",
},
}
}
@@ -0,0 +1,310 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const marketingExportSheetName = "Marketings"
func isAllExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportMarketingListExcel(c *fiber.Ctx, items []dto.MarketingListDTO) error {
content, err := buildMarketingExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("marketings_all_%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 buildMarketingExportWorkbook(items []dto.MarketingListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != marketingExportSheetName {
if err := file.SetSheetName(defaultSheet, marketingExportSheetName); err != nil {
return nil, err
}
}
if err := setMarketingExportColumns(file, marketingExportSheetName); err != nil {
return nil, err
}
if err := setMarketingExportHeaders(file, marketingExportSheetName); err != nil {
return nil, err
}
if err := setMarketingExportRows(file, marketingExportSheetName, items); err != nil {
return nil, err
}
if err := file.SetPanes(marketingExportSheetName, &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 setMarketingExportColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 16,
"B": 14,
"C": 18,
"D": 20,
"E": 18,
"F": 60,
"G": 24,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil
}
func setMarketingExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No. Order",
"Tanggal",
"Status",
"Customer",
"Grand Total",
"Products",
"Notes",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
cell := colName + "1"
if err := file.SetCellValue(sheet, cell, 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", "G1", headerStyle)
}
func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error {
if len(items) == 0 {
return nil
}
for i, item := range items {
rowNumber := i + 2
if err := file.SetCellValue(sheet, "A"+strconv.Itoa(rowNumber), safeMarketingExportText(item.SoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+strconv.Itoa(rowNumber), formatMarketingExportDate(item.SoDate)); err != nil {
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 {
return err
}
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Notes)); err != nil {
return err
}
}
lastRow := len(items) + 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", "G"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
moneyStyle, 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, "E2", "E"+strconv.Itoa(lastRow), moneyStyle)
}
func formatMarketingExportDate(value time.Time) string {
if value.IsZero() {
return "-"
}
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
value = value.In(location)
}
return value.Format("02-01-2006")
}
func formatMarketingExportStatus(item dto.MarketingListDTO) string {
if item.LatestApproval.Action != nil && strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
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 {
total := 0.0
for _, item := range items {
total += item.TotalPrice
}
return total
}
func formatMarketingRupiah(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()
}
func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,125 @@
package controller
import (
"bytes"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
items := []dto.MarketingListDTO{
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00762",
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "tes",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "AJAT",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
buildMarketingProductForExportTest("PAKAN GROWING CRUMBLE 8603 MALINDO", 5206200000),
buildMarketingProductForExportTest("PAKAN GROWING CRUMBLE 8603 MALINDO", 0),
buildMarketingProductForExportTest("295 GOLD PELLET", 0),
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Pengajuan",
},
},
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00761",
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "DHENIS",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
buildMarketingProductForExportTest("HS30 FOAM @20 LITER", 75000),
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Delivery Order",
Action: strPtr("REJECTED"),
},
},
}
content, err := buildMarketingExportWorkbook(items)
if err != nil {
t.Fatalf("buildMarketingExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
expectedHeaders := map[string]string{
"A1": "No. Order",
"B1": "Tanggal",
"C1": "Status",
"D1": "Customer",
"E1": "Grand Total",
"F1": "Products",
"G1": "Notes",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(marketingExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
assertCellEquals(t, file, "A2", "SO-00762")
assertCellEquals(t, file, "B2", "22-04-2026")
assertCellEquals(t, file, "C2", "Pengajuan")
assertCellEquals(t, file, "D2", "AJAT")
assertCellEquals(t, file, "E2", "Rp 5.206.200.000")
assertCellEquals(t, file, "F2", "PAKAN GROWING CRUMBLE 8603 MALINDO, 295 GOLD PELLET")
assertCellEquals(t, file, "G2", "tes")
assertCellEquals(t, file, "A3", "SO-00761")
assertCellEquals(t, file, "C3", "Ditolak")
assertCellEquals(t, file, "E3", "Rp 75.000")
assertCellEquals(t, file, "F3", "HS30 FOAM @20 LITER")
assertCellEquals(t, file, "G3", "-")
}
func assertCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(marketingExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
func buildMarketingProductForExportTest(name string, totalPrice float64) dto.DeliveryMarketingProductDTO {
return dto.DeliveryMarketingProductDTO{
TotalPrice: totalPrice,
ProductWarehouse: &productwarehouseDTO.ProductWarehousNestedDTO{
Product: &productDTO.ProductRelationDTO{
Name: name,
},
},
}
}
func strPtr(value string) *string {
return &value
}
@@ -64,6 +64,7 @@ type MarketingDeliveryProductDTO struct {
} }
type DeliveryItemDTO struct { type DeliveryItemDTO struct {
MarketingProductId uint `json:"marketing_product_id"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"` ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
Qty float64 `json:"qty"` Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"` UnitPrice float64 `json:"unit_price"`
@@ -153,7 +154,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD
return MarketingDeliveryProductDTO{ return MarketingDeliveryProductDTO{
Id: e.Id, Id: e.Id,
MarketingProductId: e.MarketingProductId, MarketingProductId: e.MarketingProductId,
Qty: e.UsageQty, Qty: e.UsageQty + e.PendingQty,
UnitPrice: e.UnitPrice, UnitPrice: e.UnitPrice,
TotalWeight: e.TotalWeight, TotalWeight: e.TotalWeight,
AvgWeight: e.AvgWeight, AvgWeight: e.AvgWeight,
@@ -328,6 +329,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
} }
deliveryItem := DeliveryItemDTO{ deliveryItem := DeliveryItemDTO{
MarketingProductId: product.MarketingProductId,
ProductWarehouse: product.ProductWarehouse, ProductWarehouse: product.ProductWarehouse,
Qty: product.Qty, Qty: product.Qty,
UnitPrice: product.UnitPrice, UnitPrice: product.UnitPrice,
@@ -0,0 +1,22 @@
package dto
import (
"testing"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func TestToMarketingDeliveryProductDTOIncludesPendingQty(t *testing.T) {
input := entity.MarketingDeliveryProduct{
Id: 1,
MarketingProductId: 42,
UsageQty: 15,
PendingQty: 5,
}
got := ToMarketingDeliveryProductDTO(input)
if got.Qty != 20 {
t.Fatalf("expected qty to include pending quantity, got %.2f", got.Qty)
}
}
@@ -0,0 +1,75 @@
package repository
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestMarketingRepositoryGetProgressRows(t *testing.T) {
db := openMarketingProgressTestDB(t)
repo := NewMarketingRepository(db)
mustExecMarketing(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExecMarketing(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`)
mustExecMarketing(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExecMarketing(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`)
mustExecMarketing(t, db, `CREATE TABLE warehouses (id INTEGER PRIMARY KEY, location_id INTEGER, kandang_id INTEGER)`)
mustExecMarketing(t, db, `CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, warehouse_id INTEGER, project_flock_kandang_id INTEGER)`)
mustExecMarketing(t, db, `CREATE TABLE marketings (id INTEGER PRIMARY KEY, so_date DATE, deleted_at DATETIME)`)
mustExecMarketing(t, db, `CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, marketing_id INTEGER, product_warehouse_id INTEGER)`)
mustExecMarketing(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A'), (2, 'Location B')`)
mustExecMarketing(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`)
mustExecMarketing(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`)
mustExecMarketing(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1)`)
mustExecMarketing(t, db, `INSERT INTO warehouses (id, location_id, kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`)
mustExecMarketing(t, db, `INSERT INTO product_warehouses (id, warehouse_id, project_flock_kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`)
mustExecMarketing(t, db, `INSERT INTO marketings (id, so_date, deleted_at) VALUES (1, '2026-06-12', NULL), (2, '2026-06-12', NULL)`)
mustExecMarketing(t, db, `INSERT INTO marketing_products (id, marketing_id, product_warehouse_id) VALUES
(1, 1, 1),
(2, 1, 1),
(3, 2, 2)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 grouped rows, got %d", len(rows))
}
assertProgressRowMarketing(t, rows, "Farm A", "Kandang 1", "2026-06-12", 1)
assertProgressRowMarketing(t, rows, "Location B", "Farm-level / Unassigned", "2026-06-12", 1)
}
func openMarketingProgressTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
return db
}
func mustExecMarketing(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRowMarketing(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
@@ -3,7 +3,9 @@ package repository
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"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"
"gorm.io/gorm" "gorm.io/gorm"
@@ -14,6 +16,7 @@ type MarketingRepository interface {
IdExists(ctx context.Context, id uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error)
GetNextSequence(ctx context.Context) (uint, error) GetNextSequence(ctx context.Context) (uint, error)
NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error)
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
} }
type MarketingRepositoryImpl struct { type MarketingRepositoryImpl struct {
@@ -55,3 +58,67 @@ func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB)
return soNumber, nil return soNumber, nil
} }
func (r *MarketingRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
subQuery := r.DB().WithContext(ctx).
Table("marketings AS m").
Select(`
DISTINCT m.id AS marketing_id,
'Marketings' AS module,
COALESCE(pf.flock_name, loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
CAST(DATE(m.so_date) AS TEXT) AS activity_date
`).
Joins("JOIN marketing_products mp ON mp.marketing_id = m.id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("LEFT JOIN kandangs k ON k.id = COALESCE(pfk.kandang_id, w.kandang_id)").
Joins("LEFT JOIN locations loc ON loc.id = COALESCE(k.location_id, w.location_id)").
Where("m.deleted_at IS NULL").
Where("DATE(m.so_date) >= DATE(?)", startDate).
Where("DATE(m.so_date) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
subQuery = subQuery.Where("w.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := r.DB().WithContext(ctx).
Table("(?) AS progress_rows", subQuery).
Select("module, farm_name, kandang_name, activity_date, COUNT(*) AS count").
Group("module, farm_name, kandang_name, activity_date").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := exportprogress.ParseActivityDate(item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
+1
View File
@@ -23,6 +23,7 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde
route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne)
route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne)
route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval)
route.Post("/approvals/bulk", deliveryOrdersCtrl.BulkApproveToStatus)
route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne) route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne)
route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne) route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne)
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
@@ -20,6 +21,7 @@ import (
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -32,6 +34,8 @@ type DeliveryOrdersService interface {
GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error)
CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error)
UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error)
BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
} }
type deliveryOrdersService struct { type deliveryOrdersService struct {
@@ -81,6 +85,102 @@ func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Products.DeliveryProduct") Preload("Products.DeliveryProduct")
} }
func (s deliveryOrdersService) marketingOwnerRelationQuery(ctx context.Context) *gorm.DB {
return s.MarketingRepo.DB().
WithContext(ctx).
Table("marketing_products mp").
Select("1").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN kandangs k ON k.id = COALESCE(pfk.kandang_id, w.kandang_id)").
Where("mp.marketing_id = marketings.id")
}
func (s deliveryOrdersService) marketingAttributionRelationQuery(ctx context.Context) *gorm.DB {
baseDB := s.MarketingRepo.DB().WithContext(ctx)
return baseDB.
Table("marketing_delivery_products mdp").
Select("1").
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = mdp.id", commonRepo.MarketingDeliveryAttributionRowsQuery(baseDB)).
Joins("JOIN project_flock_kandangs pfk_attr ON pfk_attr.id = mda.project_flock_kandang_id").
Joins("JOIN project_flocks pf_attr ON pf_attr.id = pfk_attr.project_flock_id").
Joins("JOIN kandangs k_attr ON k_attr.id = pfk_attr.kandang_id").
Where("mp.marketing_id = marketings.id")
}
func (s deliveryOrdersService) applyMarketingProjectFlockFilter(ctx context.Context, db *gorm.DB, projectFlockID, projectFlockKandangID uint) *gorm.DB {
if projectFlockID > 0 {
db = db.Where(
"(EXISTS (?) OR EXISTS (?))",
s.marketingOwnerRelationQuery(ctx).Where("pfk.project_flock_id = ?", projectFlockID),
s.marketingAttributionRelationQuery(ctx).Where("mda.project_flock_id = ?", projectFlockID),
)
}
if projectFlockKandangID > 0 {
db = db.Where(
"(EXISTS (?) OR EXISTS (?))",
s.marketingOwnerRelationQuery(ctx).Where("pw.project_flock_kandang_id = ?", projectFlockKandangID),
s.marketingAttributionRelationQuery(ctx).Where("mda.project_flock_kandang_id = ?", projectFlockKandangID),
)
}
return db
}
func (s deliveryOrdersService) applyMarketingSearchFilter(ctx context.Context, db *gorm.DB, rawSearch string) *gorm.DB {
searchPattern := "%" + strings.TrimSpace(rawSearch) + "%"
if searchPattern == "%%" {
return db
}
return db.Where(
`(
marketings.so_number ILIKE ? OR
EXISTS (
SELECT 1
FROM customers c
WHERE c.id = marketings.customer_id
AND c.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM users su
WHERE su.id = marketings.sales_person_id
AND su.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE mp.marketing_id = marketings.id
AND w.name ILIKE ?
) OR
EXISTS (?) OR
EXISTS (?)
)`,
searchPattern,
searchPattern,
searchPattern,
searchPattern,
searchPattern,
s.marketingOwnerRelationQuery(ctx).Where("pf.flock_name ILIKE ? OR k.name ILIKE ?", searchPattern, searchPattern),
s.marketingAttributionRelationQuery(ctx).Where("pf_attr.flock_name ILIKE ? OR k_attr.name ILIKE ?", searchPattern, searchPattern),
)
}
func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) { func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil { if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil {
return nil, err return nil, err
@@ -154,41 +254,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
} }
} }
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
db = db.Where(`(
marketings.so_number ILIKE ? OR
EXISTS (
SELECT 1
FROM customers c
WHERE c.id = marketings.customer_id
AND c.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM users su
WHERE su.id = marketings.sales_person_id
AND su.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE mp.marketing_id = marketings.id
AND w.name ILIKE ?
)
)`, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern)
}
if len(params.ProductIDs) > 0 { if len(params.ProductIDs) > 0 {
db = db.Where(`EXISTS ( db = db.Where(`EXISTS (
SELECT 1 SELECT 1
@@ -204,6 +269,9 @@ 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)
} }
db = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID)
db = s.applyMarketingSearchFilter(c.Context(), db, params.Search)
if scope.Restrict { if scope.Restrict {
if len(scope.IDs) == 0 { if len(scope.IDs) == 0 {
return db.Where("1 = 0") return db.Where("1 = 0")
@@ -247,6 +315,14 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return result, total, nil return result, total, nil
} }
func (s deliveryOrdersService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
scope, err := m.ResolveLocationScope(c, s.MarketingRepo.DB())
if err != nil {
return nil, err
}
return s.MarketingRepo.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
}
func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) { func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err return nil, err
@@ -544,6 +620,192 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return s.getMarketingWithDeliveries(c, id) return s.getMarketingWithDeliveries(c, id)
} }
func (s deliveryOrdersService) BulkApproveToStatus(c *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
approvableIDs := utils.UniqueUintSlice(req.ApprovableIds)
if len(approvableIDs) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
}
for _, id := range approvableIDs {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
}
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
var deliveryDate time.Time
if req.RequiresDate(target) {
deliveryDate, err = utils.ParseDateString(req.Date)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
}
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
marketingRepoTx := marketingRepo.NewMarketingRepository(tx)
for _, id := range approvableIDs {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: marketingRepoTx.IdExists},
); err != nil {
return err
}
marketing, err := marketingRepoTx.GetByID(c.Context(), id, s.withRelations)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing %d not found", id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
}
latestApproval, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status")
}
if latestApproval == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Marketing %d", id))
}
if latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionRejected {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is rejected and cannot be bulk approved", id))
}
currentStep := approvalutils.ApprovalStep(latestApproval.StepNumber)
if currentStep >= target {
currentStepName := utils.MarketingApprovalSteps[currentStep]
targetStepName := utils.MarketingApprovalSteps[target]
if currentStep == target {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is already at %s step", id, targetStepName))
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is already beyond %s step (current step: %s)", id, targetStepName, currentStepName))
}
if len(marketing.Products) > 0 {
pwIDs := make([]uint, 0, len(marketing.Products))
for _, product := range marketing.Products {
if product.ProductWarehouseId != 0 {
pwIDs = append(pwIDs, product.ProductWarehouseId)
}
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), tx, pwIDs); err != nil {
return err
}
}
for step := currentStep + 1; step <= target; step++ {
if step == utils.MarketingDeliveryOrder {
if err := s.createDeliveryFromMarketingProducts(c.Context(), tx, marketing, deliveryDate, actorID); err != nil {
return err
}
}
approvalAction := entity.ApprovalActionApproved
if _, err := approvalSvcTx.CreateApproval(
c.Context(),
utils.ApprovalWorkflowMarketing,
id,
step,
&approvalAction,
actorID,
req.Notes,
); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
}
}
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to bulk approve marketings")
}
results := make([]dto.MarketingDetailDTO, 0, len(approvableIDs))
for _, id := range approvableIDs {
result, err := s.getMarketingWithDeliveries(c, id)
if err != nil {
return nil, err
}
results = append(results, *result)
}
return results, nil
}
func (s deliveryOrdersService) createDeliveryFromMarketingProducts(
ctx context.Context,
tx *gorm.DB,
marketing *entity.Marketing,
deliveryDate time.Time,
actorID uint,
) error {
if marketing == nil {
return fiber.NewError(fiber.StatusBadRequest, "Marketing not found")
}
if tx == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Transaction is required")
}
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(tx)
for _, marketingProduct := range marketing.Products {
deliveryProduct := marketingProduct.DeliveryProduct
if deliveryProduct == nil {
record, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(ctx, marketingProduct.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Delivery product for marketing product %d not found", marketingProduct.Id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product")
}
deliveryProduct = record
}
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
deliveryDateCopy := deliveryDate
deliveryProduct.ProductWarehouseId = marketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = marketingProduct.UnitPrice
deliveryProduct.AvgWeight = marketingProduct.AvgWeight
deliveryProduct.WeightPerConvertion = marketingProduct.WeightPerConvertion
deliveryProduct.TotalWeight = marketingProduct.TotalWeight
deliveryProduct.TotalPrice = marketingProduct.TotalPrice
deliveryProduct.DeliveryDate = &deliveryDateCopy
requestedQty := marketingProduct.Qty
if requestedQty != oldRequestedQty {
if oldRequestedQty > 0 {
if err := s.releaseDeliveryStock(ctx, tx, deliveryProduct, &marketingProduct, actorID); err != nil {
return err
}
}
if requestedQty > 0 {
if err := s.consumeDeliveryStock(ctx, tx, deliveryProduct, &marketingProduct, requestedQty, actorID); err != nil {
return err
}
}
}
if err := marketingDeliveryProductRepositoryTx.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
}
}
return nil
}
func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) { func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) {
if marketingType == string(utils.MarketingTypeTrading) { if marketingType == string(utils.MarketingTypeTrading) {
totalWeight = 0 totalWeight = 0
@@ -1105,7 +1367,7 @@ func (s deliveryOrdersService) resolveMarketingRequestedUsageQty(ctx context.Con
var usageQty float64 var usageQty float64
if err := tx.WithContext(ctx). if err := tx.WithContext(ctx).
Table("marketing_delivery_products"). Table("marketing_delivery_products").
Select("usage_qty"). Select("usage_qty + pending_qty").
Where("id = ?", deliveryProductID). Where("id = ?", deliveryProductID).
Scan(&usageQty).Error; err != nil { Scan(&usageQty).Error; err != nil {
return 0 return 0
@@ -22,13 +22,15 @@ type DeliveryOrderUpdate struct {
} }
type DeliveryOrderQuery struct { type DeliveryOrderQuery 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,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"` Search string `query:"search" validate:"omitempty,max=100"`
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"` ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
Status string `query:"status" validate:"omitempty,max=50"` Status string `query:"status" validate:"omitempty,max=50"`
CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"` CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"`
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"`
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
} }
type DeliveryOrderApprove struct { type DeliveryOrderApprove struct {
@@ -1,5 +1,13 @@
package validation package validation
import (
"errors"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
)
type Create struct { type Create struct {
CustomerId uint `json:"customer_id" validate:"required,gt=0"` CustomerId uint `json:"customer_id" validate:"required,gt=0"`
SalesPersonId uint `json:"sales_person_id" validate:"required,gt=0"` SalesPersonId uint `json:"sales_person_id" validate:"required,gt=0"`
@@ -33,3 +41,27 @@ 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 BulkApprovalRequest struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
Status string `json:"status" validate:"required,max=100"`
Date string `json:"date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
func (r *BulkApprovalRequest) ResolveTarget() (approvalutils.ApprovalStep, error) {
status := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(r.Status), " ", "_"))
switch status {
case "SALES_ORDER":
return utils.MarketingStepSalesOrder, nil
case "DELIVERY", "DELIVERY_ORDER":
return utils.MarketingDeliveryOrder, nil
default:
return 0, errors.New("status must be one of SALES_ORDER or DELIVERY")
}
}
func (r *BulkApprovalRequest) RequiresDate(target approvalutils.ApprovalStep) bool {
return target == utils.MarketingDeliveryOrder
}
@@ -13,7 +13,7 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo
ctrl := controller.NewProjectFlockKandangController(s) ctrl := controller.NewProjectFlockKandangController(s)
route := v1.Group("/project-flock-kandangs") route := v1.Group("/project-flock-kandangs")
// route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll) route.Get("/", ctrl.GetAll)
route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne)
// route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing)
@@ -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 isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil { if _, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil {
return serr return serr
} else { } else {
dtoResult.IsTransition = isTransition dtoResult.IsTransition = false
dtoResult.IsLaying = isLaying dtoResult.IsLaying = isLaying
} }
applyCutOverLayingLookupOverride(&dtoResult) applyCutOverLayingLookupOverride(&dtoResult)
@@ -359,6 +359,40 @@ func applyCutOverLayingLookupOverride(result *dto.ProjectFlockKandangDTO) {
result.IsLaying = true result.IsLaying = true
} }
func (u *ProjectflockController) UpdateOne(c *fiber.Ctx) error {
param := c.Params("id")
req := new(validation.Update)
id, err := strconv.Atoi(param)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.ProjectflockService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
var period int
if periods, err := u.ProjectflockService.GetProjectPeriods(c, []uint{uint(id)}); err == nil {
if p, ok := periods[uint(id)]; ok {
period = p
}
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update projectflock successfully",
Data: dto.ToProjectFlockListDTOWithPeriod(*result, period),
})
}
func (u *ProjectflockController) Resubmit(c *fiber.Ctx) error { func (u *ProjectflockController) Resubmit(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
req := new(validation.Resubmit) req := new(validation.Resubmit)
@@ -17,6 +17,7 @@ type ProjectFlockKandangRepository interface {
GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error)
GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error)
UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error
UpdatePeriodByProjectFlockID(ctx context.Context, projectFlockID uint, period int) (int64, error)
CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error
DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error
GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error)
@@ -525,6 +526,19 @@ func (r *projectFlockKandangRepositoryImpl) UpdateClosedAt(ctx context.Context,
Update("closed_at", t).Error Update("closed_at", t).Error
} }
// UpdatePeriodByProjectFlockID updates the period column on every pivot row that
// belongs to the given project flock. Returns the number of rows affected.
func (r *projectFlockKandangRepositoryImpl) UpdatePeriodByProjectFlockID(ctx context.Context, projectFlockID uint, period int) (int64, error) {
result := r.db.WithContext(ctx).
Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID).
Update("period", period)
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func (r *projectFlockKandangRepositoryImpl) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) { func (r *projectFlockKandangRepositoryImpl) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) {
if kandangID == 0 { if kandangID == 0 {
return false, nil return false, nil
@@ -15,13 +15,14 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj
route := v1.Group("/project-flocks") route := v1.Group("/project-flocks")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/",m.RequirePermissions(m.P_ProjectFlockGetAll),ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_ProjectFlockGetAll), ctrl.GetAll)
route.Post("/",m.RequirePermissions(m.P_ProjectFlockCreate), ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_ProjectFlockCreate), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockGetOne), ctrl.GetOne)
route.Delete("/:id",m.RequirePermissions(m.P_ProjectFlockGetAll), ctrl.DeleteOne) route.Patch("/:id", m.RequirePermissions(m.P_ProjectFlockCreate), ctrl.UpdateOne)
route.Get("/kandangs/lookup",m.RequirePermissions(m.P_ProjectFlockLookup), ctrl.LookupProjectFlockKandang) route.Delete("/:id", m.RequirePermissions(m.P_ProjectFlockGetAll), ctrl.DeleteOne)
route.Post("/approvals",m.RequirePermissions(m.P_ProjectFlockApprove), ctrl.Approval) route.Get("/kandangs/lookup", m.RequirePermissions(m.P_ProjectFlockLookup), ctrl.LookupProjectFlockKandang)
route.Get("/locations/:location_id/periods",m.RequirePermissions(m.P_ProjectFlockNextPeriod), ctrl.GetPeriodSummary) route.Post("/approvals", m.RequirePermissions(m.P_ProjectFlockApprove), ctrl.Approval)
route.Put("/:id/resubmit",m.RequirePermissions(m.P_ProjectFlockResubmit), ctrl.Resubmit) route.Get("/locations/:location_id/periods", m.RequirePermissions(m.P_ProjectFlockNextPeriod), ctrl.GetPeriodSummary)
route.Put("/:id/resubmit", m.RequirePermissions(m.P_ProjectFlockResubmit), ctrl.Resubmit)
} }
@@ -51,6 +51,7 @@ type ProjectflockService interface {
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error)
EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error
} }
@@ -1273,6 +1274,52 @@ func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id
return s.getOneEntityOnly(c, id) return s.getOneEntityOnly(c, id)
} }
// UpdateOne updates mutable fields of a project flock.
// Currently only the `period` is updatable; the value is applied to every
// project_flock_kandang pivot row belonging to the project flock so it stays
// consistent with how periods are provisioned in CreateOne/Resubmit.
func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if req.Period == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "period is required")
}
existing, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found")
}
if err != nil {
s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
affected, err := s.pivotRepoWithTx(dbTransaction).UpdatePeriodByProjectFlockID(c.Context(), existing.Id, *req.Period)
if err != nil {
return err
}
if affected == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock tidak memiliki kandang yang dapat diperbarui periodenya")
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists")
}
s.Log.Errorf("Failed to update projectflock %d period: %+v", id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock")
}
return s.getOneEntityOnly(c, id)
}
func (s projectflockService) UpsertProjectBudget(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, budgets []validation.ProjectBudget) error { func (s projectflockService) UpsertProjectBudget(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, budgets []validation.ProjectBudget) error {
if len(budgets) == 0 { if len(budgets) == 0 {
@@ -41,3 +41,7 @@ type Resubmit struct {
KandangIds []uint `json:"kandang_ids" validate:"required_strict,min=1,dive,gt=0"` KandangIds []uint `json:"kandang_ids" validate:"required_strict,min=1,dive,gt=0"`
ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required_strict,min=1,dive"` ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required_strict,min=1,dive"`
} }
type Update struct {
Period *int `json:"period" validate:"required,number,gt=0"`
}
@@ -1,11 +1,13 @@
package controller package controller
import ( import (
"fmt"
"math" "math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"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"
@@ -25,21 +27,46 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
} }
func (u *RecordingController) GetAll(c *fiber.Ctx) error { func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0) projectFlockKandangID := c.QueryInt("project_flock_kandang_id", 0)
exportType := strings.TrimSpace(c.Query("export")) exportType := strings.TrimSpace(c.Query("export"))
if exportprogress.IsProgressExportRequest(c) {
query, err := exportprogress.ParseQuery(c)
if err != nil {
return err
}
rows, err := u.RecordingService.GetProgressRows(c, query)
if err != nil {
return err
}
content, err := exportprogress.BuildWorkbook("Recordings", query, rows)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
}
filename := fmt.Sprintf("recordings_progress_%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)
}
page := c.QueryInt("page", 1) page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10) limit := c.QueryInt("limit", 10)
offset := (page - 1) * limit offset := (page - 1) * limit
query := &validation.Query{ query := &validation.Query{
Page: page, Page: page,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
Search: c.Query("search"), Search: strings.TrimSpace(c.Query("search")),
ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)),
AreaId: uint(c.QueryInt("area_id", 0)),
LocationId: uint(c.QueryInt("location_id", 0)),
KandangId: uint(c.QueryInt("kandang_id", 0)),
ProjectFlockCategory: strings.TrimSpace(c.Query("project_flock_category")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
} }
if projectFlockID > 0 { if projectFlockKandangID > 0 {
query.ProjectFlockKandangId = uint(projectFlockID) query.ProjectFlockKandangId = uint(projectFlockKandangID)
} }
result, totalResults, err := u.RecordingService.GetAll(c, query) result, totalResults, err := u.RecordingService.GetAll(c, query)
@@ -8,8 +8,11 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"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"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -18,10 +21,10 @@ type RecordingRepository interface {
WithRelations(db *gorm.DB) *gorm.DB WithRelations(db *gorm.DB) *gorm.DB
WithRelationsList(db *gorm.DB) *gorm.DB WithRelationsList(db *gorm.DB) *gorm.DB
ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB ApplyListFilters(db *gorm.DB, params *validation.Query) *gorm.DB
ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB ApplyListCountFilters(db *gorm.DB, params *validation.Query) *gorm.DB
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error)
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
@@ -74,6 +77,7 @@ type RecordingRepository interface {
GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) ([]uint, error) GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) ([]uint, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
} }
type RecordingRepositoryImpl struct { type RecordingRepositoryImpl struct {
@@ -145,36 +149,89 @@ func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard") Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
} }
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB { func (r *RecordingRepositoryImpl) latestApprovalSubQuery(db *gorm.DB) *gorm.DB {
return db.Session(&gorm.Session{NewDB: true}).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, action, step_name, notes").
Where("approvable_type = ?", utils.ApprovalWorkflowRecording.String()).
Order("approvable_id, action_at DESC, id DESC")
}
func (r *RecordingRepositoryImpl) applyStructuredListFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
if params == nil {
return db
}
if params.ProjectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", params.ProjectFlockKandangId)
}
if params.ProjectFlockId != 0 {
db = db.Where("pfk.project_flock_id = ?", params.ProjectFlockId)
}
if params.KandangId != 0 {
db = db.Where("pfk.kandang_id = ?", params.KandangId)
}
if params.LocationId != 0 {
db = db.Where("pf.location_id = ?", params.LocationId)
}
if params.AreaId != 0 {
db = db.Where("pf.area_id = ?", params.AreaId)
}
if params.ProjectFlockCategory != "" {
db = db.Where("UPPER(COALESCE(pf.category, '')) = ?", strings.ToUpper(strings.TrimSpace(params.ProjectFlockCategory)))
}
if params.ApprovalStatus != "" {
db = db.Where(
`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = recordings.id
AND UPPER(COALESCE(CAST(latest_approval.action AS TEXT), '')) = ?
)`,
r.latestApprovalSubQuery(db),
strings.ToUpper(strings.TrimSpace(params.ApprovalStatus)),
)
}
return db
}
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
search := ""
if params != nil {
search = params.Search
}
db = r.WithRelationsList(db) db = r.WithRelationsList(db)
db = db. db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
if projectFlockKandangId != 0 { Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id")
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) db = r.applyStructuredListFilters(db, params)
}
db = r.ApplySearchFilters(db, search) db = r.ApplySearchFilters(db, search)
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
} }
func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB { func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
search := ""
if params != nil {
search = params.Search
}
db = db. db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
if projectFlockKandangId != 0 { Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id")
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) db = r.applyStructuredListFilters(db, params)
}
db = r.ApplySearchFilters(db, search) db = r.ApplySearchFilters(db, search)
return db return db
} }
func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) { func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) {
var ( var (
records []entity.Recording records []entity.Recording
total int64 total int64
) )
countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId) countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), params)
if modifier != nil { if modifier != nil {
countQ = modifier(countQ) countQ = modifier(countQ)
} }
@@ -182,7 +239,7 @@ func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset,
return nil, 0, err return nil, 0, err
} }
listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId) listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), params)
if modifier != nil { if modifier != nil {
listQ = modifier(listQ) listQ = modifier(listQ)
} }
@@ -216,6 +273,8 @@ func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch stri
Joins("LEFT JOIN warehouses ws ON ws.id = pws.warehouse_id"). Joins("LEFT JOIN warehouses ws ON ws.id = pws.warehouse_id").
Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id"). Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id").
Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id"). Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id").
Joins("LEFT JOIN users cu ON cu.id = recordings.created_by").
Joins("LEFT JOIN (?) AS latest_approval ON latest_approval.approvable_id = recordings.id", r.latestApprovalSubQuery(db)).
Where(` Where(`
LOWER(pf.flock_name) LIKE ? LOWER(pf.flock_name) LIKE ?
OR LOWER(k.name) LIKE ? OR LOWER(k.name) LIKE ?
@@ -223,8 +282,12 @@ func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch stri
OR LOWER(l.address) LIKE ? OR LOWER(l.address) LIKE ?
OR LOWER(ws.name) LIKE ? OR LOWER(ws.name) LIKE ?
OR LOWER(wd.name) LIKE ? OR LOWER(wd.name) LIKE ?
OR LOWER(we.name) LIKE ?`, OR LOWER(we.name) LIKE ?
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, OR LOWER(COALESCE(cu.name, '')) LIKE ?
OR LOWER(COALESCE(latest_approval.step_name, '')) LIKE ?
OR LOWER(COALESCE(CAST(latest_approval.action AS TEXT), '')) LIKE ?
OR LOWER(COALESCE(latest_approval.notes, '')) LIKE ?`,
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
) )
return db.Where("recordings.id IN (?)", subQuery) return db.Where("recordings.id IN (?)", subQuery)
} }
@@ -250,6 +313,65 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C
return &record, nil return &record, nil
} }
func (r *RecordingRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
query := r.DB().WithContext(ctx).
Table("recordings AS r").
Select(`
'Recordings' AS module,
COALESCE(pf.flock_name, loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
CAST(DATE(r.record_datetime) AS TEXT) AS activity_date,
COUNT(*) AS count
`).
Joins("JOIN project_flock_kandangs pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("JOIN kandangs k ON k.id = pfk.kandang_id").
Joins("LEFT JOIN locations loc ON loc.id = k.location_id").
Where("r.deleted_at IS NULL").
Where("DATE(r.record_datetime) >= DATE(?)", startDate).
Where("DATE(r.record_datetime) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
query = query.Where("pf.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := query.
Group("DATE(r.record_datetime), COALESCE(pf.flock_name, loc.name, 'Unknown Farm'), COALESCE(k.name, " + unassignedSQL + ", 'Unknown Kandang')").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := exportprogress.ParseActivityDate(item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) { func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) {
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required") return nil, errors.New("project_flock_kandang_id is required")
@@ -0,0 +1,68 @@
package repository
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestRecordingRepositoryGetProgressRows(t *testing.T) {
db := openRecordingProgressTestDB(t)
repo := NewRecordingRepository(db)
mustExecRecording(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExecRecording(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT, location_id INTEGER)`)
mustExecRecording(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExecRecording(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`)
mustExecRecording(t, db, `CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER, record_datetime DATETIME, deleted_at DATETIME)`)
mustExecRecording(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A')`)
mustExecRecording(t, db, `INSERT INTO project_flocks (id, flock_name, location_id) VALUES (1, 'Farm A', 1)`)
mustExecRecording(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`)
mustExecRecording(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1)`)
mustExecRecording(t, db, `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES
(1, 1, '2026-06-03 08:00:00', NULL),
(2, 1, '2026-06-03 10:00:00', NULL),
(3, 1, '2026-07-01 08:00:00', NULL)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 grouped row, got %d", len(rows))
}
assertProgressRowRecording(t, rows, "Farm A", "Kandang 1", "2026-06-03", 2)
}
func openRecordingProgressTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
return db
}
func mustExecRecording(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRowRecording(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
@@ -32,6 +33,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type RecordingService interface { type RecordingService interface {
@@ -42,6 +44,7 @@ type RecordingService interface {
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error)
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)
} }
type recordingService struct { type recordingService struct {
@@ -113,8 +116,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
c.Context(), c.Context(),
params.Offset, params.Offset,
params.Limit, params.Limit,
params.Search, params,
params.ProjectFlockKandangId,
func(db *gorm.DB) *gorm.DB { func(db *gorm.DB) *gorm.DB {
db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id")
return db return db
@@ -202,6 +204,14 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return recordings, total, nil return recordings, total, nil
} }
func (s recordingService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, err
}
return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
}
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
@@ -355,6 +365,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
} }
} }
var stockOwnerProjectFlockKandangID *uint
if len(req.Stocks) > 0 {
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfk, recordTime)
if err != nil {
return nil, err
}
}
day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime) day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -431,7 +449,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err return err
} }
mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks)
stockDesired := resetStockQuantitiesForFIFO(mappedStocks) stockDesired := resetStockQuantitiesForFIFO(mappedStocks)
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
s.Log.Errorf("Failed to persist stocks: %+v", err) s.Log.Errorf("Failed to persist stocks: %+v", err)
@@ -504,6 +522,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
return err return err
} }
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err)
return err
}
action := entity.ApprovalActionCreated action := entity.ApprovalActionCreated
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
@@ -577,6 +599,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
pfkForRoute = fetchedPfk pfkForRoute = fetchedPfk
} }
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil {
return err
}
routePayload := buildRecordingRoutePayloadFromUpdate(req) routePayload := buildRecordingRoutePayloadFromUpdate(req)
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
return err return err
@@ -590,10 +615,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
var existingDepletions []entity.RecordingDepletion var existingDepletions []entity.RecordingDepletion
var existingEggs []entity.RecordingEgg var existingEggs []entity.RecordingEgg
var mappedDepletions []entity.RecordingDepletion var mappedDepletions []entity.RecordingDepletion
var stockOwnerProjectFlockKandangID *uint
note := recordingutil.RecordingNote("Edit", recordingEntity.Id) note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
if hasStockChanges { if hasStockChanges {
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime)
if err != nil {
return err
}
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
if err != nil { if err != nil {
s.Log.Errorf("Failed to list existing stocks: %+v", err) s.Log.Errorf("Failed to list existing stocks: %+v", err)
@@ -601,7 +631,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
existingUsage := recordingutil.StockUsageByWarehouse(existingStocks) existingUsage := recordingutil.StockUsageByWarehouse(existingStocks)
incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks) incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks)
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
if match { if match {
hasStockChanges = false hasStockChanges = false
} else { } else {
@@ -612,7 +642,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil {
return err return err
} }
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil {
return err return err
} }
} }
@@ -788,6 +818,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err return err
} }
} }
if hasStockChanges {
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
return err
}
}
action := entity.ApprovalActionUpdated action := entity.ApprovalActionUpdated
actorID := recordingEntity.CreatedBy actorID := recordingEntity.CreatedBy
@@ -1049,6 +1085,10 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
return err return err
} }
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err)
return err
}
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
return nil return nil
@@ -1425,12 +1465,13 @@ func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx
return nil return nil
} }
businessDate := recordDate
physicalMoveDate := transferPhysicalMoveDate(transfer) physicalMoveDate := transferPhysicalMoveDate(transfer)
if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) { if !physicalMoveDate.IsZero() && businessDate.Before(physicalMoveDate) {
return nil businessDate = physicalMoveDate
} }
if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, recordDate); err != nil { if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, businessDate); err != nil {
return err return err
} }
@@ -1443,102 +1484,10 @@ func (s *recordingService) enforceTransferRecordingRoute(
recordTime time.Time, recordTime time.Time,
payload recordingRoutePayload, payload recordingRoutePayload,
) error { ) error {
if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil { _ = ctx
return nil _ = pfk
} _ = recordTime
_ = payload
recordDate := normalizeDateOnlyUTC(recordTime)
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to resolve approved transfer by target kandang %d: %+v", pfk.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer)
if physicalMoveDate.IsZero() {
return nil
}
if recordDate.Before(physicalMoveDate) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s (tanggal pindah fisik). Sebelumnya gunakan kandang growing", physicalMoveDate.Format("2006-01-02")),
)
}
if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Transfer laying %s dengan tanggal pindah fisik %s belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")),
)
}
if recordDate.Before(economicCutoffDate) && payload.StockCount > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Periode transisi transfer laying %s (%s s.d. %s): input PAKAN/OVK harus dicatat di kandang growing hingga %s. Recording kandang laying pada periode ini hanya untuk deplesi (dan telur bila ada).",
transfer.TransferNumber,
physicalMoveDate.Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
),
)
}
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, pfk.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to resolve approved transfer by source kandang %d: %+v", pfk.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer)
if physicalMoveDate.IsZero() {
return nil
}
if recordDate.Before(physicalMoveDate) {
return nil
}
if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Transfer laying %s sudah memasuki tanggal pindah fisik %s namun belum dieksekusi. Eksekusi transfer lalu lakukan recording transisi sesuai aturan", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")),
)
}
if !recordDate.Before(economicCutoffDate) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), economicCutoffDate.Format("2006-01-02")),
)
}
if payload.DepletionCount > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Periode transisi transfer laying %s (%s s.d. %s): deplesi harus dicatat di kandang laying tujuan agar mapping tidak ambigu. Kandang growing pada periode ini hanya untuk PAKAN/OVK.",
transfer.TransferNumber,
physicalMoveDate.Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
),
)
}
}
return nil return nil
} }
@@ -1648,6 +1597,362 @@ func boolPtr(value bool) *bool {
return &v return &v
} }
func recordingStocksAllOwnedBy(stocks []entity.RecordingStock, owner *uint) bool {
for _, stock := range stocks {
if !uintPtrEqual(stock.ProjectFlockKandangId, owner) {
return false
}
}
return true
}
func uintPtrEqual(a *uint, b *uint) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func (s *recordingService) resolveRecordingStockOwnerProjectFlockKandangID(
ctx context.Context,
pfk *entity.ProjectFlockKandang,
recordTime time.Time,
) (*uint, error) {
if pfk == nil || pfk.Id == 0 {
return nil, nil
}
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
if category == "" {
loaded, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, pfk.Id)
if err == nil && loaded != nil {
pfk = loaded
category = strings.ToUpper(strings.TrimSpace(loaded.ProjectFlock.Category))
}
}
if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) {
owner := pfk.Id
return &owner, nil
}
if s.TransferLayingRepo == nil {
return nil, nil
}
transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
s.Log.Errorf("Failed to resolve transfer laying for recording stock owner (target_pfk=%d): %+v", pfk.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan atribusi recording stock")
}
if transfer == nil {
return nil, nil
}
sourceProjectFlockKandangID, err := s.resolveTransferSourceProjectFlockKandangID(ctx, transfer)
if err != nil {
s.Log.Errorf("Failed to resolve transfer source kandang for transfer %d: %+v", transfer.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan sumber kandang growing")
}
if sourceProjectFlockKandangID == 0 {
return nil, nil
}
sourceChickinDate, err := s.getEarliestChickInDateByProjectFlockKandangID(ctx, sourceProjectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to resolve earliest chick-in date for source kandang %d: %+v", sourceProjectFlockKandangID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan umur ayam dari growing")
}
if sourceChickinDate == nil || sourceChickinDate.IsZero() {
owner := sourceProjectFlockKandangID
return &owner, nil
}
thresholdDay, err := s.resolveLayingDepreciationThresholdDay(ctx, pfk)
if err != nil {
s.Log.Errorf("Failed to resolve laying threshold day for kandang %d: %+v", pfk.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan standar umur laying")
}
if thresholdDay <= 0 {
thresholdDay = commonSvc.DepreciationStartAgeDay(resolveHouseType(pfk))
}
if thresholdDay <= 0 {
thresholdDay = commonSvc.DepreciationStartAgeDay("close_house")
}
recordDate := normalizeDateOnlyUTC(recordTime)
chickinDate := normalizeDateOnlyUTC(*sourceChickinDate)
ageDay := commonSvc.FlockAgeDay(chickinDate, recordDate)
if ageDay < thresholdDay {
owner := sourceProjectFlockKandangID
return &owner, nil
}
owner := pfk.Id
return &owner, nil
}
func resolveHouseType(pfk *entity.ProjectFlockKandang) string {
if pfk == nil || pfk.Kandang.HouseType == nil {
return ""
}
return strings.TrimSpace(*pfk.Kandang.HouseType)
}
func (s *recordingService) resolveLayingDepreciationThresholdDay(ctx context.Context, pfk *entity.ProjectFlockKandang) (int, error) {
houseType := commonSvc.NormalizeDepreciationHouseType(resolveHouseType(pfk))
if houseType == "" {
return 0, nil
}
var row struct {
StandardWeek int `gorm:"column:standard_week"`
}
err := s.Repository.DB().WithContext(ctx).
Table("house_depreciation_standards").
Select("standard_week").
Where("house_type::text = ?", houseType).
Where("standard_week > 0").
Order("effective_date DESC NULLS LAST").
Order("id DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return 0, err
}
if row.StandardWeek <= 0 {
return 0, nil
}
return (row.StandardWeek * 7) + 1, nil
}
func (s *recordingService) resolveTransferSourceProjectFlockKandangID(ctx context.Context, transfer *entity.LayingTransfer) (uint, error) {
if transfer == nil || transfer.Id == 0 {
return 0, nil
}
var row struct {
SourceProjectFlockKandangID uint `gorm:"column:source_project_flock_kandang_id"`
}
err := s.Repository.DB().WithContext(ctx).
Table("laying_transfer_sources").
Select("source_project_flock_kandang_id").
Where("laying_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Where("source_project_flock_kandang_id > 0").
Order("id ASC").
Limit(1).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 {
return *transfer.SourceProjectFlockKandangId, nil
}
return 0, nil
}
if err != nil {
return 0, err
}
if row.SourceProjectFlockKandangID != 0 {
return row.SourceProjectFlockKandangID, nil
}
if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 {
return *transfer.SourceProjectFlockKandangId, nil
}
return 0, nil
}
func (s *recordingService) getEarliestChickInDateByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*time.Time, error) {
if projectFlockKandangID == 0 {
return nil, nil
}
var row struct {
ChickInDate *time.Time `gorm:"column:chick_in_date"`
}
err := s.Repository.DB().WithContext(ctx).
Table("project_chickins").
Select("MIN(chick_in_date) AS chick_in_date").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where("deleted_at IS NULL").
Scan(&row).Error
if err != nil {
return nil, err
}
return row.ChickInDate, nil
}
func (s *recordingService) syncFarmDepreciationManualInputFromRecordingStocks(
ctx context.Context,
tx *gorm.DB,
projectFlockKandangID uint,
fallbackCutoverDate time.Time,
) error {
if projectFlockKandangID == 0 {
return nil
}
targetDB := s.Repository.DB()
if tx != nil {
targetDB = tx
}
projectFlockID, err := s.resolveProjectFlockIDByProjectFlockKandangID(ctx, targetDB, projectFlockKandangID)
if err != nil {
return err
}
if projectFlockID == 0 {
return nil
}
totalCost, err := s.sumNoTransferRecordingStockCostByProjectFlockID(ctx, targetDB, projectFlockID)
if err != nil {
return err
}
existing, err := s.getFarmDepreciationManualInputByProjectFlockID(ctx, targetDB, projectFlockID)
if err != nil {
return err
}
cutoverDate := normalizeDateOnlyUTC(fallbackCutoverDate)
if existing != nil && !existing.CutoverDate.IsZero() {
cutoverDate = normalizeDateOnlyUTC(existing.CutoverDate)
}
if cutoverDate.IsZero() {
earliestDate, dateErr := s.getEarliestNoTransferRecordingDateByProjectFlockID(ctx, targetDB, projectFlockID)
if dateErr != nil {
return dateErr
}
if earliestDate != nil && !earliestDate.IsZero() {
cutoverDate = normalizeDateOnlyUTC(*earliestDate)
}
}
if cutoverDate.IsZero() {
cutoverDate = normalizeDateOnlyUTC(time.Now().UTC())
}
now := time.Now().UTC()
row := entity.FarmDepreciationManualInput{
ProjectFlockId: projectFlockID,
TotalCost: totalCost,
CutoverDate: cutoverDate,
}
if existing != nil {
row.Note = existing.Note
}
return targetDB.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "project_flock_id"}},
DoUpdates: clause.Assignments(map[string]any{
"total_cost": row.TotalCost,
"cutover_date": row.CutoverDate,
"updated_at": now,
}),
}).
Create(&row).Error
}
func (s *recordingService) resolveProjectFlockIDByProjectFlockKandangID(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (uint, error) {
var row struct {
ProjectFlockID uint `gorm:"column:project_flock_id"`
}
err := db.WithContext(ctx).
Table("project_flock_kandangs").
Select("project_flock_id").
Where("id = ?", projectFlockKandangID).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return row.ProjectFlockID, nil
}
func (s *recordingService) sumNoTransferRecordingStockCostByProjectFlockID(ctx context.Context, db *gorm.DB, projectFlockID uint) (float64, error) {
if projectFlockID == 0 {
return 0, nil
}
var total float64
err := db.WithContext(ctx).
Table("recording_stocks AS rs").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Where("rs.project_flock_kandang_id IS NULL").
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (s *recordingService) getFarmDepreciationManualInputByProjectFlockID(
ctx context.Context,
db *gorm.DB,
projectFlockID uint,
) (*entity.FarmDepreciationManualInput, error) {
if projectFlockID == 0 {
return nil, nil
}
var row entity.FarmDepreciationManualInput
err := db.WithContext(ctx).
Where("project_flock_id = ?", projectFlockID).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *recordingService) getEarliestNoTransferRecordingDateByProjectFlockID(
ctx context.Context,
db *gorm.DB,
projectFlockID uint,
) (*time.Time, error) {
if projectFlockID == 0 {
return nil, nil
}
var row struct {
RecordDate *time.Time `gorm:"column:record_date"`
}
err := db.WithContext(ctx).
Table("recording_stocks AS rs").
Select("MIN(r.record_datetime) AS record_date").
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Where("rs.project_flock_kandang_id IS NULL").
Scan(&row).Error
if err != nil {
return nil, err
}
return row.RecordDate, nil
}
func (s *recordingService) resolveEggRequestsToFarmWarehouses( func (s *recordingService) resolveEggRequestsToFarmWarehouses(
ctx context.Context, ctx context.Context,
pfk *entity.ProjectFlockKandang, pfk *entity.ProjectFlockKandang,
@@ -2578,6 +2883,7 @@ func (s *recordingService) reflowSyncRecordingStocks(
recordingID uint, recordingID uint,
existing []entity.RecordingStock, existing []entity.RecordingStock,
incoming []validation.Stock, incoming []validation.Stock,
ownerProjectFlockKandangID *uint,
note string, note string,
actorID uint, actorID uint,
) error { ) error {
@@ -2601,15 +2907,26 @@ func (s *recordingService) reflowSyncRecordingStocks(
} else { } else {
zero := 0.0 zero := 0.0
stock = entity.RecordingStock{ stock = entity.RecordingStock{
RecordingId: recordingID, RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId, ProductWarehouseId: item.ProductWarehouseId,
UsageQty: &zero, ProjectFlockKandangId: ownerProjectFlockKandangID,
PendingQty: &zero, UsageQty: &zero,
PendingQty: &zero,
} }
if err := s.Repository.CreateStock(tx, &stock); err != nil { if err := s.Repository.CreateStock(tx, &stock); err != nil {
return err return err
} }
} }
stock.ProjectFlockKandangId = ownerProjectFlockKandangID
if stock.Id != 0 {
if err := tx.Model(&entity.RecordingStock{}).
Where("id = ?", stock.Id).
Updates(map[string]any{
"project_flock_kandang_id": ownerProjectFlockKandangID,
}).Error; err != nil {
return err
}
}
desired := item.Qty desired := item.Qty
stock.UsageQty = &desired stock.UsageQty = &desired
@@ -148,6 +148,53 @@ func TestShouldNormalizeEggRequestsOnUpdateNormalizesFarmLevelEggs(t *testing.T)
} }
} }
func TestResolveTransferSourceProjectFlockKandangIDPrefersTransferResourceSource(t *testing.T) {
db := setupTransferSourceResolutionTestDB(t)
repo := repository.NewRecordingRepository(db)
svc := &recordingService{Repository: repo}
transfer := &entity.LayingTransfer{
Id: 77,
SourceProjectFlockKandangId: uintPtrForTest(999),
}
if err := db.Exec(`INSERT INTO laying_transfer_sources (id, laying_transfer_id, source_project_flock_kandang_id, deleted_at) VALUES (1, 77, 123, NULL)`).Error; err != nil {
t.Fatalf("failed seeding laying_transfer_sources: %v", err)
}
got, err := svc.resolveTransferSourceProjectFlockKandangID(context.Background(), transfer)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != 123 {
t.Fatalf("expected resource source project_flock_kandang_id=123, got %d", got)
}
}
func TestResolveTransferSourceProjectFlockKandangIDFallsBackToTransferHeader(t *testing.T) {
db := setupTransferSourceResolutionTestDB(t)
repo := repository.NewRecordingRepository(db)
svc := &recordingService{Repository: repo}
transfer := &entity.LayingTransfer{
Id: 78,
SourceProjectFlockKandangId: uintPtrForTest(456),
}
got, err := svc.resolveTransferSourceProjectFlockKandangID(context.Background(), transfer)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != 456 {
t.Fatalf("expected fallback source project_flock_kandang_id=456, got %d", got)
}
}
func uintPtrForTest(value uint) *uint {
v := value
return &v
}
func setupRecordingServiceTestDB(t *testing.T) *gorm.DB { func setupRecordingServiceTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
@@ -193,3 +240,23 @@ func setupRecordingServiceTestDB(t *testing.T) *gorm.DB {
return db return db
} }
func setupTransferSourceResolutionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
if err := db.Exec(`CREATE TABLE laying_transfer_sources (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NOT NULL,
source_project_flock_kandang_id INTEGER NOT NULL,
deleted_at TIMESTAMP NULL
)`).Error; err != nil {
t.Fatalf("failed creating laying_transfer_sources schema: %v", err)
}
return db
}
@@ -39,8 +39,14 @@ type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1"`
Offset int `query:"-" validate:"omitempty,number,min=0"` Offset int `query:"-" validate:"omitempty,number,min=0"`
ProjectFlockId uint `query:"project_flock_id" validate:"omitempty,number,min=1"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"` AreaId uint `query:"area_id" validate:"omitempty,number,min=1"`
LocationId uint `query:"location_id" validate:"omitempty,number,min=1"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
ProjectFlockCategory string `query:"project_flock_category" validate:"omitempty,oneof=GROWING LAYING"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=50"`
Search string `query:"search" validate:"omitempty,max=100"`
} }
type Approve struct { type Approve struct {
@@ -231,6 +231,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, 0); err != nil {
return nil, err
}
transferDate, err := utils.ParseDateString(req.TransferDate) transferDate, err := utils.ParseDateString(req.TransferDate)
if err != nil { if err != nil {
@@ -451,6 +454,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, id); err != nil {
return nil, err
}
transferDate, err := time.Parse("2006-01-02", req.TransferDate) transferDate, err := time.Parse("2006-01-02", req.TransferDate)
if err != nil { if err != nil {
@@ -1611,6 +1617,80 @@ func (s *transferLayingService) validateKandangOwnership(
return nil return nil
} }
func (s *transferLayingService) validateTargetSourceLineage(
ctx context.Context,
sourceProjectFlockKandangID uint,
targetKandangIDs []uint,
excludeTransferID uint,
) error {
if sourceProjectFlockKandangID == 0 || len(targetKandangIDs) == 0 {
return nil
}
seen := make(map[uint]struct{}, len(targetKandangIDs))
for _, targetKandangID := range targetKandangIDs {
if targetKandangID == 0 {
continue
}
if _, exists := seen[targetKandangID]; exists {
continue
}
seen[targetKandangID] = struct{}{}
existingTransfer, err := s.Repository.GetLatestApprovedByTargetKandang(ctx, targetKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
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")
}
if existingTransfer == nil {
continue
}
if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID {
continue
}
existingSourceID := uint(0)
if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 {
existingSourceID = *existingTransfer.SourceProjectFlockKandangId
}
if existingSourceID == 0 && s.LayingTransferSourceRepo != nil {
sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id)
if sourceErr != nil {
s.Log.Errorf("Failed to resolve transfer sources for lineage validation transfer=%d: %+v", existingTransfer.Id, sourceErr)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
}
for _, source := range sources {
if source.SourceProjectFlockKandangId != 0 {
existingSourceID = source.SourceProjectFlockKandangId
break
}
}
}
if existingSourceID == 0 {
continue
}
if existingSourceID == sourceProjectFlockKandangID {
continue
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Kandang tujuan %d sudah memiliki lineage sumber kandang %d dari transfer %s. Tidak boleh ganti ke sumber kandang %d.",
targetKandangID,
existingSourceID,
existingTransfer.TransferNumber,
sourceProjectFlockKandangID,
),
)
}
return nil
}
func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) { func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) {
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
@@ -7,7 +7,10 @@ import (
"mime/multipart" "mime/multipart"
"strconv" "strconv"
"strings" "strings"
"time"
"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/purchases/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
@@ -22,25 +25,40 @@ type PurchaseController struct {
service service.PurchaseService service service.PurchaseService
} }
const purchaseExcelExportFetchLimit = 100
func NewPurchaseController(s service.PurchaseService) *PurchaseController { func NewPurchaseController(s service.PurchaseService) *PurchaseController {
return &PurchaseController{service: s} return &PurchaseController{service: s}
} }
func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{ if exportprogress.IsProgressExportRequest(c) {
Page: c.QueryInt("page", 1), query, err := exportprogress.ParseQuery(c)
Limit: c.QueryInt("limit", 10), if err != nil {
Search: strings.TrimSpace(c.Query("search")), return err
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")), }
PoDate: strings.TrimSpace(c.Query("po_date")), rows, err := ctrl.service.GetProgressRows(c, query)
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")), if err != nil {
PoDateTo: strings.TrimSpace(c.Query("po_date_to")), return err
CreatedFrom: strings.TrimSpace(c.Query("created_from")), }
CreatedTo: strings.TrimSpace(c.Query("created_to")), content, err := exportprogress.BuildWorkbook("Purchases", query, rows)
SupplierID: uint(c.QueryInt("supplier_id", 0)), if err != nil {
AreaID: uint(c.QueryInt("area_id", 0)), return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
LocationID: uint(c.QueryInt("location_id", 0)), }
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), filename := fmt.Sprintf("purchases_progress_%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)
}
query := buildPurchaseQuery(c)
if isAllPurchaseExcelExportRequest(c) {
results, err := ctrl.getAllPurchasesForExcel(c, query)
if err != nil {
return err
}
return exportPurchaseListExcel(c, results)
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -67,6 +85,53 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
}) })
} }
func buildPurchaseQuery(c *fiber.Ctx) *validation.Query {
return &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
PoDate: strings.TrimSpace(c.Query("po_date")),
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")),
PoDateTo: strings.TrimSpace(c.Query("po_date_to")),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
}
}
func (ctrl *PurchaseController) getAllPurchasesForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]entity.Purchase, error) {
query := *baseQuery
query.Page = 1
query.Limit = purchaseExcelExportFetchLimit
results := make([]entity.Purchase, 0)
for {
pageResults, total, err := ctrl.service.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 (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error { func (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -0,0 +1,203 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type purchaseServiceStub struct {
getAllCalls []validation.Query
}
var _ service.PurchaseService = (*purchaseServiceStub)(nil)
func (s *purchaseServiceStub) GetAll(_ *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) {
callCopy := *params
s.getAllCalls = append(s.getAllCalls, callCopy)
switch params.Page {
case 1:
return []entity.Purchase{
buildPurchaseForControllerTest(1, "PR-00001"),
buildPurchaseForControllerTest(2, "PR-00002"),
}, 3, nil
case 2:
return []entity.Purchase{
buildPurchaseForControllerTest(3, "PR-00003"),
}, 3, nil
default:
return []entity.Purchase{}, 3, nil
}
}
func (s *purchaseServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.CreatePurchaseRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) ApproveStaffPurchase(_ *fiber.Ctx, _ uint, _ *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) ApproveManagerPurchase(_ *fiber.Ctx, _ uint, _ *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) ReceiveProducts(_ *fiber.Ctx, _ uint, _ *validation.ReceivePurchaseRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) DeleteItems(_ *fiber.Ctx, _ uint, _ *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) DeletePurchase(_ *fiber.Ctx, _ uint) error {
return nil
}
func (s *purchaseServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
return nil, nil
}
func TestPurchaseControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &purchaseServiceStub{}
ctrl := NewPurchaseController(stub)
app.Get("/purchases", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/purchases?export=excel&type=all&page=9&limit=1&search=po&supplier_id=7&area_id=4&location_id=2&product_category_id=1,2&approval_status=pending&po_date_from=2026-01-01&po_date_to=2026-01-31&created_from=2026-02-01&created_to=2026-02-20",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !strings.Contains(disposition, "purchases_all_") {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getAllCalls) != 2 {
t.Fatalf("expected 2 GetAll calls, got %d", len(stub.getAllCalls))
}
firstCall := stub.getAllCalls[0]
secondCall := stub.getAllCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal paging page 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != purchaseExcelExportFetchLimit || secondCall.Limit != purchaseExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", purchaseExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Search != "po" ||
firstCall.SupplierID != 7 ||
firstCall.AreaID != 4 ||
firstCall.LocationID != 2 ||
firstCall.ProductCategoryID != "1,2" ||
firstCall.ApprovalStatus != "pending" ||
firstCall.PoDateFrom != "2026-01-01" ||
firstCall.PoDateTo != "2026-01-31" ||
firstCall.CreatedFrom != "2026-02-01" ||
firstCall.CreatedTo != "2026-02-20" {
t.Fatalf("unexpected forwarded filters: %+v", firstCall)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
if got, _ := file.GetCellValue(purchaseExportSheetName, "A1"); got != "PR Number" {
t.Fatalf("expected A1 header to be PR Number, got %q", got)
}
if got, _ := file.GetCellValue(purchaseExportSheetName, "A2"); got != "PR-00001" {
t.Fatalf("expected first row PR-00001, got %q", got)
}
}
func TestPurchaseControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &purchaseServiceStub{}
ctrl := NewPurchaseController(stub)
app.Get("/purchases", ctrl.GetAll)
req := httptest.NewRequest(http.MethodGet, "/purchases?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func buildPurchaseForControllerTest(id uint, prNumber string) entity.Purchase {
poNumber := "PO-" + strings.TrimPrefix(prNumber, "PR-")
poDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
notes := "catatan"
approvalAction := entity.ApprovalActionApproved
return entity.Purchase{
Id: id,
PrNumber: prNumber,
PoNumber: &poNumber,
PoDate: &poDate,
Notes: &notes,
Supplier: entity.Supplier{
Id: 10,
Name: "Supplier A",
},
LatestApproval: &entity.Approval{
Id: 1,
StepName: "Manager Purchase",
Action: &approvalAction,
},
Items: []entity.PurchaseItem{
{
Id: id*10 + 1,
TotalPrice: 1000000,
Product: &entity.Product{
Id: id*100 + 1,
Name: "Pakan Starter",
},
},
},
}
}
@@ -0,0 +1,346 @@
package controller
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const purchaseExportSheetName = "Purchases"
func isAllPurchaseExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportPurchaseListExcel(c *fiber.Ctx, purchases []entity.Purchase) error {
content, err := buildPurchaseExportWorkbook(purchases)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("purchases_all_%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 buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != purchaseExportSheetName {
if err := file.SetSheetName(defaultSheet, purchaseExportSheetName); err != nil {
return nil, err
}
}
listItems := dto.ToPurchaseListDTOs(purchases)
grandTotals := buildPurchaseGrandTotalMap(purchases)
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
return nil, err
}
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
return nil, err
}
if err := setPurchaseExportRows(file, purchaseExportSheetName, listItems, grandTotals); err != nil {
return nil, err
}
if err := file.SetPanes(purchaseExportSheetName, &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 setPurchaseExportColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 16,
"B": 16,
"C": 14,
"D": 22,
"E": 22,
"F": 18,
"G": 18,
"H": 52,
"I": 24,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil
}
func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"PR Number",
"PO Number",
"Tanggal PO",
"Supplier",
"Lokasi",
"Status",
"Grand Total",
"Products",
"Notes",
}
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", "I1", headerStyle)
}
func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error {
if len(items) == 0 {
return nil
}
for i, item := range items {
row := strconv.Itoa(i + 2)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(item.PrNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(item.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(item.PoDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+row, safePurchaseSupplierName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, safePurchaseLocationName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, formatPurchaseExportStatus(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, formatPurchaseProducts(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, safePurchaseExportPointerText(item.Notes)); err != nil {
return err
}
}
lastRow := len(items) + 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", "I"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
moneyStyle, 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, "G2", "G"+strconv.Itoa(lastRow), moneyStyle)
}
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
result := make(map[uint]float64, len(items))
for i := range items {
total := 0.0
for j := range items[i].Items {
total += items[i].Items[j].TotalPrice
}
result[items[i].Id] = total
}
return result
}
func safePurchaseSupplierName(item dto.PurchaseListDTO) string {
if item.Supplier == nil {
return "-"
}
return safePurchaseExportText(item.Supplier.Name)
}
func safePurchaseLocationName(item dto.PurchaseListDTO) string {
if item.Location == nil {
return "-"
}
return safePurchaseExportText(item.Location.Name)
}
func formatPurchaseExportStatus(item dto.PurchaseListDTO) string {
if item.LatestApproval == nil {
return "-"
}
if item.LatestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
return safePurchaseExportText(item.LatestApproval.StepName)
}
func formatPurchaseExportDate(value *time.Time) string {
if value == nil || value.IsZero() {
return "-"
}
t := *value
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(location)
}
return t.Format("02-01-2006")
}
func formatPurchaseProducts(item dto.PurchaseListDTO) string {
if len(item.Products) == 0 {
return "-"
}
seen := make(map[string]struct{})
names := make([]string, 0, len(item.Products))
for i := range item.Products {
name := strings.TrimSpace(item.Products[i].Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return "-"
}
sort.Strings(names)
return strings.Join(names, ", ")
}
func safePurchaseExportPointerText(value *string) string {
if value == nil {
return "-"
}
return safePurchaseExportText(*value)
}
func safePurchaseExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
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()
}
@@ -0,0 +1,174 @@
package controller
import (
"bytes"
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"github.com/xuri/excelize/v2"
)
func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
content, err := buildPurchaseExportWorkbook([]entity.Purchase{
buildPurchaseForExportTest(
1,
"PR-00011",
"PO-00011",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
"Supplier A",
"Manager Purchase",
nil,
"catatan",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"),
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
},
),
buildPurchaseForExportTest(
2,
"PR-00012",
"",
time.Time{},
"Supplier B",
"Manager Purchase",
ptrApprovalAction(entity.ApprovalActionRejected),
"",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""),
},
),
})
if err != nil {
t.Fatalf("buildPurchaseExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
expectedHeaders := map[string]string{
"A1": "PR Number",
"B1": "PO Number",
"C1": "Tanggal PO",
"D1": "Supplier",
"E1": "Lokasi",
"F1": "Status",
"G1": "Grand Total",
"H1": "Products",
"I1": "Notes",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(purchaseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
assertPurchaseCellEquals(t, file, "A2", "PR-00011")
assertPurchaseCellEquals(t, file, "B2", "PO-00011")
assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
assertPurchaseCellEquals(t, file, "D2", "Supplier A")
assertPurchaseCellEquals(t, file, "E2", "Location A")
assertPurchaseCellEquals(t, file, "F2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000")
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A")
assertPurchaseCellEquals(t, file, "I2", "catatan")
assertPurchaseCellEquals(t, file, "A3", "PR-00012")
assertPurchaseCellEquals(t, file, "B3", "-")
assertPurchaseCellEquals(t, file, "C3", "-")
assertPurchaseCellEquals(t, file, "E3", "-")
assertPurchaseCellEquals(t, file, "F3", "Ditolak")
assertPurchaseCellEquals(t, file, "G3", "Rp 75.000")
assertPurchaseCellEquals(t, file, "H3", "Obat X")
assertPurchaseCellEquals(t, file, "I3", "-")
}
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(purchaseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
func buildPurchaseForExportTest(
id uint,
prNumber, poNumber string,
poDate time.Time,
supplierName, stepName string,
action *entity.ApprovalAction,
notes string,
items []entity.PurchaseItem,
) entity.Purchase {
var poNumberRef *string
if poNumber != "" {
poNumberRef = &poNumber
}
var poDateRef *time.Time
if !poDate.IsZero() {
poDateRef = &poDate
}
var notesRef *string
if notes != "" {
notesRef = &notes
}
return entity.Purchase{
Id: id,
PrNumber: prNumber,
PoNumber: poNumberRef,
PoDate: poDateRef,
Notes: notesRef,
Supplier: entity.Supplier{
Id: id + 100,
Name: supplierName,
},
LatestApproval: &entity.Approval{
Id: id + 1000,
StepName: stepName,
Action: action,
},
Items: items,
}
}
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem {
item := entity.PurchaseItem{
ProductId: productID,
TotalPrice: totalPrice,
Product: &entity.Product{
Id: productID,
Name: productName,
},
}
if locationName != "" {
locationID := productID + 1000
warehouseID := productID + 500
item.Warehouse = &entity.Warehouse{
Id: warehouseID,
Location: &entity.Location{
Id: locationID,
Name: locationName,
},
}
}
return item
}
func ptrApprovalAction(value entity.ApprovalAction) *entity.ApprovalAction {
return &value
}
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"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"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils" utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -28,6 +29,7 @@ type PurchaseRepository interface {
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)
GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error)
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
} }
type PurchaseRepositoryImpl struct { type PurchaseRepositoryImpl struct {
@@ -284,6 +286,75 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
return nil return nil
} }
func (r *PurchaseRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
subQuery := r.DB().WithContext(ctx).
Table("purchases AS p").
Select(`
DISTINCT p.id AS purchase_id,
'Purchases' AS module,
COALESCE(pf_explicit.flock_name, pf_active.flock_name, kandang_loc.name, warehouse_loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k_explicit.name, k_active.name, wk.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
CAST(DATE(p.po_date) AS TEXT) AS activity_date
`).
Joins("JOIN purchase_items pi ON pi.purchase_id = p.id").
Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Joins("LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks pf_explicit ON pf_explicit.id = pfk_explicit.project_flock_id").
Joins("LEFT JOIN kandangs k_explicit ON k_explicit.id = pfk_explicit.kandang_id").
Joins("LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL").
Joins("LEFT JOIN project_flocks pf_active ON pf_active.id = pfk_active.project_flock_id").
Joins("LEFT JOIN kandangs k_active ON k_active.id = pfk_active.kandang_id").
Joins("LEFT JOIN kandangs wk ON wk.id = w.kandang_id").
Joins("LEFT JOIN locations kandang_loc ON kandang_loc.id = COALESCE(k_explicit.location_id, k_active.location_id, wk.location_id)").
Joins("LEFT JOIN locations warehouse_loc ON warehouse_loc.id = w.location_id").
Where("p.deleted_at IS NULL").
Where("p.po_date IS NOT NULL").
Where("DATE(p.po_date) >= DATE(?)", startDate).
Where("DATE(p.po_date) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
subQuery = subQuery.Where("w.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := r.DB().WithContext(ctx).
Table("(?) AS progress_rows", subQuery).
Select("module, farm_name, kandang_name, activity_date, COUNT(*) AS count").
Group("module, farm_name, kandang_name, activity_date").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := exportprogress.ParseActivityDate(item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error { func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error {
if len(itemIDs) == 0 { if len(itemIDs) == 0 {
return errors.New("itemIDs cannot be empty") return errors.New("itemIDs cannot be empty")
@@ -0,0 +1,74 @@
package repositories
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestPurchaseRepositoryGetProgressRows(t *testing.T) {
db := openPurchaseProgressTestDB(t)
repo := NewPurchaseRepository(db)
mustExecPurchase(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExecPurchase(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`)
mustExecPurchase(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExecPurchase(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER, closed_at DATETIME)`)
mustExecPurchase(t, db, `CREATE TABLE warehouses (id INTEGER PRIMARY KEY, location_id INTEGER, kandang_id INTEGER)`)
mustExecPurchase(t, db, `CREATE TABLE purchases (id INTEGER PRIMARY KEY, po_date DATE, deleted_at DATETIME)`)
mustExecPurchase(t, db, `CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, purchase_id INTEGER, warehouse_id INTEGER, project_flock_kandang_id INTEGER)`)
mustExecPurchase(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A'), (2, 'Location B')`)
mustExecPurchase(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`)
mustExecPurchase(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`)
mustExecPurchase(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id, closed_at) VALUES (1, 1, 1, NULL)`)
mustExecPurchase(t, db, `INSERT INTO warehouses (id, location_id, kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`)
mustExecPurchase(t, db, `INSERT INTO purchases (id, po_date, deleted_at) VALUES (1, '2026-06-05', NULL), (2, '2026-06-05', NULL), (3, NULL, NULL)`)
mustExecPurchase(t, db, `INSERT INTO purchase_items (id, purchase_id, warehouse_id, project_flock_kandang_id) VALUES
(1, 1, 1, 1),
(2, 1, 1, 1),
(3, 2, 2, NULL),
(4, 3, 1, 1)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 grouped rows, got %d", len(rows))
}
assertProgressRowPurchase(t, rows, "Farm A", "Kandang 1", "2026-06-05", 1)
assertProgressRowPurchase(t, rows, "Location B", "Farm-level / Unassigned", "2026-06-05", 1)
}
func openPurchaseProgressTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
return db
}
func mustExecPurchase(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRowPurchase(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -43,6 +44,7 @@ type PurchaseService interface {
ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error)
DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error)
DeletePurchase(ctx *fiber.Ctx, id uint) error DeletePurchase(ctx *fiber.Ctx, id uint) error
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
} }
const ( const (
@@ -245,7 +247,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if len(productCategoryIDs) > 0 { if len(productCategoryIDs) > 0 {
db = db.Where( db = db.Where(
`EXISTS ( `EXISTS (
SELECT 1 SELECT 1
FROM purchase_items pi FROM purchase_items pi
JOIN products p ON p.id = pi.product_id JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ? WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ?
@@ -254,183 +256,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
) )
} }
if len(approvalStatuses) > 0 { db = applyPurchaseProjectFlockFilter(db, params.ProjectFlockID, params.ProjectFlockKandangID)
approvalConditions := make([]string, 0, len(approvalStatuses)) db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3)) db = applyPurchaseSearchFilter(db, search)
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
for _, status := range approvalStatuses {
if status == "" {
continue
}
like := "%" + status + "%"
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
approvalArgs = append(approvalArgs, like, like, status)
}
if len(approvalConditions) > 0 {
approvalClause := strings.Join(approvalConditions, " OR ")
approvalQuery := fmt.Sprintf(
`EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = purchases.id
AND a.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = purchases.id
ORDER BY a2.action_at DESC, a2.id DESC
LIMIT 1
)
AND (%s)
)`,
approvalClause,
)
db = db.Where(approvalQuery, approvalArgs...)
}
}
if search != "" {
like := "%" + search + "%"
db = db.Where(
`(
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = purchases.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = purchases.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(p.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
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
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
)
)`,
like,
like,
like,
like,
like,
like,
like,
)
}
if len(approvalStatuses) > 0 {
approvalConditions := make([]string, 0, len(approvalStatuses))
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
for _, status := range approvalStatuses {
if status == "" {
continue
}
like := "%" + status + "%"
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
approvalArgs = append(approvalArgs, like, like, status)
}
if len(approvalConditions) > 0 {
approvalClause := strings.Join(approvalConditions, " OR ")
approvalQuery := fmt.Sprintf(
`EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = purchases.id
AND a.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = purchases.id
ORDER BY a2.action_at DESC, a2.id DESC
LIMIT 1
)
AND (%s)
)`,
approvalClause,
)
db = db.Where(approvalQuery, approvalArgs...)
}
}
if search != "" {
like := "%" + search + "%"
db = db.Where(
`(
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = purchases.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = purchases.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(p.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
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
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
)
)`,
like,
like,
like,
like,
like,
like,
like,
)
}
return db.Order("created_at DESC").Order("purchases.id DESC") return db.Order("created_at DESC").Order("purchases.id DESC")
}) })
@@ -446,6 +274,14 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return purchases, total, nil return purchases, total, nil
} }
func (s *purchaseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
if err != nil {
return nil, err
}
return s.PurchaseRepo.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
}
func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) {
scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
if err != nil { if err != nil {
@@ -2351,6 +2187,155 @@ func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, er
return fromPtr, toPtr, nil return fromPtr, toPtr, nil
} }
func applyPurchaseProjectFlockFilter(db *gorm.DB, projectFlockID, projectFlockKandangID uint) *gorm.DB {
if projectFlockID > 0 {
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
WHERE pi.purchase_id = purchases.id
AND COALESCE(pfk_explicit.project_flock_id, pfk_active.project_flock_id) = ?
)`,
projectFlockID,
)
}
if projectFlockKandangID > 0 {
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
WHERE pi.purchase_id = purchases.id
AND COALESCE(pi.project_flock_kandang_id, pfk_active.id) = ?
)`,
projectFlockKandangID,
)
}
return db
}
func applyPurchaseApprovalStatusFilter(db *gorm.DB, approvalStatuses []string) *gorm.DB {
if len(approvalStatuses) == 0 {
return db
}
approvalConditions := make([]string, 0, len(approvalStatuses))
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
for _, status := range approvalStatuses {
if status == "" {
continue
}
like := "%" + status + "%"
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
approvalArgs = append(approvalArgs, like, like, status)
}
if len(approvalConditions) == 0 {
return db
}
approvalClause := strings.Join(approvalConditions, " OR ")
approvalQuery := fmt.Sprintf(
`EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = purchases.id
AND a.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = purchases.id
ORDER BY a2.action_at DESC, a2.id DESC
LIMIT 1
)
AND (%s)
)`,
approvalClause,
)
return db.Where(approvalQuery, approvalArgs...)
}
func applyPurchaseSearchFilter(db *gorm.DB, search string) *gorm.DB {
if search == "" {
return db
}
like := "%" + search + "%"
return db.Where(
`(
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = purchases.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = purchases.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(p.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
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
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
LEFT JOIN project_flocks pf ON pf.id = COALESCE(pfk_explicit.project_flock_id, pfk_active.project_flock_id)
LEFT JOIN kandangs k ON k.id = COALESCE(pfk_explicit.kandang_id, pfk_active.kandang_id, w.kandang_id)
WHERE pi.purchase_id = purchases.id
AND (
LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
LOWER(COALESCE(k.name, '')) LIKE ?
)
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
)
)`,
like,
like,
like,
like,
like,
like,
like,
like,
like,
)
}
func normalizeApprovalStatusFilter(raw string) string { func normalizeApprovalStatusFilter(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw)) value := strings.ToLower(strings.TrimSpace(raw))
switch value { switch value {
@@ -61,17 +61,19 @@ type DeletePurchaseItemsRequest struct {
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
AreaID uint `query:"area_id" validate:"omitempty,gt=0"` AreaID uint `query:"area_id" validate:"omitempty,gt=0"`
LocationID uint `query:"location_id" validate:"omitempty,gt=0"` LocationID uint `query:"location_id" validate:"omitempty,gt=0"`
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"` PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
Search string `query:"search" validate:"omitempty,max=100"` PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` Search string `query:"search" validate:"omitempty,max=100"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"`
} }
@@ -30,6 +30,8 @@ type RepportController struct {
RepportService service.RepportService RepportService service.RepportService
} }
const expenseReportExcelExportFetchLimit = 100
func NewRepportController(repportService service.RepportService) *RepportController { func NewRepportController(repportService service.RepportService) *RepportController {
return &RepportController{ return &RepportController{
RepportService: repportService, RepportService: repportService,
@@ -66,6 +68,14 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) query.AllowedAreaIDs = toInt64Slice(areaScope.IDs)
} }
if isAllExpenseExcelExportRequest(ctx) {
allResults, err := c.getAllExpenseRowsForExcel(ctx, query)
if err != nil {
return err
}
return exportExpenseReportListExcel(ctx, allResults)
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
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")
} }
@@ -90,6 +100,33 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
}) })
} }
func (c *RepportController) getAllExpenseRowsForExcel(ctx *fiber.Ctx, baseQuery *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, error) {
query := *baseQuery
query.Page = 1
query.Limit = expenseReportExcelExportFetchLimit
results := make([]dto.RepportExpenseListDTO, 0)
for {
pageResults, total, err := c.RepportService.GetExpense(ctx, &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 (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error { func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error {
rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx) rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx)
if err != nil { if err != nil {
@@ -0,0 +1,202 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
)
type repportServiceStub struct {
service.RepportService
getExpenseCalls []validation.ExpenseQuery
}
func (s *repportServiceStub) GetExpense(_ *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) {
callCopy := *params
callCopy.AllowedAreaIDs = append([]int64(nil), params.AllowedAreaIDs...)
callCopy.AllowedLocationIDs = append([]int64(nil), params.AllowedLocationIDs...)
s.getExpenseCalls = append(s.getExpenseCalls, callCopy)
switch params.Page {
case 1:
return []dto.RepportExpenseListDTO{
buildExpenseListForControllerTest("REF-00001", "TRANSPORT 2"),
buildExpenseListForControllerTest("REF-00002", "TRANSPORT"),
}, 3, nil
case 2:
return []dto.RepportExpenseListDTO{
buildExpenseListForControllerTest("REF-00003", "TRANSPORT"),
}, 3, nil
default:
return []dto.RepportExpenseListDTO{}, 3, nil
}
}
func (s *repportServiceStub) DB() *gorm.DB {
return nil
}
func TestRepportControllerGetExpenseExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &repportServiceStub{}
ctrl := NewRepportController(stub)
app.Get("/reports/expense", ctrl.GetExpense)
req := httptest.NewRequest(
http.MethodGet,
"/reports/expense?export=excel&type=all&page=9&limit=1&search=operasional&category=BOP&supplier_id=7&kandang_id=4&project_flock_kandang_id=2&nonstock_id=5&area_id=3&location_id=9&realization_date=2026-04-22",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if contentType != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !bytes.Contains([]byte(disposition), []byte("reports_expense_all_")) {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getExpenseCalls) != 2 {
t.Fatalf("expected 2 GetExpense calls, got %d", len(stub.getExpenseCalls))
}
firstCall := stub.getExpenseCalls[0]
secondCall := stub.getExpenseCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal pages 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != expenseReportExcelExportFetchLimit || secondCall.Limit != expenseReportExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", expenseReportExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Search != "operasional" ||
firstCall.Category != "BOP" ||
firstCall.SupplierId != 7 ||
firstCall.KandangId != 4 ||
firstCall.ProjectFlockKandangId != 2 ||
firstCall.NonstockId != 5 ||
firstCall.AreaId != 3 ||
firstCall.LocationId != 9 ||
firstCall.RealizationDate != "2026-04-22" {
t.Fatalf("unexpected forwarded filters: %+v", firstCall)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
sheetList := file.GetSheetList()
expectedSheets := []string{"EKSPEDISI ADE", "EKSPEDISI LTI"}
if !reflect.DeepEqual(sheetList, expectedSheets) {
t.Fatalf("unexpected sheet list: got %v, expected %v", sheetList, expectedSheets)
}
if got, _ := file.GetCellValue("EKSPEDISI ADE", "A1"); got != "No" {
t.Fatalf("expected EKSPEDISI ADE A1 to be No, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI ADE", "G2"); got != "TRANSPORT 2" {
t.Fatalf("expected EKSPEDISI ADE G2 to be TRANSPORT 2, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI LTI", "G2"); got != "TRANSPORT" {
t.Fatalf("expected EKSPEDISI LTI G2 to be TRANSPORT, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI LTI", "A4"); got != "Total" {
t.Fatalf("expected EKSPEDISI LTI A4 to be Total, got %q", got)
}
}
func TestRepportControllerGetExpenseKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &repportServiceStub{}
ctrl := NewRepportController(stub)
app.Get("/reports/expense", ctrl.GetExpense)
req := httptest.NewRequest(http.MethodGet, "/reports/expense?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func buildExpenseListForControllerTest(reference, product string) dto.RepportExpenseListDTO {
realizationDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
return dto.RepportExpenseListDTO{
RepportExpenseBaseDTO: dto.RepportExpenseBaseDTO{
ReferenceNumber: reference,
PoNumber: "PO-001",
Category: "BOP",
Notes: "catatan expense",
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
RealizationDate: &realizationDate,
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
},
Kandang: &kandangDTO.KandangRelationDTO{
Name: "Kandang A",
Location: &locationDTO.LocationRelationDTO{
Name: "Darawati",
},
},
Pengajuan: dto.RepportExpensePengajuanDTO{
Qty: 1,
Price: 50000,
Notes: "catatan pengajuan",
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
Realisasi: dto.RepportExpenseRealisasiDTO{
Qty: 1,
Price: 50000,
Notes: "catatan realisasi",
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
TotalPengajuan: 50000,
TotalRealisasi: 50000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Realisasi",
},
}
}
@@ -0,0 +1,424 @@
package controller
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const expenseReportExportSheetName = "Expense Reports"
var expenseReportTemplateSheetOrder = []string{
"UANG MAKAN",
"UPAH",
"EKSPEDISI ADE",
"GALON",
"GAS",
"KEBUTUHAN",
"EKSPEDISI LTI",
"KONTRIBUSI",
"PRODUKSI",
"KOMPENSASI",
"LAIN-LAIN",
"PERBAIKAN",
"LISTRIK",
"PAJAK",
"SOLAR",
}
var expenseReportSheetAliasMap = map[string]string{
"TRANSPORT 2": "EKSPEDISI ADE",
"TRANSPORT": "EKSPEDISI LTI",
"GAS BROODING": "GAS",
}
func isAllExpenseExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportExpenseReportListExcel(c *fiber.Ctx, items []dto.RepportExpenseListDTO) error {
content, err := buildExpenseReportExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("reports_expense_all_%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 buildExpenseReportExportWorkbook(items []dto.RepportExpenseListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
groups := groupExpenseReportRowsBySheet(items)
orderedSheetNames := orderExpenseReportSheetNames(groups)
if len(orderedSheetNames) == 0 {
if defaultSheet != expenseReportExportSheetName {
if err := file.SetSheetName(defaultSheet, expenseReportExportSheetName); err != nil {
return nil, err
}
}
if err := writeExpenseReportSheet(file, expenseReportExportSheetName, []dto.RepportExpenseListDTO{}); err != nil {
return nil, err
}
} else {
for idx, sheetName := range orderedSheetNames {
if idx == 0 {
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
} else {
if _, err := file.NewSheet(sheetName); err != nil {
return nil, err
}
}
if err := writeExpenseReportSheet(file, sheetName, groups[sheetName]); err != nil {
return nil, err
}
}
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func groupExpenseReportRowsBySheet(items []dto.RepportExpenseListDTO) map[string][]dto.RepportExpenseListDTO {
groups := make(map[string][]dto.RepportExpenseListDTO)
for _, item := range items {
product := resolveExpenseReportProduct(item)
sheetName := resolveExpenseReportSheetName(product)
groups[sheetName] = append(groups[sheetName], item)
}
return groups
}
func orderExpenseReportSheetNames(groups map[string][]dto.RepportExpenseListDTO) []string {
if len(groups) == 0 {
return nil
}
templateSet := make(map[string]struct{}, len(expenseReportTemplateSheetOrder))
ordered := make([]string, 0, len(groups))
for _, sheet := range expenseReportTemplateSheetOrder {
templateSet[sheet] = struct{}{}
if _, ok := groups[sheet]; ok {
ordered = append(ordered, sheet)
}
}
extras := make([]string, 0)
for sheet := range groups {
if _, ok := templateSet[sheet]; !ok {
extras = append(extras, sheet)
}
}
sort.Slice(extras, func(i, j int) bool {
return strings.ToUpper(extras[i]) < strings.ToUpper(extras[j])
})
ordered = append(ordered, extras...)
return ordered
}
func resolveExpenseReportSheetName(product string) string {
normalizedProduct := strings.ToUpper(strings.TrimSpace(product))
if alias, exists := expenseReportSheetAliasMap[normalizedProduct]; exists {
return alias
}
if normalizedProduct == "" {
normalizedProduct = "-"
}
return sanitizeExpenseReportSheetName(normalizedProduct)
}
func sanitizeExpenseReportSheetName(name string) string {
replacer := strings.NewReplacer(
":", " ",
"\\", " ",
"/", " ",
"?", " ",
"*", " ",
"[", " ",
"]", " ",
)
sanitized := strings.TrimSpace(replacer.Replace(name))
if sanitized == "" {
sanitized = "Sheet"
}
runes := []rune(sanitized)
if len(runes) > 31 {
sanitized = string(runes[:31])
}
return sanitized
}
func writeExpenseReportSheet(file *excelize.File, sheet string, items []dto.RepportExpenseListDTO) error {
if err := setExpenseReportTemplateColumns(file, sheet); err != nil {
return err
}
if err := setExpenseReportTemplateHeaders(file, sheet); err != nil {
return err
}
return setExpenseReportTemplateRows(file, sheet, items)
}
func setExpenseReportTemplateColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 5.83203125,
"B": 20.83203125,
"C": 20.83203125,
"D": 15.83203125,
"E": 15.83203125,
"F": 15.83203125,
"G": 30.83203125,
"H": 20.83203125,
"I": 15.83203125,
"J": 15.83203125,
"K": 15.83203125,
"L": 20.83203125,
"M": 15.83203125,
"N": 20.83203125,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return nil
}
func setExpenseReportTemplateHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No",
"No. PO",
"No. Referensi",
"Tanggal Realisasi",
"Tanggal Transaksi",
"Kategori",
"Produk",
"Lokasi",
"Kandang",
"Qty Pengajuan",
"Harga Pengajuan",
"Total Pengajuan",
"Qty Realisasi",
"Harga Realisasi",
"Total Realisasi",
"Status Pencairan",
}
for i, header := range headers {
columnName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, columnName+"1", header); err != nil {
return err
}
}
return nil
}
func setExpenseReportTemplateRows(file *excelize.File, sheet string, items []dto.RepportExpenseListDTO) error {
totalQtyPengajuan := 0.0
totalPengajuan := 0.0
totalQtyRealisasi := 0.0
totalRealisasi := 0.0
for idx, item := range items {
row := idx + 2
rowString := strconv.Itoa(row)
produk := resolveExpenseReportProduct(item)
status := formatExpenseReportStatus(item)
lokasi := resolveExpenseReportLocation(item)
kandang := resolveExpenseReportKandang(item)
if err := file.SetCellValue(sheet, "A"+rowString, idx+1); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+rowString, safeExpenseReportExportText(item.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+rowString, safeExpenseReportExportText(item.ReferenceNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+rowString, formatExpenseReportOptionalDate(item.RealizationDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+rowString, formatExpenseReportDate(item.TransactionDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+rowString, safeExpenseReportExportText(item.Category)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+rowString, produk); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+rowString, lokasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+rowString, kandang); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+rowString, item.Pengajuan.Qty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+rowString, item.Pengajuan.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+rowString, item.TotalPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+rowString, item.Realisasi.Qty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+rowString, item.Realisasi.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+rowString, item.TotalRealisasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+rowString, status); err != nil {
return err
}
totalQtyPengajuan += item.Pengajuan.Qty
totalPengajuan += item.TotalPengajuan
totalQtyRealisasi += item.Realisasi.Qty
totalRealisasi += item.TotalRealisasi
}
totalRow := strconv.Itoa(len(items) + 2)
if err := file.SetCellValue(sheet, "A"+totalRow, "Total"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+totalRow, totalQtyPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+totalRow, 0); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+totalRow, totalPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+totalRow, totalQtyRealisasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+totalRow, 0); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+totalRow, totalRealisasi); err != nil {
return err
}
return nil
}
func resolveExpenseReportProduct(item dto.RepportExpenseListDTO) string {
if item.Realisasi.Nonstock != nil {
name := strings.TrimSpace(item.Realisasi.Nonstock.Name)
if name != "" {
return name
}
}
if item.Pengajuan.Nonstock != nil {
name := strings.TrimSpace(item.Pengajuan.Nonstock.Name)
if name != "" {
return name
}
}
return "-"
}
func resolveExpenseReportLocation(item dto.RepportExpenseListDTO) string {
if item.Kandang != nil && item.Kandang.Location != nil {
name := strings.TrimSpace(item.Kandang.Location.Name)
if name != "" {
return name
}
}
return "-"
}
func resolveExpenseReportKandang(item dto.RepportExpenseListDTO) string {
if item.Kandang != nil {
name := strings.TrimSpace(item.Kandang.Name)
if name != "" {
return name
}
}
return "-"
}
func formatExpenseReportStatus(item dto.RepportExpenseListDTO) string {
if item.LatestApproval == nil {
return "-"
}
if item.LatestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
stepName := strings.TrimSpace(item.LatestApproval.StepName)
if stepName == "" {
return "-"
}
return stepName
}
func formatExpenseReportDate(value time.Time) string {
if value.IsZero() {
return "-"
}
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
value = value.In(location)
}
return value.Format("02 Jan 2006")
}
func formatExpenseReportOptionalDate(value *time.Time) string {
if value == nil || value.IsZero() {
return "-"
}
return formatExpenseReportDate(*value)
}
func safeExpenseReportExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,281 @@
package controller
import (
"bytes"
"reflect"
"strings"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildExpenseReportExportWorkbookHeadersAndRows(t *testing.T) {
realizationDate := time.Date(2026, time.April, 23, 0, 0, 0, 0, time.UTC)
items := []dto.RepportExpenseListDTO{
buildExpenseExportTestItem(
"REF-0001",
"PO-0001",
"BOP",
"UPAH",
"Darawati",
"Darawati C1",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
2,
10000,
20000,
2,
9000,
18000,
"Realisasi",
nil,
),
buildExpenseExportTestItem(
"REF-0002",
"PO-0002",
"BOP",
"TRANSPORT 2",
"",
"",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
1,
50000,
50000,
1,
50000,
50000,
"Pengajuan",
strPtr("REJECTED"),
),
buildExpenseExportTestItem(
"REF-0003",
"PO-0003",
"BOP",
"TRANSPORT",
"Jamali",
"Jamali 1",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
3,
12000,
36000,
2,
11000,
22000,
"Selesai",
nil,
),
buildExpenseExportTestItem(
"REF-0004",
"PO-0004",
"BOP",
"TRANSPORT",
"Jamali",
"Jamali 2",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
1,
8000,
8000,
1,
8000,
8000,
"Realisasi",
nil,
),
buildExpenseExportTestItem(
"REF-0005",
"PO-0005",
"BOP",
"ZZZ CUSTOM",
"",
"",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
nil,
1,
7000,
7000,
1,
7000,
7000,
"",
nil,
),
}
content, err := buildExpenseReportExportWorkbook(items)
if err != nil {
t.Fatalf("buildExpenseReportExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
expectedSheetOrder := []string{"UPAH", "EKSPEDISI ADE", "EKSPEDISI LTI", "ZZZ CUSTOM"}
if got := file.GetSheetList(); !reflect.DeepEqual(got, expectedSheetOrder) {
t.Fatalf("unexpected sheet order: got %v expected %v", got, expectedSheetOrder)
}
expectedHeaders := map[string]string{
"A1": "No",
"B1": "No. PO",
"C1": "No. Referensi",
"D1": "Tanggal Realisasi",
"E1": "Tanggal Transaksi",
"F1": "Kategori",
"G1": "Produk",
"H1": "Lokasi",
"I1": "Kandang",
"J1": "Qty Pengajuan",
"K1": "Harga Pengajuan",
"L1": "Total Pengajuan",
"M1": "Qty Realisasi",
"N1": "Harga Realisasi",
"O1": "Total Realisasi",
"P1": "Status Pencairan",
}
for cell, expected := range expectedHeaders {
assertExpenseSheetCellEquals(t, file, "UPAH", cell, expected)
}
assertExpenseSheetCellEquals(t, file, "UPAH", "A2", "1")
assertExpenseSheetCellEquals(t, file, "UPAH", "B2", "PO-0001")
assertExpenseSheetCellEquals(t, file, "UPAH", "C2", "REF-0001")
assertExpenseSheetCellEquals(t, file, "UPAH", "D2", "23 Apr 2026")
assertExpenseSheetCellEquals(t, file, "UPAH", "E2", "22 Apr 2026")
assertExpenseSheetCellEquals(t, file, "UPAH", "F2", "BOP")
assertExpenseSheetCellEquals(t, file, "UPAH", "G2", "UPAH")
assertExpenseSheetCellEquals(t, file, "UPAH", "H2", "Darawati")
assertExpenseSheetCellEquals(t, file, "UPAH", "I2", "Darawati C1")
assertExpenseSheetCellEquals(t, file, "UPAH", "J2", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "K2", "10000")
assertExpenseSheetCellEquals(t, file, "UPAH", "L2", "20000")
assertExpenseSheetCellEquals(t, file, "UPAH", "M2", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "N2", "9000")
assertExpenseSheetCellEquals(t, file, "UPAH", "O2", "18000")
assertExpenseSheetCellEquals(t, file, "UPAH", "P2", "Realisasi")
assertExpenseSheetCellEquals(t, file, "UPAH", "A3", "Total")
assertExpenseSheetCellEquals(t, file, "UPAH", "J3", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "K3", "0")
assertExpenseSheetCellEquals(t, file, "UPAH", "L3", "20000")
assertExpenseSheetCellEquals(t, file, "UPAH", "M3", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "N3", "0")
assertExpenseSheetCellEquals(t, file, "UPAH", "O3", "18000")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "G2", "TRANSPORT 2")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "P2", "Ditolak")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "A3", "Total")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A2", "1")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A3", "2")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "G2", "TRANSPORT")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "G3", "TRANSPORT")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A4", "Total")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "J4", "4")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "L4", "44000")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "M4", "3")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "O4", "30000")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "H2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "I2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "D2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "P2", "-")
for _, cell := range []string{"K2", "L2", "N2", "O2"} {
val, err := file.GetCellValue("UPAH", cell)
if err != nil {
t.Fatalf("GetCellValue(UPAH,%s) failed: %v", cell, err)
}
if strings.Contains(val, "Rp") {
t.Fatalf("expected numeric plain value in %s, got %q", cell, val)
}
}
}
func assertExpenseSheetCellEquals(t *testing.T, file *excelize.File, sheet, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(sheet, cell)
if err != nil {
t.Fatalf("GetCellValue(%s,%s) failed: %v", sheet, cell, err)
}
if got != expected {
t.Fatalf("expected %s!%s=%q, got %q", sheet, cell, expected, got)
}
}
func buildExpenseExportTestItem(
reference,
poNumber,
category,
product,
location,
kandang string,
transactionDate time.Time,
realizationDate *time.Time,
qtyPengajuan,
hargaPengajuan,
totalPengajuan,
qtyRealisasi,
hargaRealisasi,
totalRealisasi float64,
stepName string,
action *string,
) dto.RepportExpenseListDTO {
item := dto.RepportExpenseListDTO{
RepportExpenseBaseDTO: dto.RepportExpenseBaseDTO{
ReferenceNumber: reference,
PoNumber: poNumber,
Category: category,
TransactionDate: transactionDate,
RealizationDate: realizationDate,
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
},
Pengajuan: dto.RepportExpensePengajuanDTO{
Qty: qtyPengajuan,
Price: hargaPengajuan,
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
Realisasi: dto.RepportExpenseRealisasiDTO{
Qty: qtyRealisasi,
Price: hargaRealisasi,
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
TotalPengajuan: totalPengajuan,
TotalRealisasi: totalRealisasi,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: stepName,
Action: action,
},
}
if kandang != "" {
item.Kandang = &kandangDTO.KandangRelationDTO{Name: kandang}
if location != "" {
item.Kandang.Location = &locationDTO.LocationRelationDTO{Name: location}
}
}
return item
}
func strPtr(value string) *string {
return &value
}
@@ -17,6 +17,7 @@ type RepportExpenseBaseDTO struct {
ReferenceNumber string `json:"reference_number"` ReferenceNumber string `json:"reference_number"`
PoNumber string `json:"po_number"` PoNumber string `json:"po_number"`
Category string `json:"category"` Category string `json:"category"`
Notes string `json:"notes"`
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"` Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"`
RealizationDate *time.Time `json:"realization_date,omitempty"` RealizationDate *time.Time `json:"realization_date,omitempty"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
@@ -74,6 +75,7 @@ func ToRepportExpenseBaseDTO(e *entity.Expense) RepportExpenseBaseDTO {
ReferenceNumber: e.ReferenceNumber, ReferenceNumber: e.ReferenceNumber,
PoNumber: e.PoNumber, PoNumber: e.PoNumber,
Category: e.Category, Category: e.Category,
Notes: e.Notes,
Supplier: supplier, Supplier: supplier,
RealizationDate: realizationDate, RealizationDate: realizationDate,
TransactionDate: e.TransactionDate, TransactionDate: e.TransactionDate,
@@ -221,7 +221,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
feedQuery := r.db.WithContext(ctx). feedQuery := r.db.WithContext(ctx).
Table("recordings AS r"). Table("recordings AS r").
Select(` Select(`
r.project_flock_kandangs_id AS project_flock_kandang_id, rs.project_flock_kandang_id AS project_flock_kandang_id,
COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost, COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost,
s.id AS supplier_id, s.id AS supplier_id,
s.name AS supplier_name, s.name AS supplier_name,
@@ -233,10 +233,10 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("f.name = ?", utils.FlagPakan). Where("f.name = ?", utils.FlagPakan).
Group("r.project_flock_kandangs_id, s.id, s.name, s.alias") Group("rs.project_flock_kandang_id, s.id, s.name, s.alias")
if err := feedQuery.Scan(&feedRows).Error; err != nil { if err := feedQuery.Scan(&feedRows).Error; err != nil {
return nil, nil, err return nil, nil, err
@@ -13,6 +13,7 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
dto "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" dto "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
@@ -21,12 +22,17 @@ import (
type expenseDepreciationRepoMock struct { type expenseDepreciationRepoMock struct {
repportRepo.ExpenseDepreciationRepository repportRepo.ExpenseDepreciationRepository
manualInputs []repportRepo.FarmDepreciationManualInputRow manualInputs []repportRepo.FarmDepreciationManualInputRow
candidateRows []repportRepo.FarmDepreciationCandidateRow
snapshots []entity.FarmDepreciationSnapshot
upsertedRow *entity.FarmDepreciationManualInput upsertedRow *entity.FarmDepreciationManualInput
deleteCalled bool deleteCalled bool
deleteDate time.Time deleteDate time.Time
deleteFarmIDs []uint deleteFarmIDs []uint
upsertSnapshotCalls int
upsertedSnapshots []entity.FarmDepreciationSnapshot
getSnapshotsCalls int
} }
func (m *expenseDepreciationRepoMock) DB() *gorm.DB { func (m *expenseDepreciationRepoMock) DB() *gorm.DB {
@@ -46,6 +52,37 @@ func (m *expenseDepreciationRepoMock) UpsertManualInput(_ context.Context, row *
return nil return nil
} }
func (m *expenseDepreciationRepoMock) GetCandidateFarms(_ context.Context, _ time.Time, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationCandidateRow, error) {
return append([]repportRepo.FarmDepreciationCandidateRow{}, m.candidateRows...), nil
}
func (m *expenseDepreciationRepoMock) GetSnapshotsByPeriodAndFarmIDs(_ context.Context, period time.Time, farmIDs []uint) ([]entity.FarmDepreciationSnapshot, error) {
m.getSnapshotsCalls++
if len(farmIDs) == 0 {
return []entity.FarmDepreciationSnapshot{}, nil
}
allowed := make(map[uint]struct{}, len(farmIDs))
for _, farmID := range farmIDs {
allowed[farmID] = struct{}{}
}
result := make([]entity.FarmDepreciationSnapshot, 0, len(m.snapshots))
for _, row := range m.snapshots {
if _, ok := allowed[row.ProjectFlockId]; !ok {
continue
}
if row.PeriodDate.IsZero() || row.PeriodDate.Format("2006-01-02") == period.Format("2006-01-02") {
result = append(result, row)
}
}
return result, nil
}
func (m *expenseDepreciationRepoMock) UpsertSnapshots(_ context.Context, rows []entity.FarmDepreciationSnapshot) error {
m.upsertSnapshotCalls++
m.upsertedSnapshots = append([]entity.FarmDepreciationSnapshot{}, rows...)
return nil
}
func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
m.deleteCalled = true m.deleteCalled = true
m.deleteDate = fromDate m.deleteDate = fromDate
@@ -57,6 +94,15 @@ func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Con
return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil
} }
type expenseRealizationRepoMock struct {
expenseRepo.ExpenseRealizationRepository
db *gorm.DB
}
func (m *expenseRealizationRepoMock) DB() *gorm.DB {
return m.db
}
type hppCostRepoMock struct { type hppCostRepoMock struct {
commonRepo.HppCostRepository commonRepo.HppCostRepository
kandangIDsByFarm map[uint][]uint kandangIDsByFarm map[uint][]uint
@@ -352,6 +398,167 @@ func TestComputeExpenseDepreciationSnapshots_ZeroWhenDepreciationMissing(t *test
} }
} }
func TestGetExpenseDepreciation_UsesExistingSnapshotWhenForceRecomputeFalse(t *testing.T) {
periodDate := mustJakartaDate(t, "2026-06-05")
repo := &expenseDepreciationRepoMock{
candidateRows: []repportRepo.FarmDepreciationCandidateRow{
{ProjectFlockID: 1, FarmName: "Farm A"},
},
snapshots: []entity.FarmDepreciationSnapshot{
{
ProjectFlockId: 1,
PeriodDate: periodDate,
DepreciationPercentEffective: 11.1,
DepreciationValue: 111,
PulletCostDayNTotal: 1001,
Components: json.RawMessage(`{"kandang_count":0,"kandang":[]}`),
},
},
}
svc := &repportService{
Validate: validator.New(),
ExpenseDepreciationRepo: repo,
ExpenseRealizationRepo: &expenseRealizationRepoMock{},
HppCostRepo: &hppCostRepoMock{},
HppV2Svc: &hppV2ServiceMock{},
}
rows, meta, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].DepreciationValue != 111 {
t.Fatalf("expected depreciation value 111, got %v", rows[0].DepreciationValue)
}
if meta == nil || meta.TotalResults != 1 {
t.Fatalf("expected meta total_results 1, got %+v", meta)
}
if repo.upsertSnapshotCalls != 0 {
t.Fatalf("expected no snapshot upsert, got %d", repo.upsertSnapshotCalls)
}
if repo.getSnapshotsCalls != 1 {
t.Fatalf("expected snapshot fetch called once, got %d", repo.getSnapshotsCalls)
}
}
func TestGetExpenseDepreciation_ForceRecomputeRebuildsAllSnapshots(t *testing.T) {
periodDate := mustJakartaDate(t, "2026-06-05")
repo := &expenseDepreciationRepoMock{
candidateRows: []repportRepo.FarmDepreciationCandidateRow{
{ProjectFlockID: 1, FarmName: "Farm A"},
},
snapshots: []entity.FarmDepreciationSnapshot{
{
ProjectFlockId: 1,
PeriodDate: periodDate,
DepreciationValue: 999,
PulletCostDayNTotal: 999,
},
},
}
svc := &repportService{
Validate: validator.New(),
ExpenseDepreciationRepo: repo,
ExpenseRealizationRepo: &expenseRealizationRepoMock{},
HppCostRepo: &hppCostRepoMock{
kandangIDsByFarm: map[uint][]uint{
1: {10},
},
},
HppV2Svc: &hppV2ServiceMock{
breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{
10: depreciationBreakdown(10, 100, "Kandang A", 100, 1000, 10),
},
},
}
rows, _, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05&force_recompute=true")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].DepreciationValue != 100 {
t.Fatalf("expected recomputed depreciation value 100, got %v", rows[0].DepreciationValue)
}
if repo.upsertSnapshotCalls != 1 {
t.Fatalf("expected snapshot upsert called once, got %d", repo.upsertSnapshotCalls)
}
if len(repo.upsertedSnapshots) != 1 || repo.upsertedSnapshots[0].ProjectFlockId != 1 {
t.Fatalf("expected upserted snapshot for farm 1, got %+v", repo.upsertedSnapshots)
}
if repo.getSnapshotsCalls != 0 {
t.Fatalf("expected no snapshot fetch in force recompute mode, got %d", repo.getSnapshotsCalls)
}
}
func TestGetExpenseDepreciation_ForceRecomputeFalseComputesOnlyMissingFarms(t *testing.T) {
periodDate := mustJakartaDate(t, "2026-06-05")
repo := &expenseDepreciationRepoMock{
candidateRows: []repportRepo.FarmDepreciationCandidateRow{
{ProjectFlockID: 1, FarmName: "Farm A"},
{ProjectFlockID: 2, FarmName: "Farm B"},
},
snapshots: []entity.FarmDepreciationSnapshot{
{
ProjectFlockId: 1,
PeriodDate: periodDate,
DepreciationPercentEffective: 11.1,
DepreciationValue: 111,
PulletCostDayNTotal: 1001,
Components: json.RawMessage(`{"kandang_count":0,"kandang":[]}`),
},
},
}
svc := &repportService{
Validate: validator.New(),
ExpenseDepreciationRepo: repo,
ExpenseRealizationRepo: &expenseRealizationRepoMock{},
HppCostRepo: &hppCostRepoMock{
kandangIDsByFarm: map[uint][]uint{
1: {10},
2: {20},
},
},
HppV2Svc: &hppV2ServiceMock{
breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{
10: depreciationBreakdown(10, 100, "Kandang A", 999, 9999, 10),
20: depreciationBreakdown(20, 200, "Kandang B", 200, 2000, 10),
},
},
}
rows, _, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].ProjectFlockID != 1 || rows[0].DepreciationValue != 111 {
t.Fatalf("expected farm 1 use existing snapshot value 111, got %+v", rows[0])
}
if rows[1].ProjectFlockID != 2 || rows[1].DepreciationValue != 200 {
t.Fatalf("expected farm 2 recomputed value 200, got %+v", rows[1])
}
if repo.upsertSnapshotCalls != 1 {
t.Fatalf("expected one upsert call for missing farms, got %d", repo.upsertSnapshotCalls)
}
if len(repo.upsertedSnapshots) != 1 || repo.upsertedSnapshots[0].ProjectFlockId != 2 {
t.Fatalf("expected upsert only farm 2 snapshot, got %+v", repo.upsertedSnapshots)
}
if repo.getSnapshotsCalls != 1 {
t.Fatalf("expected snapshot fetch called once, got %d", repo.getSnapshotsCalls)
}
}
func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDate(t *testing.T) { func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDate(t *testing.T) {
repo := &expenseDepreciationRepoMock{ repo := &expenseDepreciationRepoMock{
manualInputs: []repportRepo.FarmDepreciationManualInputRow{ manualInputs: []repportRepo.FarmDepreciationManualInputRow{
@@ -411,6 +618,82 @@ func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDat
} }
} }
func getExpenseDepreciationByQuery(t *testing.T, svc *repportService, query string) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) {
t.Helper()
app := fiber.New()
var (
rows []dto.ExpenseDepreciationRowDTO
meta *dto.ExpenseDepreciationMetaDTO
)
app.Get("/", func(c *fiber.Ctx) error {
resultRows, resultMeta, err := svc.GetExpenseDepreciation(c)
if err != nil {
return err
}
rows = resultRows
meta = resultMeta
return c.SendStatus(fiber.StatusOK)
})
target := "/"
if query != "" {
target += "?" + query
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, target, nil))
if err != nil {
return nil, nil, err
}
if resp.StatusCode != fiber.StatusOK {
return nil, nil, fiber.NewError(resp.StatusCode, "request failed")
}
return rows, meta, nil
}
func depreciationBreakdown(
projectFlockKandangID uint,
kandangID uint,
kandangName string,
depreciationValue float64,
pulletCostDayN float64,
depreciationPercent float64,
) *approvalService.HppV2Breakdown {
return &approvalService.HppV2Breakdown{
ProjectFlockKandangID: projectFlockKandangID,
KandangID: kandangID,
KandangName: kandangName,
HouseType: "close_house",
Components: []approvalService.HppV2Component{
{
Code: "DEPRECIATION",
Title: "Depreciation",
Total: depreciationValue,
Parts: []approvalService.HppV2ComponentPart{
{
Code: "normal_transfer",
Total: depreciationValue,
Details: map[string]any{
"schedule_day": 1,
"depreciation_percent": depreciationPercent,
"pullet_cost_day_n": pulletCostDayN,
"source_project_flock_id": 77,
"origin_date": "2026-01-01",
},
References: []approvalService.HppV2Reference{
{
Type: "laying_transfer",
ID: 701,
Date: "2026-05-20",
Qty: 100,
},
},
},
},
},
},
}
}
func decodeDepreciationComponents(t *testing.T, raw []byte) depreciationFarmComponents { func decodeDepreciationComponents(t *testing.T, raw []byte) depreciationFarmComponents {
t.Helper() t.Helper()
var out depreciationFarmComponents var out depreciationFarmComponents
@@ -231,25 +231,9 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe
farmNameByID[row.ProjectFlockID] = row.FarmName farmNameByID[row.ProjectFlockID] = row.FarmName
} }
snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs) snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot)
if err != nil { if params.ForceRecompute {
return nil, nil, err computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, farmIDs, farmNameByID)
}
snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots))
for _, row := range snapshots {
snapshotByFarmID[row.ProjectFlockId] = row
}
missingFarmIDs := make([]uint, 0)
for _, farmID := range farmIDs {
if _, exists := snapshotByFarmID[farmID]; exists {
continue
}
missingFarmIDs = append(missingFarmIDs, farmID)
}
if len(missingFarmIDs) > 0 {
computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID)
if computeErr != nil { if computeErr != nil {
return nil, nil, computeErr return nil, nil, computeErr
} }
@@ -257,10 +241,43 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe
if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil { if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil {
return nil, nil, err return nil, nil, err
} }
snapshotByFarmID = make(map[uint]entity.FarmDepreciationSnapshot, len(computedSnapshots))
for _, row := range computedSnapshots { for _, row := range computedSnapshots {
snapshotByFarmID[row.ProjectFlockId] = row snapshotByFarmID[row.ProjectFlockId] = row
} }
} }
} else {
snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs)
if err != nil {
return nil, nil, err
}
snapshotByFarmID = make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots))
for _, row := range snapshots {
snapshotByFarmID[row.ProjectFlockId] = row
}
missingFarmIDs := make([]uint, 0)
for _, farmID := range farmIDs {
if _, exists := snapshotByFarmID[farmID]; exists {
continue
}
missingFarmIDs = append(missingFarmIDs, farmID)
}
if len(missingFarmIDs) > 0 {
computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID)
if computeErr != nil {
return nil, nil, computeErr
}
if len(computedSnapshots) > 0 {
if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil {
return nil, nil, err
}
for _, row := range computedSnapshots {
snapshotByFarmID[row.ProjectFlockId] = row
}
}
}
} }
rows := make([]dto.ExpenseDepreciationRowDTO, 0, len(candidateRows)) rows := make([]dto.ExpenseDepreciationRowDTO, 0, len(candidateRows))
@@ -2366,8 +2383,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
} }
if hppCost != nil { if hppCost != nil {
eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir
// eggHpp = hppCost.Estimation.HargaKg eggHpp = hppCost.Estimation.HargaKg
eggHpp = hppCost.Real.HargaKg // eggHpp = hppCost.Real.HargaKg
eggTotalPiecesFloat = hppCost.Estimation.Butir eggTotalPiecesFloat = hppCost.Estimation.Butir
eggWeightFloat = hppCost.Estimation.Kg eggWeightFloat = hppCost.Estimation.Kg
if eggTotalPiecesFloat > 0 { if eggTotalPiecesFloat > 0 {
@@ -2717,6 +2734,7 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat
rawLocation := ctx.Query("location_id", "") rawLocation := ctx.Query("location_id", "")
rawProjectFlock := ctx.Query("project_flock_id", "") rawProjectFlock := ctx.Query("project_flock_id", "")
period := strings.TrimSpace(ctx.Query("period", "")) period := strings.TrimSpace(ctx.Query("period", ""))
forceRecompute := ctx.QueryBool("force_recompute", false)
areaIDs, err := parseCommaSeparatedInt64s(rawArea) areaIDs, err := parseCommaSeparatedInt64s(rawArea)
if err != nil { if err != nil {
@@ -2766,6 +2784,7 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat
Page: page, Page: page,
Limit: limit, Limit: limit,
Period: period, Period: period,
ForceRecompute: forceRecompute,
ProjectFlockIDs: projectFlockIDs, ProjectFlockIDs: projectFlockIDs,
AreaIDs: areaIDs, AreaIDs: areaIDs,
LocationIDs: locationIDs, LocationIDs: locationIDs,
@@ -84,6 +84,7 @@ type ExpenseDepreciationQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"` Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"`
Period string `query:"period" validate:"required,datetime=2006-01-02"` Period string `query:"period" validate:"required,datetime=2006-01-02"`
ForceRecompute bool `query:"force_recompute"`
ProjectFlockIDs []int64 `query:"-"` ProjectFlockIDs []int64 `query:"-"`
AreaIDs []int64 `query:"-"` AreaIDs []int64 `query:"-"`
LocationIDs []int64 `query:"-"` LocationIDs []int64 `query:"-"`
+5 -4
View File
@@ -8,7 +8,7 @@ import (
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
) )
func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock { func MapStocks(recordingID uint, ownerProjectFlockKandangID *uint, items []validation.Stock) []entity.RecordingStock {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
@@ -18,9 +18,10 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto
usagePtr := new(float64) usagePtr := new(float64)
*usagePtr = item.Qty *usagePtr = item.Qty
result = append(result, entity.RecordingStock{ result = append(result, entity.RecordingStock{
RecordingId: recordingID, RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId, ProductWarehouseId: item.ProductWarehouseId,
UsageQty: usagePtr, ProjectFlockKandangId: ownerProjectFlockKandangID,
UsageQty: usagePtr,
}) })
} }
return result return result
BIN
View File
Binary file not shown.