mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Merge branch 'development' into 'production'
Development See merge request mbugroup/lti-api!443
This commit is contained in:
+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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1588,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").
|
||||
@@ -1601,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").
|
||||
@@ -1627,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()).
|
||||
@@ -1651,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)
|
||||
|
||||
@@ -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,
|
||||
@@ -153,7 +211,13 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
)`,
|
||||
`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,
|
||||
@@ -180,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,
|
||||
@@ -194,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)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,8 @@ func (r *DailyChecklistRepositoryImpl) ListScopedChecklistIDs(c *fiber.Ctx, ids
|
||||
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.id IN ?", ids).
|
||||
Where("dc.deleted_at IS NULL")
|
||||
|
||||
db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
||||
if err != nil {
|
||||
|
||||
@@ -122,6 +122,13 @@ type DailyChecklistReportCategory struct {
|
||||
Baik int
|
||||
}
|
||||
|
||||
const (
|
||||
dailyChecklistDateLayout = "2006-01-02"
|
||||
dailyChecklistCategoryEmptyKandang = "empty_kandang"
|
||||
dailyChecklistStatusRejected = "REJECTED"
|
||||
dailyChecklistStatusDraft = "DRAFT"
|
||||
)
|
||||
|
||||
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
|
||||
return &dailyChecklistService{
|
||||
Log: utils.Log,
|
||||
@@ -146,7 +153,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 {
|
||||
@@ -196,7 +204,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").
|
||||
@@ -228,7 +236,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")
|
||||
@@ -501,66 +510,39 @@ 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) {
|
||||
return err
|
||||
if req.EmptyKandang {
|
||||
return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
|
||||
}
|
||||
|
||||
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 = ?", date, req.KandangId, category, "REJECTED").
|
||||
Count(&rejectedCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if rejectedCount > 0 {
|
||||
createStatus = "DRAFT"
|
||||
}
|
||||
|
||||
createBody := &entity.DailyChecklist{
|
||||
KandangId: req.KandangId,
|
||||
Date: date,
|
||||
Category: category,
|
||||
Status: &createStatus,
|
||||
}
|
||||
|
||||
if err := tx.Create(createBody).Error; err != nil {
|
||||
// Handle concurrent insert for active checklist with same key.
|
||||
if findErr := tx.
|
||||
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
|
||||
Take(existing).Error; findErr == nil {
|
||||
targetID = existing.Id
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -570,6 +552,109 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
||||
return s.GetOne(c, targetID)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -712,7 +797,35 @@ 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")
|
||||
}
|
||||
@@ -1152,7 +1265,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").
|
||||
@@ -1224,7 +1337,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").
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,6 +24,8 @@ type ExpenseController struct {
|
||||
ExpenseService service.ExpenseService
|
||||
}
|
||||
|
||||
const expenseExcelExportFetchLimit = 100
|
||||
|
||||
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
|
||||
return &ExpenseController{
|
||||
ExpenseService: expenseService,
|
||||
@@ -51,9 +53,26 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -80,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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||
@@ -86,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
|
||||
@@ -98,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 {
|
||||
|
||||
@@ -42,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 {
|
||||
|
||||
@@ -23,6 +23,8 @@ type DeliveryOrdersController struct {
|
||||
DeliveryOrdersService service.DeliveryOrdersService
|
||||
}
|
||||
|
||||
const marketingExcelExportFetchLimit = 100
|
||||
|
||||
func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController {
|
||||
return &DeliveryOrdersController{
|
||||
DeliveryOrdersService: deliveryOrdersService,
|
||||
@@ -49,39 +51,29 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).Send(content)
|
||||
}
|
||||
|
||||
parseUintListParam := func(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
|
||||
}
|
||||
|
||||
productIDs, err := parseUintListParam(c.Query("product_ids", ""))
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -108,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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -85,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
|
||||
@@ -158,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
|
||||
@@ -208,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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,7 +27,7 @@ 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) {
|
||||
@@ -54,13 +54,19 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
|
||||
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)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -19,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)
|
||||
@@ -147,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)
|
||||
}
|
||||
@@ -184,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)
|
||||
}
|
||||
@@ -218,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 ?
|
||||
@@ -225,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)
|
||||
}
|
||||
|
||||
@@ -116,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
|
||||
@@ -450,12 +449,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
return err
|
||||
}
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range mappedStocks {
|
||||
if i >= len(stockDesired) {
|
||||
@@ -519,14 +518,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
s.Log.Errorf("Failed to compute recording metrics: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
||||
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
|
||||
}
|
||||
if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
||||
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 {
|
||||
@@ -587,8 +586,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return err
|
||||
}
|
||||
|
||||
recordingEntity = recording
|
||||
pfkForRoute := recordingEntity.ProjectFlockKandang
|
||||
recordingEntity = recording
|
||||
pfkForRoute := recordingEntity.ProjectFlockKandang
|
||||
if pfkForRoute == nil || pfkForRoute.Id == 0 {
|
||||
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
|
||||
if fetchErr != nil {
|
||||
@@ -599,43 +598,43 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return fetchErr
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
hasStockChanges := req.Stocks != nil
|
||||
hasDepletionChanges := req.Depletions != nil
|
||||
hasEggChanges := req.Eggs != nil
|
||||
|
||||
var existingStocks []entity.RecordingStock
|
||||
var existingDepletions []entity.RecordingDepletion
|
||||
var existingEggs []entity.RecordingEgg
|
||||
var mappedDepletions []entity.RecordingDepletion
|
||||
var stockOwnerProjectFlockKandangID *uint
|
||||
var existingStocks []entity.RecordingStock
|
||||
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)
|
||||
return err
|
||||
}
|
||||
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)
|
||||
return err
|
||||
}
|
||||
existingUsage := recordingutil.StockUsageByWarehouse(existingStocks)
|
||||
incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks)
|
||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
|
||||
if match {
|
||||
hasStockChanges = false
|
||||
} else {
|
||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
|
||||
if match {
|
||||
hasStockChanges = false
|
||||
} else {
|
||||
if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -643,11 +642,11 @@ 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, stockOwnerProjectFlockKandangID, note, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasDepletionChanges {
|
||||
existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id)
|
||||
@@ -809,22 +808,22 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return err
|
||||
}
|
||||
|
||||
if hasStockChanges || hasDepletionChanges || hasEggChanges {
|
||||
if hasStockChanges || hasDepletionChanges || hasEggChanges {
|
||||
if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil {
|
||||
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to recalculate recordings after update: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to recalculate recordings after update: %+v", err)
|
||||
return err
|
||||
}
|
||||
if hasStockChanges {
|
||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -1082,15 +1081,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
||||
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)
|
||||
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
||||
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
|
||||
})
|
||||
@@ -2905,32 +2904,32 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
||||
if len(list) > 0 {
|
||||
stock = list[0]
|
||||
existingByWarehouse[item.ProductWarehouseId] = list[1:]
|
||||
} else {
|
||||
zero := 0.0
|
||||
stock = entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||
UsageQty: &zero,
|
||||
PendingQty: &zero,
|
||||
}
|
||||
if err := s.Repository.CreateStock(tx, &stock); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
zero := 0.0
|
||||
stock = entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||
UsageQty: &zero,
|
||||
PendingQty: &zero,
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
desired := item.Qty
|
||||
stock.UsageQty = &desired
|
||||
zero := 0.0
|
||||
stock.PendingQty = &zero
|
||||
stocksToApply = append(stocksToApply, stock)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"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"
|
||||
@@ -24,6 +25,8 @@ type PurchaseController struct {
|
||||
service service.PurchaseService
|
||||
}
|
||||
|
||||
const purchaseExcelExportFetchLimit = 100
|
||||
|
||||
func NewPurchaseController(s service.PurchaseService) *PurchaseController {
|
||||
return &PurchaseController{service: s}
|
||||
}
|
||||
@@ -48,20 +51,14 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).Send(content)
|
||||
}
|
||||
|
||||
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")),
|
||||
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 {
|
||||
@@ -88,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,334 @@
|
||||
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": 18,
|
||||
"F": 18,
|
||||
"G": 52,
|
||||
"H": 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",
|
||||
"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", "H1", 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, formatPurchaseExportStatus(item)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "F"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "G"+row, formatPurchaseProducts(item)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "H"+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", "H"+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, "F2", "F"+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 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,157 @@
|
||||
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),
|
||||
buildPurchaseItemForExportTest(12, "Vitamin A", 350000),
|
||||
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": "Status",
|
||||
"F1": "Grand Total",
|
||||
"G1": "Products",
|
||||
"H1": "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", "Manager Purchase")
|
||||
assertPurchaseCellEquals(t, file, "F2", "Rp 1.350.000")
|
||||
assertPurchaseCellEquals(t, file, "G2", "Pakan Starter, Vitamin A")
|
||||
assertPurchaseCellEquals(t, file, "H2", "catatan")
|
||||
|
||||
assertPurchaseCellEquals(t, file, "A3", "PR-00012")
|
||||
assertPurchaseCellEquals(t, file, "B3", "-")
|
||||
assertPurchaseCellEquals(t, file, "C3", "-")
|
||||
assertPurchaseCellEquals(t, file, "E3", "Ditolak")
|
||||
assertPurchaseCellEquals(t, file, "F3", "Rp 75.000")
|
||||
assertPurchaseCellEquals(t, file, "G3", "Obat X")
|
||||
assertPurchaseCellEquals(t, file, "H3", "-")
|
||||
}
|
||||
|
||||
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) entity.PurchaseItem {
|
||||
return entity.PurchaseItem{
|
||||
ProductId: productID,
|
||||
TotalPrice: totalPrice,
|
||||
Product: &entity.Product{
|
||||
Id: productID,
|
||||
Name: productName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ptrApprovalAction(value entity.ApprovalAction) *entity.ApprovalAction {
|
||||
return &value
|
||||
}
|
||||
@@ -247,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 ?
|
||||
@@ -256,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")
|
||||
})
|
||||
@@ -2361,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,
|
||||
|
||||
Reference in New Issue
Block a user