mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be440af1c2 | |||
| e6010fe47e | |||
| cdcc268d89 | |||
| 891da70efb | |||
| e45ebca5a4 | |||
| eacc460f67 | |||
| d2ab1c7ea5 | |||
| 151edf578e | |||
| e065e1fb25 | |||
| e24e2ff123 | |||
| 266f683db1 | |||
| c744043321 | |||
| 4673c7ad33 | |||
| 3e99caf3a7 | |||
| a15fd1b174 | |||
| 831d72cb86 | |||
| ff630a1ed0 | |||
| 7d223c81ba | |||
| 91d51bf1b8 | |||
| 2a141a96d1 | |||
| f51fa0a16c | |||
| 9b9f5e257e | |||
| adabd43f38 | |||
| 640b6b382b | |||
| ca6e9ef0d2 | |||
| c8ea370e4b | |||
| fec7bb5825 | |||
| 091f706276 | |||
| 5594c27108 | |||
| e91c45ee50 | |||
| 5b2766676b | |||
| 5e7c51e9c2 | |||
| a98a709766 | |||
| 0d04397bd5 | |||
| ded8be198a | |||
| 1e34a0e7b2 | |||
| c5bb0ef577 | |||
| 5b2f66c0c7 | |||
| 916f1980e9 | |||
| 5355fe0729 | |||
| e679193f18 | |||
| 36a740d330 | |||
| 75d42354e9 |
@@ -30,3 +30,4 @@ coverage/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
.gemini/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 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("f.name = ?", utils.FlagPakan).
|
||||
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 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("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
|
||||
|
||||
@@ -103,6 +103,7 @@ type HppV2CostRepository interface {
|
||||
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
|
||||
GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, 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)
|
||||
GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, 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
|
||||
}
|
||||
|
||||
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(
|
||||
ctx context.Context,
|
||||
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 product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id").
|
||||
Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id").
|
||||
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames).
|
||||
Group(`
|
||||
@@ -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 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("f.name = ?", utils.FlagPakan).
|
||||
Scan(&total).Error
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -96,6 +97,116 @@ func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(
|
||||
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 {
|
||||
t.Helper()
|
||||
|
||||
@@ -111,6 +222,12 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
record_datetime 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 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NULL,
|
||||
@@ -174,6 +291,11 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
id INTEGER PRIMARY KEY,
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
marketing_product_id INTEGER NULL,
|
||||
@@ -187,6 +309,26 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
flagable_id INTEGER 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
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
hppV2ComponentBopRegular = "BOP_REGULAR"
|
||||
hppV2ComponentBopEksp = "BOP_EKSPEDISI"
|
||||
hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST"
|
||||
hppV2ComponentRecordingStockRoute = "RECORDING_STOCK_ROUTE"
|
||||
hppV2ComponentDepreciation = "DEPRECIATION"
|
||||
hppV2PartGrowingNormal = "growing_normal"
|
||||
hppV2PartGrowingCutover = "growing_cutover"
|
||||
@@ -26,6 +27,7 @@ const (
|
||||
hppV2PartLayingDirect = "laying_direct"
|
||||
hppV2PartLayingFarm = "laying_farm"
|
||||
hppV2PartManualCutover = "manual_cutover"
|
||||
hppV2PartRecordingStockRoute = "recording_stock_route"
|
||||
hppV2PartDepreciationNormal = "normal_transfer"
|
||||
hppV2PartDepreciationCutover = "manual_cutover"
|
||||
hppV2PartDepreciationFarmSnapshot = "farm_snapshot"
|
||||
@@ -190,6 +192,12 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1064,6 +1072,100 @@ func (s *hppV2Service) getManualPulletCostComponent(
|
||||
}, 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(
|
||||
projectFlockKandangId uint,
|
||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||
|
||||
@@ -25,6 +25,7 @@ type hppV2RepoStub struct {
|
||||
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
|
||||
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
routeCostByProject map[uint]float64
|
||||
totalPopulationByKey map[string]float64
|
||||
transferSummaryByPFK map[uint]struct {
|
||||
projectFlockID uint
|
||||
@@ -60,6 +61,10 @@ func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Con
|
||||
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) {
|
||||
if s.snapshotByProjectKey == nil {
|
||||
return nil, nil
|
||||
|
||||
+17
@@ -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;
|
||||
+52
@@ -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;
|
||||
+21
@@ -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;
|
||||
+27
@@ -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;
|
||||
+41
@@ -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 $$;
|
||||
@@ -1,6 +1,10 @@
|
||||
package entities
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DailyChecklist struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
@@ -14,12 +18,15 @@ type DailyChecklist struct {
|
||||
DocumentPath *string
|
||||
RejectReason *string
|
||||
CreatedBy *uint
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
DeletedBy *uint
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
|
||||
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
|
||||
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
|
||||
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ type HouseDepreciationStandard struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
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"`
|
||||
StandardWeek int `gorm:"column:standard_week;not null"`
|
||||
DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package entities
|
||||
|
||||
type RecordingStock struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
UsageQty *float64 `gorm:"column:usage_qty"`
|
||||
PendingQty *float64 `gorm:"column:pending_qty"`
|
||||
Id uint `gorm:"primaryKey"`
|
||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"`
|
||||
UsageQty *float64 `gorm:"column:usage_qty"`
|
||||
PendingQty *float64 `gorm:"column:pending_qty"`
|
||||
|
||||
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
||||
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||
|
||||
@@ -153,6 +153,20 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
||||
}
|
||||
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)
|
||||
|
||||
byFlag := map[string]**SapronakCategoryDTO{}
|
||||
@@ -258,6 +272,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
||||
UnitPrice: item.Harga,
|
||||
Notes: "-",
|
||||
}
|
||||
isCutOver := containsCutOver(baseRow.ProductCategory, baseRow.Description, item.ProductName)
|
||||
|
||||
row := getOrCreateRow(productKey, baseRow)
|
||||
|
||||
@@ -289,11 +304,21 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
||||
}
|
||||
row.QtyUsed += item.QtyKeluar
|
||||
row.TotalAmount += item.QtyKeluar * price
|
||||
case "adjustment keluar", "mutasi keluar", "penjualan":
|
||||
case "adjustment keluar":
|
||||
price := row.UnitPrice
|
||||
if price == 0 {
|
||||
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
|
||||
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
|
||||
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(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
params.ProjectFlockKandangIDs,
|
||||
fifo.UsableKeyProjectChickin.String(),
|
||||
params.ProjectFlockKandangIDs,
|
||||
params.ProjectFlockKandangIDs,
|
||||
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 products prod ON prod.id = pw.product_id").
|
||||
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").
|
||||
Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used").
|
||||
Scan(&usageAgg).Error
|
||||
@@ -905,7 +908,11 @@ WITH scoped_farm_allocations AS (
|
||||
WHERE sa.stockable_type = ?
|
||||
AND sa.status = ?
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
@@ -1167,7 +1174,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui
|
||||
"recording_stocks rs",
|
||||
"pw.id = rs.product_warehouse_id",
|
||||
[]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,
|
||||
sapronakFlagsUsage,
|
||||
)
|
||||
@@ -1208,7 +1215,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p
|
||||
COALESCE(rs.usage_qty,0) AS qty_out,
|
||||
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,
|
||||
sapronakFlagsUsage,
|
||||
)
|
||||
@@ -1294,7 +1301,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
|
||||
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
|
||||
Where("f.name IN ?", sapronakFlagsAll).
|
||||
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
|
||||
(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.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
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("pi.received_date IS NOT NULL")
|
||||
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) {
|
||||
poByWarehouse := r.DB().
|
||||
Table("purchase_items pi").
|
||||
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
|
||||
Joins("JOIN purchases po ON po.id = pi.purchase_id").
|
||||
Where("pi.received_date IS NOT NULL").
|
||||
Order("pi.product_warehouse_id, pi.received_date ASC")
|
||||
Table("(?) AS ranked_po",
|
||||
r.DB().
|
||||
Table("purchase_items pi").
|
||||
Select(`
|
||||
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).
|
||||
Table("adjustment_stocks AS ast").
|
||||
@@ -1589,10 +1610,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
|
||||
p.name AS product_name,
|
||||
f.name AS flag,
|
||||
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,
|
||||
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 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,
|
||||
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(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,
|
||||
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("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("f.name IN ?", sapronakFlagsAll).
|
||||
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 = applyDateRange(outgoingQuery, dateExpr, start, end)
|
||||
outgoing, err := scanAndGroupDetails(outgoingQuery)
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlo
|
||||
if sql != sapronakIncomingPurchasesScopedSQL() {
|
||||
t.Fatalf("expected scoped purchase SQL, got %q", sql)
|
||||
}
|
||||
if len(args) != 8 {
|
||||
t.Fatalf("expected 8 argument groups, got %d", len(args))
|
||||
if len(args) != 11 {
|
||||
t.Fatalf("expected 11 argument groups, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
|
||||
(2, 10, 'products', 'OBAT')`,
|
||||
`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 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
|
||||
(1, 1, 10, 1, NULL, 100, 261700, '` + 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 {
|
||||
t.Helper()
|
||||
|
||||
@@ -134,6 +191,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
purchase_id INTEGER NOT NULL,
|
||||
product_id INTEGER NOT NULL,
|
||||
warehouse_id INTEGER NOT NULL,
|
||||
product_warehouse_id INTEGER NULL,
|
||||
project_flock_kandang_id INTEGER NULL,
|
||||
total_qty 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
|
||||
)`,
|
||||
`CREATE TABLE recording_stocks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NOT NULL,
|
||||
product_warehouse_id INTEGER NOT NULL,
|
||||
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
|
||||
)`,
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NOT NULL,
|
||||
product_warehouse_id INTEGER NOT NULL,
|
||||
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
||||
project_flock_kandang_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE project_chickins (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -179,6 +244,16 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
movement_number 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 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
stock_transfer_id INTEGER NOT NULL,
|
||||
@@ -193,6 +268,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
product_warehouse_id INTEGER NOT NULL,
|
||||
total_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,
|
||||
created_at TIMESTAMP NULL
|
||||
)`,
|
||||
|
||||
@@ -573,17 +573,21 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
|
||||
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").
|
||||
Select("MIN(chick_in_date)").
|
||||
Where("project_flock_kandang_id = ?", pfk.Id).
|
||||
Scan(&minChickin).Error; err != nil {
|
||||
Select("chick_in_date").
|
||||
Where("project_flock_kandang_id = ? AND chick_in_date IS NOT NULL", pfk.Id).
|
||||
Order("chick_in_date ASC").
|
||||
Limit(1).
|
||||
Scan(&firstChickin).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
start := pfk.CreatedAt
|
||||
if minChickin != nil && !minChickin.IsZero() {
|
||||
start = *minChickin
|
||||
if !firstChickin.ChickInDate.IsZero() {
|
||||
start = firstChickin.ChickInDate
|
||||
}
|
||||
startDate := dateOnlyUTC(start)
|
||||
|
||||
@@ -596,26 +600,34 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
|
||||
return &startDate, endDate, nil
|
||||
}
|
||||
|
||||
var minCreated time.Time
|
||||
var firstPFK entity.ProjectFlockKandang
|
||||
if err := db.Model(&entity.ProjectFlockKandang{}).
|
||||
Select("MIN(created_at)").
|
||||
Select("created_at").
|
||||
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
|
||||
}
|
||||
|
||||
var minChickin *time.Time
|
||||
var firstChickin struct {
|
||||
ChickInDate time.Time `gorm:"column:chick_in_date"`
|
||||
}
|
||||
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").
|
||||
Where("pfk.project_flock_id = ?", projectFlockID).
|
||||
Scan(&minChickin).Error; err != nil {
|
||||
Where("pfk.project_flock_id = ? AND pc.chick_in_date IS NOT NULL", projectFlockID).
|
||||
Order("pc.chick_in_date ASC").
|
||||
Limit(1).
|
||||
Scan(&firstChickin).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
start := minCreated
|
||||
if minChickin != nil && !minChickin.IsZero() {
|
||||
start = *minChickin
|
||||
start := firstPFK.CreatedAt
|
||||
if !firstChickin.ChickInDate.IsZero() {
|
||||
start = firstChickin.ChickInDate
|
||||
}
|
||||
startDate := dateOnlyUTC(start)
|
||||
|
||||
@@ -627,15 +639,19 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
|
||||
return nil, nil, err
|
||||
}
|
||||
if openCount == 0 {
|
||||
var maxClosed *time.Time
|
||||
var latestClosed entity.ProjectFlockKandang
|
||||
if err := db.Model(&entity.ProjectFlockKandang{}).
|
||||
Select("MAX(closed_at)").
|
||||
Where("project_flock_id = ?", projectFlockID).
|
||||
Scan(&maxClosed).Error; err != nil {
|
||||
Select("closed_at").
|
||||
Where("project_flock_id = ? AND closed_at IS NOT NULL", projectFlockID).
|
||||
Order("closed_at DESC").
|
||||
First(&latestClosed).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &startDate, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if maxClosed != nil && !maxClosed.IsZero() {
|
||||
d := dateOnlyUTC(*maxClosed)
|
||||
if latestClosed.ClosedAt != nil && !latestClosed.ClosedAt.IsZero() {
|
||||
d := dateOnlyUTC(*latestClosed.ClosedAt)
|
||||
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.Usage, usageRows, "Pemakaian", false)
|
||||
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.TransferOut, transferOutRows, "Mutasi Keluar", 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 {
|
||||
req := new(validation.Update)
|
||||
param := c.Params("idDailyChecklist")
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package repository
|
||||
|
||||
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"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DailyChecklistRepository interface {
|
||||
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 {
|
||||
@@ -19,3 +28,71 @@ func NewDailyChecklistRepository(db *gorm.DB) DailyChecklistRepository {
|
||||
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.Patch("/bulk-update", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.BulkUpdate)
|
||||
route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne)
|
||||
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)
|
||||
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*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
|
||||
AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error
|
||||
AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error
|
||||
@@ -121,6 +122,15 @@ type DailyChecklistReportCategory struct {
|
||||
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 {
|
||||
return &dailyChecklistService{
|
||||
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 locations loc ON loc.id = k.location_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")
|
||||
if err != nil {
|
||||
@@ -195,7 +206,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error
|
||||
|
||||
db := s.Repository.DB().WithContext(c.Context()).
|
||||
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 locations loc ON loc.id = k.location_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").
|
||||
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")
|
||||
Joins("JOIN areas a ON a.id = loc.area_id").
|
||||
Where("dc.deleted_at IS NULL")
|
||||
|
||||
var scopeErr error
|
||||
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
|
||||
}
|
||||
|
||||
date, err := time.Parse("2006-01-02", req.Date)
|
||||
date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date))
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
|
||||
}
|
||||
|
||||
status := req.Status
|
||||
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)
|
||||
|
||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) 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 <> ?)", date, req.KandangId, category, "REJECTED").
|
||||
Take(existing).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if err := tx.Model(&entity.DailyChecklist{}).
|
||||
Where("id = ?", existing.Id).
|
||||
Update("updated_at", time.Now()).Error; err != nil {
|
||||
if req.EmptyKandang {
|
||||
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, endDate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetID = existing.Id
|
||||
return nil
|
||||
return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
|
||||
}
|
||||
|
||||
createStatus := status
|
||||
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
|
||||
}
|
||||
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, endDate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetID = createBody.Id
|
||||
return nil
|
||||
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
@@ -646,11 +795,100 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
|
||||
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 {
|
||||
if err := s.ensureChecklistAccess(c, id); err != nil {
|
||||
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) {
|
||||
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
||||
}
|
||||
@@ -908,6 +1146,33 @@ func parsePhaseIDs(raw string) ([]uint, error) {
|
||||
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) {
|
||||
parts := strings.Split(raw, ",")
|
||||
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,
|
||||
MAX(a.updated_at) AS last_activity`).
|
||||
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 employees e ON e.id = a.employee_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()).
|
||||
Table("daily_checklist_activity_task_assignments AS dca").
|
||||
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 kandang_groups k ON k.id = dc.kandang_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 {
|
||||
Date string `json:"date" validate:"required"`
|
||||
KandangId uint `json:"kandang_id" validate:"required"`
|
||||
Category string `json:"category" validate:"required"`
|
||||
Status string `json:"status" validate:"required"`
|
||||
Date string `json:"date" validate:"required"`
|
||||
KandangId uint `json:"kandang_id" validate:"required"`
|
||||
Category string `json:"category" validate:"required"`
|
||||
Status string `json:"status" validate:"required"`
|
||||
EmptyKandang bool `json:"empty_kandang"`
|
||||
EmptyKandangEndDate string `json:"empty_kandang_end_date"`
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
@@ -18,6 +20,12 @@ type Update struct {
|
||||
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 {
|
||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||
|
||||
@@ -6,11 +6,16 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"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"
|
||||
service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
|
||||
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/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -19,6 +24,8 @@ type ExpenseController struct {
|
||||
ExpenseService service.ExpenseService
|
||||
}
|
||||
|
||||
const expenseExcelExportFetchLimit = 100
|
||||
|
||||
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
|
||||
return &ExpenseController{
|
||||
ExpenseService: expenseService,
|
||||
@@ -26,10 +33,46 @@ func NewExpenseController(expenseService service.ExpenseService) *ExpenseControl
|
||||
}
|
||||
|
||||
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{
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: c.Query("search", ""),
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
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 {
|
||||
@@ -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 {
|
||||
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 {
|
||||
expenseID := c.Params("id")
|
||||
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 {
|
||||
expenseID, err := strconv.Atoi(c.Params("id"))
|
||||
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"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
@@ -20,6 +21,7 @@ type ExpenseRepository interface {
|
||||
WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB
|
||||
CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, 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 {
|
||||
@@ -130,3 +132,64 @@ func (r *ExpenseRepositoryImpl) DeleteOne(ctx context.Context, id uint) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), 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.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
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)
|
||||
DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) 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 {
|
||||
@@ -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) {
|
||||
if err := s.Validate.Struct(params); err != nil {
|
||||
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 {
|
||||
db = s.withRelations(db)
|
||||
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
|
||||
if params.Search != "" {
|
||||
return db.Where("category ILIKE ?", "%"+params.Search+"%")
|
||||
db = db.Where("expenses.deleted_at IS NULL")
|
||||
|
||||
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 {
|
||||
@@ -155,6 +345,14 @@ func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetail
|
||||
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) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
@@ -742,8 +940,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
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))
|
||||
realizationRepoTx := repository.NewExpenseRealizationRepository(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 {
|
||||
documentFiles := make([]commonSvc.DocumentFile, 0, len(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,
|
||||
})
|
||||
}
|
||||
actorID := uint(1) // TODO: replace with authenticated user id
|
||||
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
||||
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
|
||||
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
|
||||
if _, err := approvalSvc.CreateApproval(
|
||||
c.Context(),
|
||||
@@ -814,9 +1015,9 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
expenseID,
|
||||
utils.ExpenseStepRealisasi,
|
||||
&approvalAction,
|
||||
uint(1), // TODO: replace with authenticated user id
|
||||
nil); err != nil {
|
||||
|
||||
actorID,
|
||||
nil,
|
||||
); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
)
|
||||
|
||||
type Create struct {
|
||||
@@ -37,9 +42,18 @@ type Update struct {
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||
Search string `query:"search" validate:"omitempty,max=50"`
|
||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||
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 {
|
||||
@@ -66,3 +80,31 @@ type ApprovalRequest struct {
|
||||
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"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"
|
||||
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
|
||||
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/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -17,6 +23,8 @@ type DeliveryOrdersController struct {
|
||||
DeliveryOrdersService service.DeliveryOrdersService
|
||||
}
|
||||
|
||||
const marketingExcelExportFetchLimit = 100
|
||||
|
||||
func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController {
|
||||
return &DeliveryOrdersController{
|
||||
DeliveryOrdersService: deliveryOrdersService,
|
||||
@@ -24,24 +32,23 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer
|
||||
}
|
||||
|
||||
func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
|
||||
parseUintListParam := func(param string) ([]uint, error) {
|
||||
if param == "" {
|
||||
return nil, nil
|
||||
if exportprogress.IsProgressExportRequest(c) {
|
||||
query, err := exportprogress.ParseQuery(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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))
|
||||
rows, err := u.DeliveryOrdersService.GetProgressRows(c, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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", ""))
|
||||
@@ -50,13 +57,23 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
query := &validation.DeliveryOrderQuery{
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: strings.TrimSpace(c.Query("search", "")),
|
||||
ProductIDs: productIDs,
|
||||
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
|
||||
CustomerId: uint(c.QueryInt("customer_id", 0)),
|
||||
MarketingId: uint(c.QueryInt("marketing_id", 0)),
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: strings.TrimSpace(c.Query("search", "")),
|
||||
ProductIDs: productIDs,
|
||||
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
|
||||
CustomerId: uint(c.QueryInt("customer_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 {
|
||||
@@ -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 {
|
||||
param := c.Params("id")
|
||||
|
||||
@@ -152,3 +219,64 @@ func (u *DeliveryOrdersController) UpdateOne(c *fiber.Ctx) error {
|
||||
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 {
|
||||
MarketingProductId uint `json:"marketing_product_id"`
|
||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
|
||||
Qty float64 `json:"qty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
@@ -153,7 +154,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD
|
||||
return MarketingDeliveryProductDTO{
|
||||
Id: e.Id,
|
||||
MarketingProductId: e.MarketingProductId,
|
||||
Qty: e.UsageQty,
|
||||
Qty: e.UsageQty + e.PendingQty,
|
||||
UnitPrice: e.UnitPrice,
|
||||
TotalWeight: e.TotalWeight,
|
||||
AvgWeight: e.AvgWeight,
|
||||
@@ -328,6 +329,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
|
||||
}
|
||||
|
||||
deliveryItem := DeliveryItemDTO{
|
||||
MarketingProductId: product.MarketingProductId,
|
||||
ProductWarehouse: product.ProductWarehouse,
|
||||
Qty: product.Qty,
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
@@ -14,6 +16,7 @@ type MarketingRepository interface {
|
||||
IdExists(ctx context.Context, id uint) (bool, error)
|
||||
GetNextSequence(ctx context.Context) (uint, 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 {
|
||||
@@ -55,3 +58,67 @@ func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.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("/approvals/bulk", deliveryOrdersCtrl.BulkApproveToStatus)
|
||||
|
||||
route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne)
|
||||
route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
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"
|
||||
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||
"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"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
@@ -32,6 +34,8 @@ type DeliveryOrdersService interface {
|
||||
GetOne(ctx *fiber.Ctx, id uint) (*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)
|
||||
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 {
|
||||
@@ -81,6 +85,102 @@ func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB {
|
||||
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) {
|
||||
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil {
|
||||
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 {
|
||||
db = db.Where(`EXISTS (
|
||||
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 = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID)
|
||||
db = s.applyMarketingSearchFilter(c.Context(), db, params.Search)
|
||||
|
||||
if scope.Restrict {
|
||||
if len(scope.IDs) == 0 {
|
||||
return db.Where("1 = 0")
|
||||
@@ -247,6 +315,14 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
|
||||
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) {
|
||||
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
|
||||
return nil, err
|
||||
@@ -544,6 +620,192 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
|
||||
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) {
|
||||
if marketingType == string(utils.MarketingTypeTrading) {
|
||||
totalWeight = 0
|
||||
@@ -1105,7 +1367,7 @@ func (s deliveryOrdersService) resolveMarketingRequestedUsageQty(ctx context.Con
|
||||
var usageQty float64
|
||||
if err := tx.WithContext(ctx).
|
||||
Table("marketing_delivery_products").
|
||||
Select("usage_qty").
|
||||
Select("usage_qty + pending_qty").
|
||||
Where("id = ?", deliveryProductID).
|
||||
Scan(&usageQty).Error; err != nil {
|
||||
return 0
|
||||
|
||||
@@ -22,13 +22,15 @@ type DeliveryOrderUpdate struct {
|
||||
}
|
||||
|
||||
type DeliveryOrderQuery struct {
|
||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||
Search string `query:"search" validate:"omitempty,max=100"`
|
||||
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
|
||||
Status string `query:"status" validate:"omitempty,max=50"`
|
||||
CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"`
|
||||
MarketingId uint `query:"marketing_id" validate:"omitempty,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"`
|
||||
Search string `query:"search" validate:"omitempty,max=100"`
|
||||
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
|
||||
Status string `query:"status" validate:"omitempty,max=50"`
|
||||
CustomerId uint `query:"customer_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 {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 {
|
||||
CustomerId uint `json:"customer_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"`
|
||||
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)
|
||||
|
||||
route := v1.Group("/project-flock-kandangs")
|
||||
// route.Use(m.Auth(u))
|
||||
route.Use(m.Auth(u))
|
||||
route.Get("/", ctrl.GetAll)
|
||||
route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne)
|
||||
// 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)
|
||||
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
|
||||
} else {
|
||||
dtoResult.IsTransition = isTransition
|
||||
dtoResult.IsTransition = false
|
||||
dtoResult.IsLaying = isLaying
|
||||
}
|
||||
applyCutOverLayingLookupOverride(&dtoResult)
|
||||
@@ -359,6 +359,40 @@ func applyCutOverLayingLookupOverride(result *dto.ProjectFlockKandangDTO) {
|
||||
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 {
|
||||
param := c.Params("id")
|
||||
req := new(validation.Resubmit)
|
||||
|
||||
+14
@@ -17,6 +17,7 @@ type ProjectFlockKandangRepository interface {
|
||||
GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error)
|
||||
GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, 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
|
||||
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)
|
||||
@@ -525,6 +526,19 @@ func (r *projectFlockKandangRepositoryImpl) UpdateClosedAt(ctx context.Context,
|
||||
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) {
|
||||
if kandangID == 0 {
|
||||
return false, nil
|
||||
|
||||
@@ -15,13 +15,14 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj
|
||||
route := v1.Group("/project-flocks")
|
||||
route.Use(m.Auth(u))
|
||||
|
||||
route.Get("/",m.RequirePermissions(m.P_ProjectFlockGetAll),ctrl.GetAll)
|
||||
route.Post("/",m.RequirePermissions(m.P_ProjectFlockCreate), ctrl.CreateOne)
|
||||
route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockGetOne), ctrl.GetOne)
|
||||
route.Delete("/:id",m.RequirePermissions(m.P_ProjectFlockGetAll), ctrl.DeleteOne)
|
||||
route.Get("/kandangs/lookup",m.RequirePermissions(m.P_ProjectFlockLookup), ctrl.LookupProjectFlockKandang)
|
||||
route.Post("/approvals",m.RequirePermissions(m.P_ProjectFlockApprove), ctrl.Approval)
|
||||
route.Get("/locations/:location_id/periods",m.RequirePermissions(m.P_ProjectFlockNextPeriod), ctrl.GetPeriodSummary)
|
||||
route.Put("/:id/resubmit",m.RequirePermissions(m.P_ProjectFlockResubmit), ctrl.Resubmit)
|
||||
route.Get("/", m.RequirePermissions(m.P_ProjectFlockGetAll), ctrl.GetAll)
|
||||
route.Post("/", m.RequirePermissions(m.P_ProjectFlockCreate), ctrl.CreateOne)
|
||||
route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockGetOne), ctrl.GetOne)
|
||||
route.Patch("/:id", m.RequirePermissions(m.P_ProjectFlockCreate), ctrl.UpdateOne)
|
||||
route.Delete("/:id", m.RequirePermissions(m.P_ProjectFlockGetAll), ctrl.DeleteOne)
|
||||
route.Get("/kandangs/lookup", m.RequirePermissions(m.P_ProjectFlockLookup), ctrl.LookupProjectFlockKandang)
|
||||
route.Post("/approvals", m.RequirePermissions(m.P_ProjectFlockApprove), ctrl.Approval)
|
||||
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)
|
||||
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]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
|
||||
}
|
||||
|
||||
@@ -1273,6 +1274,52 @@ func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, 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 {
|
||||
|
||||
if len(budgets) == 0 {
|
||||
|
||||
@@ -41,3 +41,7 @@ type Resubmit struct {
|
||||
KandangIds []uint `json:"kandang_ids" validate:"required_strict,min=1,dive,gt=0"`
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
|
||||
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
|
||||
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 {
|
||||
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
|
||||
projectFlockKandangID := c.QueryInt("project_flock_kandang_id", 0)
|
||||
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)
|
||||
limit := c.QueryInt("limit", 10)
|
||||
offset := (page - 1) * limit
|
||||
|
||||
query := &validation.Query{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Search: c.Query("search"),
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
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 {
|
||||
query.ProjectFlockKandangId = uint(projectFlockID)
|
||||
if projectFlockKandangID > 0 {
|
||||
query.ProjectFlockKandangId = uint(projectFlockKandangID)
|
||||
}
|
||||
|
||||
result, totalResults, err := u.RecordingService.GetAll(c, query)
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -18,10 +21,10 @@ type RecordingRepository interface {
|
||||
|
||||
WithRelations(db *gorm.DB) *gorm.DB
|
||||
WithRelationsList(db *gorm.DB) *gorm.DB
|
||||
ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
|
||||
ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
|
||||
ApplyListFilters(db *gorm.DB, params *validation.Query) *gorm.DB
|
||||
ApplyListCountFilters(db *gorm.DB, params *validation.Query) *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)
|
||||
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, 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)
|
||||
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID 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 {
|
||||
@@ -145,36 +149,89 @@ func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
|
||||
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 = db.
|
||||
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")
|
||||
if projectFlockKandangId != 0 {
|
||||
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
|
||||
}
|
||||
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
|
||||
Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id")
|
||||
db = r.applyStructuredListFilters(db, params)
|
||||
db = r.ApplySearchFilters(db, search)
|
||||
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.
|
||||
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")
|
||||
if projectFlockKandangId != 0 {
|
||||
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
|
||||
}
|
||||
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
|
||||
Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id")
|
||||
db = r.applyStructuredListFilters(db, params)
|
||||
db = r.ApplySearchFilters(db, search)
|
||||
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 (
|
||||
records []entity.Recording
|
||||
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 {
|
||||
countQ = modifier(countQ)
|
||||
}
|
||||
@@ -182,7 +239,7 @@ func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset,
|
||||
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 {
|
||||
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 wd ON wd.id = pwd.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(`
|
||||
LOWER(pf.flock_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(ws.name) LIKE ?
|
||||
OR LOWER(wd.name) LIKE ?
|
||||
OR LOWER(we.name) LIKE ?`,
|
||||
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
|
||||
OR LOWER(we.name) LIKE ?
|
||||
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)
|
||||
}
|
||||
@@ -250,6 +313,65 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C
|
||||
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) {
|
||||
if projectFlockKandangId == 0 {
|
||||
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"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
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/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type RecordingService interface {
|
||||
@@ -42,6 +44,7 @@ type RecordingService interface {
|
||||
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error)
|
||||
DeleteOne(ctx *fiber.Ctx, id uint) error
|
||||
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
|
||||
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
|
||||
}
|
||||
|
||||
type recordingService struct {
|
||||
@@ -113,8 +116,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
||||
c.Context(),
|
||||
params.Offset,
|
||||
params.Limit,
|
||||
params.Search,
|
||||
params.ProjectFlockKandangId,
|
||||
params,
|
||||
func(db *gorm.DB) *gorm.DB {
|
||||
db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id")
|
||||
return db
|
||||
@@ -202,6 +204,14 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
||||
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) {
|
||||
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -431,7 +449,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
return err
|
||||
}
|
||||
|
||||
mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks)
|
||||
mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks)
|
||||
stockDesired := resetStockQuantitiesForFIFO(mappedStocks)
|
||||
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
|
||||
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)
|
||||
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
|
||||
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
|
||||
}
|
||||
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil {
|
||||
return err
|
||||
}
|
||||
routePayload := buildRecordingRoutePayloadFromUpdate(req)
|
||||
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
|
||||
return err
|
||||
@@ -590,10 +615,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
var existingDepletions []entity.RecordingDepletion
|
||||
var existingEggs []entity.RecordingEgg
|
||||
var mappedDepletions []entity.RecordingDepletion
|
||||
var stockOwnerProjectFlockKandangID *uint
|
||||
|
||||
note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
|
||||
|
||||
if hasStockChanges {
|
||||
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
|
||||
if err != nil {
|
||||
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)
|
||||
incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks)
|
||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage)
|
||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
|
||||
if match {
|
||||
hasStockChanges = false
|
||||
} 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -788,6 +818,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
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
|
||||
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)
|
||||
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)
|
||||
|
||||
return nil
|
||||
@@ -1425,12 +1465,13 @@ func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
businessDate := recordDate
|
||||
physicalMoveDate := transferPhysicalMoveDate(transfer)
|
||||
if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) {
|
||||
return nil
|
||||
if !physicalMoveDate.IsZero() && businessDate.Before(physicalMoveDate) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1443,102 +1484,10 @@ func (s *recordingService) enforceTransferRecordingRoute(
|
||||
recordTime time.Time,
|
||||
payload recordingRoutePayload,
|
||||
) error {
|
||||
if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_ = ctx
|
||||
_ = pfk
|
||||
_ = recordTime
|
||||
_ = payload
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1648,6 +1597,362 @@ func boolPtr(value bool) *bool {
|
||||
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(
|
||||
ctx context.Context,
|
||||
pfk *entity.ProjectFlockKandang,
|
||||
@@ -2578,6 +2883,7 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
||||
recordingID uint,
|
||||
existing []entity.RecordingStock,
|
||||
incoming []validation.Stock,
|
||||
ownerProjectFlockKandangID *uint,
|
||||
note string,
|
||||
actorID uint,
|
||||
) error {
|
||||
@@ -2601,15 +2907,26 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
||||
} else {
|
||||
zero := 0.0
|
||||
stock = entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
UsageQty: &zero,
|
||||
PendingQty: &zero,
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||
UsageQty: &zero,
|
||||
PendingQty: &zero,
|
||||
}
|
||||
if err := s.Repository.CreateStock(tx, &stock); err != nil {
|
||||
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
|
||||
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 {
|
||||
t.Helper()
|
||||
|
||||
@@ -193,3 +240,23 @@ func setupRecordingServiceTestDB(t *testing.T) *gorm.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"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1"`
|
||||
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"`
|
||||
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 {
|
||||
|
||||
@@ -231,6 +231,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transferDate, err := utils.ParseDateString(req.TransferDate)
|
||||
if err != nil {
|
||||
@@ -451,6 +454,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
|
||||
); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -1611,6 +1617,80 @@ func (s *transferLayingService) validateKandangOwnership(
|
||||
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) {
|
||||
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
"mime/multipart"
|
||||
"strconv"
|
||||
"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"
|
||||
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
|
||||
@@ -22,25 +25,40 @@ type PurchaseController struct {
|
||||
service service.PurchaseService
|
||||
}
|
||||
|
||||
const purchaseExcelExportFetchLimit = 100
|
||||
|
||||
func NewPurchaseController(s service.PurchaseService) *PurchaseController {
|
||||
return &PurchaseController{service: s}
|
||||
}
|
||||
|
||||
func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
|
||||
query := &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)),
|
||||
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
|
||||
if exportprogress.IsProgressExportRequest(c) {
|
||||
query, err := exportprogress.ParseQuery(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := ctrl.service.GetProgressRows(c, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := exportprogress.BuildWorkbook("Purchases", query, rows)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
|
||||
}
|
||||
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 {
|
||||
@@ -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 {
|
||||
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: ¬es,
|
||||
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 = ¬es
|
||||
}
|
||||
|
||||
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"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
@@ -28,6 +29,7 @@ type PurchaseRepository interface {
|
||||
SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
|
||||
GetItemsByProjectFlockID(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 {
|
||||
@@ -284,6 +286,75 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
|
||||
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 {
|
||||
if len(itemIDs) == 0 {
|
||||
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"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
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)
|
||||
DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error)
|
||||
DeletePurchase(ctx *fiber.Ctx, id uint) error
|
||||
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -245,7 +247,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
||||
if len(productCategoryIDs) > 0 {
|
||||
db = db.Where(
|
||||
`EXISTS (
|
||||
SELECT 1
|
||||
SELECT 1
|
||||
FROM purchase_items pi
|
||||
JOIN products p ON p.id = pi.product_id
|
||||
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 {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
db = applyPurchaseProjectFlockFilter(db, params.ProjectFlockID, params.ProjectFlockKandangID)
|
||||
db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
|
||||
db = applyPurchaseSearchFilter(db, search)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
|
||||
if err != nil {
|
||||
@@ -2351,6 +2187,155 @@ func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, er
|
||||
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 {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch value {
|
||||
|
||||
@@ -61,17 +61,19 @@ type DeletePurchaseItemsRequest struct {
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
||||
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
|
||||
AreaID uint `query:"area_id" validate:"omitempty,gt=0"`
|
||||
LocationID uint `query:"location_id" validate:"omitempty,gt=0"`
|
||||
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
|
||||
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
|
||||
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
|
||||
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
|
||||
PoDateTo string `query:"po_date_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"`
|
||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
||||
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
|
||||
AreaID uint `query:"area_id" validate:"omitempty,gt=0"`
|
||||
LocationID uint `query:"location_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"`
|
||||
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
|
||||
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
|
||||
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
|
||||
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
|
||||
PoDateTo string `query:"po_date_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
|
||||
}
|
||||
|
||||
const expenseReportExcelExportFetchLimit = 100
|
||||
|
||||
func NewRepportController(repportService service.RepportService) *RepportController {
|
||||
return &RepportController{
|
||||
RepportService: repportService,
|
||||
@@ -66,6 +68,14 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
|
||||
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 {
|
||||
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 {
|
||||
rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx)
|
||||
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"`
|
||||
PoNumber string `json:"po_number"`
|
||||
Category string `json:"category"`
|
||||
Notes string `json:"notes"`
|
||||
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"`
|
||||
RealizationDate *time.Time `json:"realization_date,omitempty"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
@@ -74,6 +75,7 @@ func ToRepportExpenseBaseDTO(e *entity.Expense) RepportExpenseBaseDTO {
|
||||
ReferenceNumber: e.ReferenceNumber,
|
||||
PoNumber: e.PoNumber,
|
||||
Category: e.Category,
|
||||
Notes: e.Notes,
|
||||
Supplier: supplier,
|
||||
RealizationDate: realizationDate,
|
||||
TransactionDate: e.TransactionDate,
|
||||
|
||||
@@ -221,7 +221,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
|
||||
feedQuery := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
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,
|
||||
s.id AS supplier_id,
|
||||
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("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_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("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 {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
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"
|
||||
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
||||
@@ -21,12 +22,17 @@ import (
|
||||
|
||||
type expenseDepreciationRepoMock struct {
|
||||
repportRepo.ExpenseDepreciationRepository
|
||||
manualInputs []repportRepo.FarmDepreciationManualInputRow
|
||||
manualInputs []repportRepo.FarmDepreciationManualInputRow
|
||||
candidateRows []repportRepo.FarmDepreciationCandidateRow
|
||||
snapshots []entity.FarmDepreciationSnapshot
|
||||
|
||||
upsertedRow *entity.FarmDepreciationManualInput
|
||||
deleteCalled bool
|
||||
deleteDate time.Time
|
||||
deleteFarmIDs []uint
|
||||
upsertedRow *entity.FarmDepreciationManualInput
|
||||
deleteCalled bool
|
||||
deleteDate time.Time
|
||||
deleteFarmIDs []uint
|
||||
upsertSnapshotCalls int
|
||||
upsertedSnapshots []entity.FarmDepreciationSnapshot
|
||||
getSnapshotsCalls int
|
||||
}
|
||||
|
||||
func (m *expenseDepreciationRepoMock) DB() *gorm.DB {
|
||||
@@ -46,6 +52,37 @@ func (m *expenseDepreciationRepoMock) UpsertManualInput(_ context.Context, row *
|
||||
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 {
|
||||
m.deleteCalled = true
|
||||
m.deleteDate = fromDate
|
||||
@@ -57,6 +94,15 @@ func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Con
|
||||
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 {
|
||||
commonRepo.HppCostRepository
|
||||
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) {
|
||||
repo := &expenseDepreciationRepoMock{
|
||||
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 {
|
||||
t.Helper()
|
||||
var out depreciationFarmComponents
|
||||
|
||||
@@ -231,25 +231,9 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe
|
||||
farmNameByID[row.ProjectFlockID] = row.FarmName
|
||||
}
|
||||
|
||||
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)
|
||||
snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot)
|
||||
if params.ForceRecompute {
|
||||
computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, farmIDs, farmNameByID)
|
||||
if computeErr != nil {
|
||||
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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
snapshotByFarmID = make(map[uint]entity.FarmDepreciationSnapshot, len(computedSnapshots))
|
||||
for _, row := range computedSnapshots {
|
||||
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))
|
||||
@@ -2366,8 +2383,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
|
||||
}
|
||||
if hppCost != nil {
|
||||
eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir
|
||||
// eggHpp = hppCost.Estimation.HargaKg
|
||||
eggHpp = hppCost.Real.HargaKg
|
||||
eggHpp = hppCost.Estimation.HargaKg
|
||||
// eggHpp = hppCost.Real.HargaKg
|
||||
eggTotalPiecesFloat = hppCost.Estimation.Butir
|
||||
eggWeightFloat = hppCost.Estimation.Kg
|
||||
if eggTotalPiecesFloat > 0 {
|
||||
@@ -2717,6 +2734,7 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat
|
||||
rawLocation := ctx.Query("location_id", "")
|
||||
rawProjectFlock := ctx.Query("project_flock_id", "")
|
||||
period := strings.TrimSpace(ctx.Query("period", ""))
|
||||
forceRecompute := ctx.QueryBool("force_recompute", false)
|
||||
|
||||
areaIDs, err := parseCommaSeparatedInt64s(rawArea)
|
||||
if err != nil {
|
||||
@@ -2766,6 +2784,7 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Period: period,
|
||||
ForceRecompute: forceRecompute,
|
||||
ProjectFlockIDs: projectFlockIDs,
|
||||
AreaIDs: areaIDs,
|
||||
LocationIDs: locationIDs,
|
||||
|
||||
@@ -84,6 +84,7 @@ type ExpenseDepreciationQuery struct {
|
||||
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"`
|
||||
Period string `query:"period" validate:"required,datetime=2006-01-02"`
|
||||
ForceRecompute bool `query:"force_recompute"`
|
||||
ProjectFlockIDs []int64 `query:"-"`
|
||||
AreaIDs []int64 `query:"-"`
|
||||
LocationIDs []int64 `query:"-"`
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@@ -18,9 +18,10 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto
|
||||
usagePtr := new(float64)
|
||||
*usagePtr = item.Qty
|
||||
result = append(result, entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
UsageQty: usagePtr,
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||
UsageQty: usagePtr,
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user