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
|
package entities
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
type DailyChecklist struct {
|
type DailyChecklist struct {
|
||||||
Id uint `gorm:"primaryKey"`
|
Id uint `gorm:"primaryKey"`
|
||||||
@@ -14,12 +18,15 @@ type DailyChecklist struct {
|
|||||||
DocumentPath *string
|
DocumentPath *string
|
||||||
RejectReason *string
|
RejectReason *string
|
||||||
CreatedBy *uint
|
CreatedBy *uint
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
DeletedBy *uint
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
|
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
|
||||||
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
|
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
|
||||||
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||||
|
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
|
||||||
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
|
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,20 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
|||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
normalizeCutOverToken := func(raw string) string {
|
||||||
|
normalized := strings.ToUpper(strings.TrimSpace(raw))
|
||||||
|
normalized = strings.ReplaceAll(normalized, "-", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
containsCutOver := func(values ...string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.Contains(normalizeCutOverToken(value), "CUTOVER") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
filter := normalizeFlag(flag)
|
filter := normalizeFlag(flag)
|
||||||
|
|
||||||
byFlag := map[string]**SapronakCategoryDTO{}
|
byFlag := map[string]**SapronakCategoryDTO{}
|
||||||
@@ -258,6 +272,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
|||||||
UnitPrice: item.Harga,
|
UnitPrice: item.Harga,
|
||||||
Notes: "-",
|
Notes: "-",
|
||||||
}
|
}
|
||||||
|
isCutOver := containsCutOver(baseRow.ProductCategory, baseRow.Description, item.ProductName)
|
||||||
|
|
||||||
row := getOrCreateRow(productKey, baseRow)
|
row := getOrCreateRow(productKey, baseRow)
|
||||||
|
|
||||||
@@ -289,11 +304,21 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
|||||||
}
|
}
|
||||||
row.QtyUsed += item.QtyKeluar
|
row.QtyUsed += item.QtyKeluar
|
||||||
row.TotalAmount += item.QtyKeluar * price
|
row.TotalAmount += item.QtyKeluar * price
|
||||||
case "adjustment keluar", "mutasi keluar", "penjualan":
|
case "adjustment keluar":
|
||||||
price := row.UnitPrice
|
price := row.UnitPrice
|
||||||
if price == 0 {
|
if price == 0 {
|
||||||
price = item.Harga
|
price = item.Harga
|
||||||
}
|
}
|
||||||
|
if row.UnitPrice == 0 {
|
||||||
|
row.UnitPrice = price
|
||||||
|
}
|
||||||
|
if isCutOver {
|
||||||
|
row.QtyUsed += item.QtyKeluar
|
||||||
|
row.TotalAmount += item.QtyKeluar * price
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row.QtyOut += item.QtyKeluar
|
||||||
|
case "mutasi keluar", "penjualan":
|
||||||
row.QtyOut += item.QtyKeluar
|
row.QtyOut += item.QtyKeluar
|
||||||
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
|
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
|
||||||
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
|
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestToSapronakProjectAggregatedFromReportMovesCutOverAdjustmentOutToQtyUsed(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
groupFlag string
|
||||||
|
filter string
|
||||||
|
productFlag string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "pakan cut-over",
|
||||||
|
groupFlag: "PAKAN",
|
||||||
|
filter: "PAKAN",
|
||||||
|
productFlag: "PAKAN CUT-OVER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ovk cut over",
|
||||||
|
groupFlag: "OVK",
|
||||||
|
filter: "OVK",
|
||||||
|
productFlag: "OVK CUT OVER",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
report := &SapronakReportDTO{
|
||||||
|
Groups: []SapronakGroupDTO{
|
||||||
|
{
|
||||||
|
Flag: tc.groupFlag,
|
||||||
|
Items: []SapronakDetailDTO{
|
||||||
|
{
|
||||||
|
ProductID: 1,
|
||||||
|
ProductName: "CUTOVER ITEM",
|
||||||
|
NoReferensi: "ADJ-CUT-01",
|
||||||
|
JenisTransaksi: "Adjustment Keluar",
|
||||||
|
QtyKeluar: 5,
|
||||||
|
Harga: 15000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ToSapronakProjectAggregatedFromReport(
|
||||||
|
report,
|
||||||
|
tc.filter,
|
||||||
|
map[uint][]string{
|
||||||
|
1: {tc.productFlag},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
var cat *SapronakCategoryDTO
|
||||||
|
if tc.groupFlag == "PAKAN" {
|
||||||
|
cat = result.Pakan
|
||||||
|
} else {
|
||||||
|
cat = result.Ovk
|
||||||
|
}
|
||||||
|
if cat == nil {
|
||||||
|
t.Fatalf("expected category payload for %s", tc.groupFlag)
|
||||||
|
}
|
||||||
|
if len(cat.Rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 row, got %d", len(cat.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
row := cat.Rows[0]
|
||||||
|
if row.QtyOut != 0 {
|
||||||
|
t.Fatalf("expected qty_out 0 for cut-over adjustment, got %.2f", row.QtyOut)
|
||||||
|
}
|
||||||
|
if row.QtyUsed != 5 {
|
||||||
|
t.Fatalf("expected qty_used 5 for cut-over adjustment, got %.2f", row.QtyUsed)
|
||||||
|
}
|
||||||
|
if row.UnitPrice != 15000 {
|
||||||
|
t.Fatalf("expected unit_price 15000, got %.2f", row.UnitPrice)
|
||||||
|
}
|
||||||
|
if row.TotalAmount != 75000 {
|
||||||
|
t.Fatalf("expected total_amount 75000, got %.2f", row.TotalAmount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToSapronakProjectAggregatedFromReportKeepsNonCutOverAdjustmentOutInQtyOut(t *testing.T) {
|
||||||
|
report := &SapronakReportDTO{
|
||||||
|
Groups: []SapronakGroupDTO{
|
||||||
|
{
|
||||||
|
Flag: "PAKAN",
|
||||||
|
Items: []SapronakDetailDTO{
|
||||||
|
{
|
||||||
|
ProductID: 7,
|
||||||
|
ProductName: "PAKAN REGULER",
|
||||||
|
NoReferensi: "ADJ-REG-01",
|
||||||
|
JenisTransaksi: "Adjustment Keluar",
|
||||||
|
QtyKeluar: 3,
|
||||||
|
Harga: 12000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ToSapronakProjectAggregatedFromReport(
|
||||||
|
report,
|
||||||
|
"PAKAN",
|
||||||
|
map[uint][]string{
|
||||||
|
7: {"PAKAN"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if result.Pakan == nil || len(result.Pakan.Rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 pakan row, got %+v", result.Pakan)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := result.Pakan.Rows[0]
|
||||||
|
if row.QtyOut != 3 {
|
||||||
|
t.Fatalf("expected qty_out 3 for non cut-over adjustment, got %.2f", row.QtyOut)
|
||||||
|
}
|
||||||
|
if row.QtyUsed != 0 {
|
||||||
|
t.Fatalf("expected qty_used 0 for non cut-over adjustment, got %.2f", row.QtyUsed)
|
||||||
|
}
|
||||||
|
if row.TotalAmount != 0 {
|
||||||
|
t.Fatalf("expected total_amount 0 for non cut-over adjustment, got %.2f", row.TotalAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
|
||||||
poByWarehouse := r.DB().
|
poByWarehouse := r.DB().
|
||||||
Table("purchase_items pi").
|
Table("(?) AS ranked_po",
|
||||||
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
|
r.DB().
|
||||||
Joins("JOIN purchases po ON po.id = pi.purchase_id").
|
Table("purchase_items pi").
|
||||||
Where("pi.received_date IS NOT NULL").
|
Select(`
|
||||||
Order("pi.product_warehouse_id, pi.received_date ASC")
|
pi.product_warehouse_id,
|
||||||
|
po.po_number,
|
||||||
|
pi.received_date,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY pi.product_warehouse_id ORDER BY pi.received_date ASC, pi.id ASC) AS rn
|
||||||
|
`).
|
||||||
|
Joins("JOIN purchases po ON po.id = pi.purchase_id").
|
||||||
|
Where("pi.received_date IS NOT NULL"),
|
||||||
|
).
|
||||||
|
Select("ranked_po.product_warehouse_id, ranked_po.po_number, ranked_po.received_date").
|
||||||
|
Where("ranked_po.rn = 1")
|
||||||
|
|
||||||
incomingQuery := r.withCtx(ctx).
|
incomingQuery := r.withCtx(ctx).
|
||||||
Table("adjustment_stocks AS ast").
|
Table("adjustment_stocks AS ast").
|
||||||
@@ -1601,10 +1610,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
|
|||||||
p.name AS product_name,
|
p.name AS product_name,
|
||||||
f.name AS flag,
|
f.name AS flag,
|
||||||
ast.created_at AS date,
|
ast.created_at AS date,
|
||||||
CONCAT('ADJ-', ast.id) AS reference,
|
'ADJ-' || CAST(ast.id AS TEXT) AS reference,
|
||||||
COALESCE(ast.total_qty, 0) AS qty_in,
|
COALESCE(ast.total_qty, 0) AS qty_in,
|
||||||
0 AS qty_out,
|
0 AS qty_out,
|
||||||
COALESCE(p.product_price, 0) AS price
|
COALESCE(ast.price, p.product_price, 0) AS price
|
||||||
`).
|
`).
|
||||||
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
|
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
|
||||||
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
@@ -1627,10 +1636,18 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
|
|||||||
p.name AS product_name,
|
p.name AS product_name,
|
||||||
f.name AS flag,
|
f.name AS flag,
|
||||||
COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
|
COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
|
||||||
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference,
|
COALESCE(
|
||||||
|
po.po_number,
|
||||||
|
st.movement_number,
|
||||||
|
lt.transfer_number,
|
||||||
|
pfp_po.po_number,
|
||||||
|
CASE WHEN ast_in.id IS NOT NULL THEN 'ADJ-' || CAST(ast_in.id AS TEXT) END,
|
||||||
|
CASE WHEN ast.id IS NOT NULL THEN 'ADJ-' || CAST(ast.id AS TEXT) END,
|
||||||
|
CASE WHEN pc.id IS NOT NULL THEN 'CHICKIN-' || CAST(pc.id AS TEXT) END
|
||||||
|
) AS reference,
|
||||||
0 AS qty_in,
|
0 AS qty_in,
|
||||||
COALESCE(SUM(sa.qty), 0) AS qty_out,
|
COALESCE(SUM(sa.qty), 0) AS qty_out,
|
||||||
COALESCE(p.product_price, 0) AS price
|
COALESCE(pi.price, ast_in.price, ast.price, p.product_price, 0) AS price
|
||||||
`).
|
`).
|
||||||
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()).
|
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()).
|
||||||
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
|
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
|
||||||
@@ -1651,7 +1668,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
|
|||||||
Where("w.kandang_id = ?", kandangID).
|
Where("w.kandang_id = ?", kandangID).
|
||||||
Where("f.name IN ?", sapronakFlagsAll).
|
Where("f.name IN ?", sapronakFlagsAll).
|
||||||
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
|
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
|
||||||
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price")
|
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, pi.price, ast_in.price, ast.price, p.product_price")
|
||||||
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
|
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
|
||||||
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
|
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
|
||||||
outgoing, err := scanAndGroupDetails(outgoingQuery)
|
outgoing, err := scanAndGroupDetails(outgoingQuery)
|
||||||
|
|||||||
@@ -89,6 +89,63 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetchSapronakAdjustmentsUsesAdjustmentReferenceAndPrice(t *testing.T) {
|
||||||
|
db := setupClosingRepositoryTestDB(t)
|
||||||
|
repo := NewClosingRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
statements := []string{
|
||||||
|
`INSERT INTO warehouses (id, kandang_id) VALUES (5, 5)`,
|
||||||
|
`INSERT INTO product_categories (id, code) VALUES (1, 'OBT')`,
|
||||||
|
`INSERT INTO products (id, name, product_category_id, product_price) VALUES (17, 'OVK CUT-OVER', 1, 1)`,
|
||||||
|
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES (1, 17, 'products', 'OVK')`,
|
||||||
|
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id) VALUES (1365, 17, 5, 66)`,
|
||||||
|
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, usage_qty, price) VALUES
|
||||||
|
(1139, 1365, 1, 0, 298594487),
|
||||||
|
(1140, 1365, 0, 1, 298594487)`,
|
||||||
|
fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES
|
||||||
|
(25990, 1365, '%s', 1139, '%s', 1140, 1, 'CONSUME', 'ACTIVE')`,
|
||||||
|
fifo.StockableKeyAdjustmentIn.String(),
|
||||||
|
fifo.UsableKeyAdjustmentOut.String(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatalf("failed seeding schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming, outgoing, err := repo.FetchSapronakAdjustments(ctx, 5, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
incomingRows := incoming[17]
|
||||||
|
if len(incomingRows) != 1 {
|
||||||
|
t.Fatalf("expected 1 incoming row for product 17, got %d", len(incomingRows))
|
||||||
|
}
|
||||||
|
if incomingRows[0].Reference != "ADJ-1139" {
|
||||||
|
t.Fatalf("expected incoming reference ADJ-1139, got %q", incomingRows[0].Reference)
|
||||||
|
}
|
||||||
|
if incomingRows[0].Price != 298594487 {
|
||||||
|
t.Fatalf("expected incoming price 298594487 from adjustment_stocks.price, got %.3f", incomingRows[0].Price)
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoingRows := outgoing[17]
|
||||||
|
if len(outgoingRows) != 1 {
|
||||||
|
t.Fatalf("expected 1 outgoing row for product 17, got %d", len(outgoingRows))
|
||||||
|
}
|
||||||
|
if outgoingRows[0].Reference != "ADJ-1139" {
|
||||||
|
t.Fatalf("expected outgoing reference ADJ-1139, got %q", outgoingRows[0].Reference)
|
||||||
|
}
|
||||||
|
if outgoingRows[0].Reference == "CHICKIN-" {
|
||||||
|
t.Fatalf("expected outgoing reference to avoid CHICKIN- placeholder")
|
||||||
|
}
|
||||||
|
if outgoingRows[0].Price != 298594487 {
|
||||||
|
t.Fatalf("expected outgoing price 298594487 from adjustment_stocks.price, got %.3f", outgoingRows[0].Price)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -134,6 +191,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
|||||||
purchase_id INTEGER NOT NULL,
|
purchase_id INTEGER NOT NULL,
|
||||||
product_id INTEGER NOT NULL,
|
product_id INTEGER NOT NULL,
|
||||||
warehouse_id INTEGER NOT NULL,
|
warehouse_id INTEGER NOT NULL,
|
||||||
|
product_warehouse_id INTEGER NULL,
|
||||||
project_flock_kandang_id INTEGER NULL,
|
project_flock_kandang_id INTEGER NULL,
|
||||||
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
||||||
price NUMERIC(15,3) NOT NULL DEFAULT 0,
|
price NUMERIC(15,3) NOT NULL DEFAULT 0,
|
||||||
@@ -153,7 +211,13 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
|||||||
)`,
|
)`,
|
||||||
`CREATE TABLE project_chickins (
|
`CREATE TABLE project_chickins (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
project_flock_kandang_id INTEGER NOT NULL
|
project_flock_kandang_id INTEGER NOT NULL,
|
||||||
|
chick_in_date TIMESTAMP NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE project_flock_populations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_chickin_id INTEGER NULL,
|
||||||
|
product_warehouse_id INTEGER NULL
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE stock_allocations (
|
`CREATE TABLE stock_allocations (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -180,6 +244,16 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
|||||||
movement_number TEXT NULL,
|
movement_number TEXT NULL,
|
||||||
reason TEXT NULL
|
reason TEXT NULL
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE laying_transfers (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
transfer_date TIMESTAMP NULL,
|
||||||
|
transfer_number TEXT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE laying_transfer_targets (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
laying_transfer_id INTEGER NOT NULL,
|
||||||
|
product_warehouse_id INTEGER NULL
|
||||||
|
)`,
|
||||||
`CREATE TABLE stock_transfer_details (
|
`CREATE TABLE stock_transfer_details (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
stock_transfer_id INTEGER NOT NULL,
|
stock_transfer_id INTEGER NOT NULL,
|
||||||
@@ -194,6 +268,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
|||||||
product_warehouse_id INTEGER NOT NULL,
|
product_warehouse_id INTEGER NOT NULL,
|
||||||
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
||||||
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
||||||
|
price NUMERIC(15,3) NOT NULL DEFAULT 0,
|
||||||
adj_number TEXT NULL,
|
adj_number TEXT NULL,
|
||||||
created_at TIMESTAMP NULL
|
created_at TIMESTAMP NULL
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -573,17 +573,21 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var minChickin *time.Time
|
var firstChickin struct {
|
||||||
|
ChickInDate time.Time `gorm:"column:chick_in_date"`
|
||||||
|
}
|
||||||
if err := db.Table("project_chickins").
|
if err := db.Table("project_chickins").
|
||||||
Select("MIN(chick_in_date)").
|
Select("chick_in_date").
|
||||||
Where("project_flock_kandang_id = ?", pfk.Id).
|
Where("project_flock_kandang_id = ? AND chick_in_date IS NOT NULL", pfk.Id).
|
||||||
Scan(&minChickin).Error; err != nil {
|
Order("chick_in_date ASC").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&firstChickin).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
start := pfk.CreatedAt
|
start := pfk.CreatedAt
|
||||||
if minChickin != nil && !minChickin.IsZero() {
|
if !firstChickin.ChickInDate.IsZero() {
|
||||||
start = *minChickin
|
start = firstChickin.ChickInDate
|
||||||
}
|
}
|
||||||
startDate := dateOnlyUTC(start)
|
startDate := dateOnlyUTC(start)
|
||||||
|
|
||||||
@@ -596,26 +600,34 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
|
|||||||
return &startDate, endDate, nil
|
return &startDate, endDate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var minCreated time.Time
|
var firstPFK entity.ProjectFlockKandang
|
||||||
if err := db.Model(&entity.ProjectFlockKandang{}).
|
if err := db.Model(&entity.ProjectFlockKandang{}).
|
||||||
Select("MIN(created_at)").
|
Select("created_at").
|
||||||
Where("project_flock_id = ?", projectFlockID).
|
Where("project_flock_id = ?", projectFlockID).
|
||||||
Scan(&minCreated).Error; err != nil {
|
Order("created_at ASC").
|
||||||
|
First(&firstPFK).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var minChickin *time.Time
|
var firstChickin struct {
|
||||||
|
ChickInDate time.Time `gorm:"column:chick_in_date"`
|
||||||
|
}
|
||||||
if err := db.Table("project_chickins pc").
|
if err := db.Table("project_chickins pc").
|
||||||
Select("MIN(pc.chick_in_date)").
|
Select("pc.chick_in_date").
|
||||||
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
|
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
|
||||||
Where("pfk.project_flock_id = ?", projectFlockID).
|
Where("pfk.project_flock_id = ? AND pc.chick_in_date IS NOT NULL", projectFlockID).
|
||||||
Scan(&minChickin).Error; err != nil {
|
Order("pc.chick_in_date ASC").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&firstChickin).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
start := minCreated
|
start := firstPFK.CreatedAt
|
||||||
if minChickin != nil && !minChickin.IsZero() {
|
if !firstChickin.ChickInDate.IsZero() {
|
||||||
start = *minChickin
|
start = firstChickin.ChickInDate
|
||||||
}
|
}
|
||||||
startDate := dateOnlyUTC(start)
|
startDate := dateOnlyUTC(start)
|
||||||
|
|
||||||
@@ -627,15 +639,19 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if openCount == 0 {
|
if openCount == 0 {
|
||||||
var maxClosed *time.Time
|
var latestClosed entity.ProjectFlockKandang
|
||||||
if err := db.Model(&entity.ProjectFlockKandang{}).
|
if err := db.Model(&entity.ProjectFlockKandang{}).
|
||||||
Select("MAX(closed_at)").
|
Select("closed_at").
|
||||||
Where("project_flock_id = ?", projectFlockID).
|
Where("project_flock_id = ? AND closed_at IS NOT NULL", projectFlockID).
|
||||||
Scan(&maxClosed).Error; err != nil {
|
Order("closed_at DESC").
|
||||||
|
First(&latestClosed).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return &startDate, nil, nil
|
||||||
|
}
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if maxClosed != nil && !maxClosed.IsZero() {
|
if latestClosed.ClosedAt != nil && !latestClosed.ClosedAt.IsZero() {
|
||||||
d := dateOnlyUTC(*maxClosed)
|
d := dateOnlyUTC(*latestClosed.ClosedAt)
|
||||||
endDate = &d
|
endDate = &d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetSapronakDateRange_ProjectWithoutChickin_DoesNotError(t *testing.T) {
|
||||||
|
db := setupClosingServiceTestDB(t)
|
||||||
|
repo := repository.NewClosingRepository(db)
|
||||||
|
svc := closingService{Repository: repo}
|
||||||
|
|
||||||
|
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
|
||||||
|
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
|
||||||
|
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start, end, err := svc.getSapronakDateRange(context.Background(), 47, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if start == nil {
|
||||||
|
t.Fatalf("expected non-nil start date")
|
||||||
|
}
|
||||||
|
expected := dateOnlyUTC(createdAt)
|
||||||
|
if !start.Equal(expected) {
|
||||||
|
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if end != nil {
|
||||||
|
t.Fatalf("expected nil end date for open kandang, got %v", end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSapronakDateRange_KandangWithoutChickin_DoesNotError(t *testing.T) {
|
||||||
|
db := setupClosingServiceTestDB(t)
|
||||||
|
repo := repository.NewClosingRepository(db)
|
||||||
|
svc := closingService{Repository: repo}
|
||||||
|
|
||||||
|
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
|
||||||
|
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
|
||||||
|
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pfkID := uint(66)
|
||||||
|
start, end, err := svc.getSapronakDateRange(context.Background(), 47, &pfkID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if start == nil {
|
||||||
|
t.Fatalf("expected non-nil start date")
|
||||||
|
}
|
||||||
|
expected := dateOnlyUTC(createdAt)
|
||||||
|
if !start.Equal(expected) {
|
||||||
|
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if end != nil {
|
||||||
|
t.Fatalf("expected nil end date for open kandang, got %v", end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupClosingServiceTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed opening sqlite db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE project_flock_kandangs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_flock_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
closed_at TIMESTAMP NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE project_chickins (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_flock_kandang_id INTEGER NOT NULL,
|
||||||
|
chick_in_date TIMESTAMP NULL
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatalf("failed preparing schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -371,7 +371,9 @@ func buildSapronakDetails(
|
|||||||
addRows(result.Incoming, incomingRows, "Pembelian", true)
|
addRows(result.Incoming, incomingRows, "Pembelian", true)
|
||||||
addRows(result.Usage, usageRows, "Pemakaian", false)
|
addRows(result.Usage, usageRows, "Pemakaian", false)
|
||||||
addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true)
|
addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true)
|
||||||
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false)
|
// Outgoing adjustment rows here are sourced from stock allocation
|
||||||
|
// consume flow (adjustment_stocks.usage_qty), so treat them as usage.
|
||||||
|
addRows(result.AdjOutgoing, adjOutgoingRows, "Pemakaian", false)
|
||||||
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
|
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
|
||||||
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
|
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
|
||||||
addRows(result.SalesOut, salesOutRows, "Penjualan", false)
|
addRows(result.SalesOut, salesOutRows, "Penjualan", false)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildSapronakDetailsMapsAdjustmentOutgoingAsUsage(t *testing.T) {
|
||||||
|
res := buildSapronakDetails(
|
||||||
|
map[uint][]repository.SapronakDetailRow{},
|
||||||
|
map[uint][]repository.SapronakDetailRow{},
|
||||||
|
map[uint][]repository.SapronakDetailRow{},
|
||||||
|
map[uint][]repository.SapronakDetailRow{
|
||||||
|
17: {
|
||||||
|
{
|
||||||
|
ProductID: 17,
|
||||||
|
ProductName: "PAKAN GROWING CRUMBLE 8603 MALINDO",
|
||||||
|
Flag: "PAKAN",
|
||||||
|
QtyOut: 9000,
|
||||||
|
Price: 6450,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[uint][]repository.SapronakDetailRow{},
|
||||||
|
map[uint][]repository.SapronakDetailRow{},
|
||||||
|
map[uint][]repository.SapronakDetailRow{},
|
||||||
|
)
|
||||||
|
|
||||||
|
rows := res.AdjOutgoing[17]
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 adjustment outgoing row, got %d", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
row := rows[0]
|
||||||
|
if row.JenisTransaksi != "Pemakaian" {
|
||||||
|
t.Fatalf("expected jenis_transaksi Pemakaian, got %q", row.JenisTransaksi)
|
||||||
|
}
|
||||||
|
if row.QtyKeluar != 9000 {
|
||||||
|
t.Fatalf("expected qty_keluar 9000, got %.3f", row.QtyKeluar)
|
||||||
|
}
|
||||||
|
if row.Nilai != 58050000 {
|
||||||
|
t.Fatalf("expected nilai 58050000, got %.3f", row.Nilai)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 kandang_groups k ON k.id = dc.kandang_id").
|
||||||
Joins("JOIN locations loc ON loc.id = k.location_id").
|
Joins("JOIN locations loc ON loc.id = k.location_id").
|
||||||
Joins("JOIN areas a ON a.id = loc.area_id").
|
Joins("JOIN areas a ON a.id = loc.area_id").
|
||||||
Where("dc.id IN ?", ids)
|
Where("dc.id IN ?", ids).
|
||||||
|
Where("dc.deleted_at IS NULL")
|
||||||
|
|
||||||
db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ type DailyChecklistReportCategory struct {
|
|||||||
Baik int
|
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 {
|
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
|
||||||
return &dailyChecklistService{
|
return &dailyChecklistService{
|
||||||
Log: utils.Log,
|
Log: utils.Log,
|
||||||
@@ -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 kandang_groups k ON k.id = dc.kandang_id").
|
||||||
Joins("JOIN locations loc ON loc.id = k.location_id").
|
Joins("JOIN locations loc ON loc.id = k.location_id").
|
||||||
Joins("JOIN areas a ON a.id = loc.area_id").
|
Joins("JOIN areas a ON a.id = loc.area_id").
|
||||||
Where("dc.id = ?", checklistID)
|
Where("dc.id = ?", checklistID).
|
||||||
|
Where("dc.deleted_at IS NULL")
|
||||||
|
|
||||||
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,7 +204,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error
|
|||||||
|
|
||||||
db := s.Repository.DB().WithContext(c.Context()).
|
db := s.Repository.DB().WithContext(c.Context()).
|
||||||
Table("daily_checklist_activity_tasks t").
|
Table("daily_checklist_activity_tasks t").
|
||||||
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id").
|
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id AND dc.deleted_at IS NULL").
|
||||||
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
|
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
|
||||||
Joins("JOIN locations loc ON loc.id = k.location_id").
|
Joins("JOIN locations loc ON loc.id = k.location_id").
|
||||||
Joins("JOIN areas a ON a.id = loc.area_id").
|
Joins("JOIN areas a ON a.id = loc.area_id").
|
||||||
@@ -228,7 +236,8 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
|
|||||||
Table("daily_checklists dc").
|
Table("daily_checklists dc").
|
||||||
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
|
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
|
||||||
Joins("JOIN locations loc ON loc.id = k.location_id").
|
Joins("JOIN locations loc ON loc.id = k.location_id").
|
||||||
Joins("JOIN areas a ON a.id = loc.area_id")
|
Joins("JOIN areas a ON a.id = loc.area_id").
|
||||||
|
Where("dc.deleted_at IS NULL")
|
||||||
|
|
||||||
var scopeErr error
|
var scopeErr error
|
||||||
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
||||||
@@ -501,66 +510,39 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
date, err := time.Parse("2006-01-02", req.Date)
|
date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
|
||||||
}
|
}
|
||||||
|
|
||||||
status := req.Status
|
status := req.Status
|
||||||
category := req.Category
|
category := req.Category
|
||||||
|
endDate := date
|
||||||
|
|
||||||
|
if req.EmptyKandang {
|
||||||
|
if strings.TrimSpace(req.EmptyKandangEndDate) == "" {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date is required when empty_kandang is true")
|
||||||
|
}
|
||||||
|
|
||||||
|
endDate, err = time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.EmptyKandangEndDate))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
if endDate.Before(date) {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date must be greater than or equal to date")
|
||||||
|
}
|
||||||
|
|
||||||
|
category = dailyChecklistCategoryEmptyKandang
|
||||||
|
}
|
||||||
|
|
||||||
targetID := uint(0)
|
targetID := uint(0)
|
||||||
|
|
||||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
existing := new(entity.DailyChecklist)
|
if req.EmptyKandang {
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
|
||||||
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 err == nil {
|
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
|
||||||
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
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
|
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
|
||||||
@@ -570,6 +552,109 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
|||||||
return s.GetOne(c, targetID)
|
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) {
|
func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) {
|
||||||
if err := s.Validate.Struct(req); err != nil {
|
if err := s.Validate.Struct(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -712,7 +797,35 @@ func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
if err := s.ensureChecklistAccess(c, id); err != nil {
|
if err := s.ensureChecklistAccess(c, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
|
actorID, err := m.ActorIDFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
|
updateResult := tx.Model(&entity.DailyChecklist{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"deleted_by": actorID,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
})
|
||||||
|
if updateResult.Error != nil {
|
||||||
|
return updateResult.Error
|
||||||
|
}
|
||||||
|
if updateResult.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResult := tx.Delete(&entity.DailyChecklist{}, id)
|
||||||
|
if deleteResult.Error != nil {
|
||||||
|
return deleteResult.Error
|
||||||
|
}
|
||||||
|
if deleteResult.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
||||||
}
|
}
|
||||||
@@ -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,
|
SUM(CASE WHEN NOT a.checked THEN 1 ELSE 0 END) AS activity_left,
|
||||||
MAX(a.updated_at) AS last_activity`).
|
MAX(a.updated_at) AS last_activity`).
|
||||||
Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id").
|
Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id").
|
||||||
Joins("JOIN daily_checklists d ON d.id = t.checklist_id").
|
Joins("JOIN daily_checklists d ON d.id = t.checklist_id AND d.deleted_at IS NULL").
|
||||||
Joins("JOIN kandang_groups k ON k.id = d.kandang_id").
|
Joins("JOIN kandang_groups k ON k.id = d.kandang_id").
|
||||||
Joins("JOIN employees e ON e.id = a.employee_id").
|
Joins("JOIN employees e ON e.id = a.employee_id").
|
||||||
Joins("JOIN locations loc ON loc.id = k.location_id").
|
Joins("JOIN locations loc ON loc.id = k.location_id").
|
||||||
@@ -1224,7 +1337,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
|
|||||||
db := s.Repository.DB().WithContext(c.Context()).
|
db := s.Repository.DB().WithContext(c.Context()).
|
||||||
Table("daily_checklist_activity_task_assignments AS dca").
|
Table("daily_checklist_activity_task_assignments AS dca").
|
||||||
Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id").
|
Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id").
|
||||||
Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id").
|
Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id AND dc.deleted_at IS NULL").
|
||||||
Joins("JOIN employees e ON e.id = dca.employee_id").
|
Joins("JOIN employees e ON e.id = dca.employee_id").
|
||||||
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
|
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
|
||||||
Joins("JOIN locations loc ON loc.id = k.location_id").
|
Joins("JOIN locations loc ON loc.id = k.location_id").
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Create struct {
|
type Create struct {
|
||||||
Date string `json:"date" validate:"required"`
|
Date string `json:"date" validate:"required"`
|
||||||
KandangId uint `json:"kandang_id" validate:"required"`
|
KandangId uint `json:"kandang_id" validate:"required"`
|
||||||
Category string `json:"category" validate:"required"`
|
Category string `json:"category" validate:"required"`
|
||||||
Status string `json:"status" validate:"required"`
|
Status string `json:"status" validate:"required"`
|
||||||
|
EmptyKandang bool `json:"empty_kandang"`
|
||||||
|
EmptyKandangEndDate string `json:"empty_kandang_end_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Update struct {
|
type Update struct {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type ExpenseController struct {
|
|||||||
ExpenseService service.ExpenseService
|
ExpenseService service.ExpenseService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expenseExcelExportFetchLimit = 100
|
||||||
|
|
||||||
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
|
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
|
||||||
return &ExpenseController{
|
return &ExpenseController{
|
||||||
ExpenseService: expenseService,
|
ExpenseService: expenseService,
|
||||||
@@ -51,9 +53,26 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := &validation.Query{
|
query := &validation.Query{
|
||||||
Page: c.QueryInt("page", 1),
|
Page: c.QueryInt("page", 1),
|
||||||
Limit: c.QueryInt("limit", 10),
|
Limit: c.QueryInt("limit", 10),
|
||||||
Search: c.Query("search", ""),
|
Search: strings.TrimSpace(c.Query("search", "")),
|
||||||
|
TransactionDate: strings.TrimSpace(c.Query("transaction_date", "")),
|
||||||
|
RealizationDate: strings.TrimSpace(c.Query("realization_date", "")),
|
||||||
|
LocationID: uint64(c.QueryInt("location_id", 0)),
|
||||||
|
VendorID: uint64(c.QueryInt("vendor_id", 0)),
|
||||||
|
Category: strings.TrimSpace(c.Query("category", "")),
|
||||||
|
ApprovalStatus: strings.TrimSpace(c.Query("approval_status", "")),
|
||||||
|
RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")),
|
||||||
|
ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)),
|
||||||
|
ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAllExpenseExcelExportRequest(c) {
|
||||||
|
allResults, err := u.getAllExpensesForExcel(c, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exportExpenseListExcel(c, allResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.Page < 1 || query.Limit < 1 {
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
@@ -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 {
|
func (u *ExpenseController) GetOne(c *fiber.Ctx) error {
|
||||||
param := c.Params("id")
|
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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
"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) {
|
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
|
||||||
if err := s.Validate.Struct(params); err != nil {
|
if err := s.Validate.Struct(params); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -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 {
|
expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
||||||
db = s.withRelations(db)
|
db = s.withRelations(db)
|
||||||
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
|
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
|
||||||
if params.Search != "" {
|
db = db.Where("expenses.deleted_at IS NULL")
|
||||||
return db.Where("category ILIKE ?", "%"+params.Search+"%")
|
|
||||||
|
if params.TransactionDate != "" {
|
||||||
|
db = db.Where("DATE(expenses.transaction_date) = DATE(?)", params.TransactionDate)
|
||||||
}
|
}
|
||||||
return db.Order("created_at DESC").Order("updated_at DESC")
|
if params.RealizationDate != "" {
|
||||||
|
db = db.Where("DATE(expenses.realization_date) = DATE(?)", params.RealizationDate)
|
||||||
|
}
|
||||||
|
if params.LocationID > 0 {
|
||||||
|
db = db.Where("expenses.location_id = ?", params.LocationID)
|
||||||
|
}
|
||||||
|
if params.VendorID > 0 {
|
||||||
|
db = db.Where("expenses.supplier_id = ?", params.VendorID)
|
||||||
|
}
|
||||||
|
if params.Category != "" {
|
||||||
|
db = db.Where("expenses.category = ?", params.Category)
|
||||||
|
}
|
||||||
|
if params.ProjectFlockID > 0 {
|
||||||
|
projectFlockJSON := fmt.Sprintf("[%d]", params.ProjectFlockID)
|
||||||
|
db = db.Where(`(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM expense_nonstocks en
|
||||||
|
LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
|
||||||
|
WHERE en.expense_id = expenses.id
|
||||||
|
AND (
|
||||||
|
pfk.project_flock_id = ? OR
|
||||||
|
en.kandang_id IN (
|
||||||
|
SELECT kandang_id
|
||||||
|
FROM project_flock_kandangs
|
||||||
|
WHERE project_flock_id = ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) OR
|
||||||
|
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
|
||||||
|
)`, params.ProjectFlockID, params.ProjectFlockID, projectFlockJSON)
|
||||||
|
}
|
||||||
|
if params.ProjectFlockKandangID > 0 {
|
||||||
|
db = db.Where(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM expense_nonstocks en
|
||||||
|
LEFT JOIN project_flock_kandangs selected_pfk ON selected_pfk.id = ?
|
||||||
|
WHERE en.expense_id = expenses.id
|
||||||
|
AND (
|
||||||
|
en.project_flock_kandang_id = ? OR
|
||||||
|
(selected_pfk.kandang_id IS NOT NULL AND en.kandang_id = selected_pfk.kandang_id)
|
||||||
|
)
|
||||||
|
)`, params.ProjectFlockKandangID, params.ProjectFlockKandangID)
|
||||||
|
}
|
||||||
|
|
||||||
|
latestApprovalSubQuery := s.Repository.DB().
|
||||||
|
WithContext(c.Context()).
|
||||||
|
Table("approvals").
|
||||||
|
Select("DISTINCT ON (approvable_id) approvable_id, step_name, action, step_number").
|
||||||
|
Where("approvable_type = ?", utils.ApprovalWorkflowExpense.String()).
|
||||||
|
Order("approvable_id, action_at DESC, id DESC")
|
||||||
|
|
||||||
|
if approvalStatus := normalizeExpenseApprovalStatusFilter(params.ApprovalStatus); approvalStatus != "" {
|
||||||
|
if approvalStatus == "REJECTED" {
|
||||||
|
db = db.Where(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (?) AS latest_approval
|
||||||
|
WHERE latest_approval.approvable_id = expenses.id
|
||||||
|
AND latest_approval.action = ?
|
||||||
|
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
|
||||||
|
} else {
|
||||||
|
db = db.Where(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (?) AS latest_approval
|
||||||
|
WHERE latest_approval.approvable_id = expenses.id
|
||||||
|
AND LOWER(latest_approval.step_name) = LOWER(?)
|
||||||
|
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
|
||||||
|
)`, latestApprovalSubQuery, approvalStatus, string(entity.ApprovalActionRejected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(params.RealizationStatus), " ", "_")) {
|
||||||
|
case "REALIZED", "SUDAH_REALISASI":
|
||||||
|
db = db.Where(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (?) AS latest_approval
|
||||||
|
WHERE latest_approval.approvable_id = expenses.id
|
||||||
|
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
|
||||||
|
AND latest_approval.step_number >= 5
|
||||||
|
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
|
||||||
|
case "NOT_REALIZED", "BELUM_REALISASI":
|
||||||
|
db = db.Where(`NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (?) AS latest_approval
|
||||||
|
WHERE latest_approval.approvable_id = expenses.id
|
||||||
|
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
|
||||||
|
AND latest_approval.step_number >= 5
|
||||||
|
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
|
||||||
|
db = db.Where(`NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (?) AS latest_approval
|
||||||
|
WHERE latest_approval.approvable_id = expenses.id
|
||||||
|
AND latest_approval.action = ?
|
||||||
|
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
|
||||||
|
case "REJECTED", "DITOLAK":
|
||||||
|
db = db.Where(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (?) AS latest_approval
|
||||||
|
WHERE latest_approval.approvable_id = expenses.id
|
||||||
|
AND latest_approval.action = ?
|
||||||
|
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
|
||||||
|
}
|
||||||
|
|
||||||
|
if search := strings.ToLower(strings.TrimSpace(params.Search)); search != "" {
|
||||||
|
like := "%" + search + "%"
|
||||||
|
db = db.Where(`(
|
||||||
|
LOWER(COALESCE(expenses.reference_number, '')) LIKE ?
|
||||||
|
OR LOWER(COALESCE(expenses.po_number, '')) LIKE ?
|
||||||
|
OR LOWER(COALESCE(expenses.category, '')) LIKE ?
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM suppliers s
|
||||||
|
WHERE s.id = expenses.supplier_id
|
||||||
|
AND LOWER(COALESCE(s.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM locations l
|
||||||
|
WHERE l.id = expenses.location_id
|
||||||
|
AND LOWER(COALESCE(l.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users u
|
||||||
|
WHERE u.id = expenses.created_by
|
||||||
|
AND LOWER(COALESCE(u.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM expense_nonstocks en
|
||||||
|
LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
|
||||||
|
LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
||||||
|
LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)
|
||||||
|
WHERE en.expense_id = expenses.id
|
||||||
|
AND (
|
||||||
|
LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
|
||||||
|
LOWER(COALESCE(k.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM approvals a
|
||||||
|
WHERE a.approvable_type = ?
|
||||||
|
AND a.approvable_id = expenses.id
|
||||||
|
AND (
|
||||||
|
LOWER(COALESCE(a.step_name, '')) LIKE ? OR
|
||||||
|
LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR
|
||||||
|
LOWER(COALESCE(a.notes, '')) LIKE ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
utils.ApprovalWorkflowExpense.String(),
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
|
||||||
})
|
})
|
||||||
|
|
||||||
if scopeErr != nil {
|
if scopeErr != nil {
|
||||||
|
|||||||
@@ -42,9 +42,18 @@ type Update struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Query struct {
|
type Query struct {
|
||||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||||
Search string `query:"search" validate:"omitempty,max=50"`
|
Search string `query:"search" validate:"omitempty,max=100"`
|
||||||
|
TransactionDate string `query:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
|
||||||
|
RealizationDate string `query:"realization_date" validate:"omitempty,datetime=2006-01-02"`
|
||||||
|
LocationID uint64 `query:"location_id" validate:"omitempty,gt=0"`
|
||||||
|
VendorID uint64 `query:"vendor_id" validate:"omitempty,gt=0"`
|
||||||
|
Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"`
|
||||||
|
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=100"`
|
||||||
|
RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"`
|
||||||
|
ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"`
|
||||||
|
ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRealization struct {
|
type CreateRealization struct {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type DeliveryOrdersController struct {
|
|||||||
DeliveryOrdersService service.DeliveryOrdersService
|
DeliveryOrdersService service.DeliveryOrdersService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const marketingExcelExportFetchLimit = 100
|
||||||
|
|
||||||
func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController {
|
func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController {
|
||||||
return &DeliveryOrdersController{
|
return &DeliveryOrdersController{
|
||||||
DeliveryOrdersService: deliveryOrdersService,
|
DeliveryOrdersService: deliveryOrdersService,
|
||||||
@@ -49,39 +51,29 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusOK).Send(content)
|
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", ""))
|
productIDs, err := parseUintListParam(c.Query("product_ids", ""))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
|
||||||
}
|
}
|
||||||
|
|
||||||
query := &validation.DeliveryOrderQuery{
|
query := &validation.DeliveryOrderQuery{
|
||||||
Page: c.QueryInt("page", 1),
|
Page: c.QueryInt("page", 1),
|
||||||
Limit: c.QueryInt("limit", 10),
|
Limit: c.QueryInt("limit", 10),
|
||||||
Search: strings.TrimSpace(c.Query("search", "")),
|
Search: strings.TrimSpace(c.Query("search", "")),
|
||||||
ProductIDs: productIDs,
|
ProductIDs: productIDs,
|
||||||
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
|
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
|
||||||
CustomerId: uint(c.QueryInt("customer_id", 0)),
|
CustomerId: uint(c.QueryInt("customer_id", 0)),
|
||||||
MarketingId: uint(c.QueryInt("marketing_id", 0)),
|
MarketingId: uint(c.QueryInt("marketing_id", 0)),
|
||||||
|
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
|
||||||
|
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAllExcelExportRequest(c) {
|
||||||
|
allResults, err := u.getAllMarketingRowsForExcel(c, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exportMarketingListExcel(c, allResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.Page < 1 || query.Limit < 1 {
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
@@ -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 {
|
func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error {
|
||||||
param := c.Params("id")
|
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")
|
Preload("Products.DeliveryProduct")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s deliveryOrdersService) marketingOwnerRelationQuery(ctx context.Context) *gorm.DB {
|
||||||
|
return s.MarketingRepo.DB().
|
||||||
|
WithContext(ctx).
|
||||||
|
Table("marketing_products mp").
|
||||||
|
Select("1").
|
||||||
|
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
|
||||||
|
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id").
|
||||||
|
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
|
||||||
|
Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
|
Joins("LEFT JOIN kandangs k ON k.id = COALESCE(pfk.kandang_id, w.kandang_id)").
|
||||||
|
Where("mp.marketing_id = marketings.id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s deliveryOrdersService) marketingAttributionRelationQuery(ctx context.Context) *gorm.DB {
|
||||||
|
baseDB := s.MarketingRepo.DB().WithContext(ctx)
|
||||||
|
return baseDB.
|
||||||
|
Table("marketing_delivery_products mdp").
|
||||||
|
Select("1").
|
||||||
|
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
|
||||||
|
Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = mdp.id", commonRepo.MarketingDeliveryAttributionRowsQuery(baseDB)).
|
||||||
|
Joins("JOIN project_flock_kandangs pfk_attr ON pfk_attr.id = mda.project_flock_kandang_id").
|
||||||
|
Joins("JOIN project_flocks pf_attr ON pf_attr.id = pfk_attr.project_flock_id").
|
||||||
|
Joins("JOIN kandangs k_attr ON k_attr.id = pfk_attr.kandang_id").
|
||||||
|
Where("mp.marketing_id = marketings.id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s deliveryOrdersService) applyMarketingProjectFlockFilter(ctx context.Context, db *gorm.DB, projectFlockID, projectFlockKandangID uint) *gorm.DB {
|
||||||
|
if projectFlockID > 0 {
|
||||||
|
db = db.Where(
|
||||||
|
"(EXISTS (?) OR EXISTS (?))",
|
||||||
|
s.marketingOwnerRelationQuery(ctx).Where("pfk.project_flock_id = ?", projectFlockID),
|
||||||
|
s.marketingAttributionRelationQuery(ctx).Where("mda.project_flock_id = ?", projectFlockID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectFlockKandangID > 0 {
|
||||||
|
db = db.Where(
|
||||||
|
"(EXISTS (?) OR EXISTS (?))",
|
||||||
|
s.marketingOwnerRelationQuery(ctx).Where("pw.project_flock_kandang_id = ?", projectFlockKandangID),
|
||||||
|
s.marketingAttributionRelationQuery(ctx).Where("mda.project_flock_kandang_id = ?", projectFlockKandangID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s deliveryOrdersService) applyMarketingSearchFilter(ctx context.Context, db *gorm.DB, rawSearch string) *gorm.DB {
|
||||||
|
searchPattern := "%" + strings.TrimSpace(rawSearch) + "%"
|
||||||
|
if searchPattern == "%%" {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Where(
|
||||||
|
`(
|
||||||
|
marketings.so_number ILIKE ? OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM customers c
|
||||||
|
WHERE c.id = marketings.customer_id
|
||||||
|
AND c.name ILIKE ?
|
||||||
|
) OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users su
|
||||||
|
WHERE su.id = marketings.sales_person_id
|
||||||
|
AND su.name ILIKE ?
|
||||||
|
) OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM marketing_products mp
|
||||||
|
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
WHERE mp.marketing_id = marketings.id
|
||||||
|
AND p.name ILIKE ?
|
||||||
|
) OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM marketing_products mp
|
||||||
|
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
|
||||||
|
JOIN warehouses w ON w.id = pw.warehouse_id
|
||||||
|
WHERE mp.marketing_id = marketings.id
|
||||||
|
AND w.name ILIKE ?
|
||||||
|
) OR
|
||||||
|
EXISTS (?) OR
|
||||||
|
EXISTS (?)
|
||||||
|
)`,
|
||||||
|
searchPattern,
|
||||||
|
searchPattern,
|
||||||
|
searchPattern,
|
||||||
|
searchPattern,
|
||||||
|
searchPattern,
|
||||||
|
s.marketingOwnerRelationQuery(ctx).Where("pf.flock_name ILIKE ? OR k.name ILIKE ?", searchPattern, searchPattern),
|
||||||
|
s.marketingAttributionRelationQuery(ctx).Where("pf_attr.flock_name ILIKE ? OR k_attr.name ILIKE ?", searchPattern, searchPattern),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) {
|
func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) {
|
||||||
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil {
|
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -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 {
|
if len(params.ProductIDs) > 0 {
|
||||||
db = db.Where(`EXISTS (
|
db = db.Where(`EXISTS (
|
||||||
SELECT 1
|
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 = db.Where("marketings.customer_id = ?", params.CustomerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID)
|
||||||
|
db = s.applyMarketingSearchFilter(c.Context(), db, params.Search)
|
||||||
|
|
||||||
if scope.Restrict {
|
if scope.Restrict {
|
||||||
if len(scope.IDs) == 0 {
|
if len(scope.IDs) == 0 {
|
||||||
return db.Where("1 = 0")
|
return db.Where("1 = 0")
|
||||||
|
|||||||
@@ -22,13 +22,15 @@ type DeliveryOrderUpdate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryOrderQuery struct {
|
type DeliveryOrderQuery struct {
|
||||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||||
Search string `query:"search" validate:"omitempty,max=100"`
|
Search string `query:"search" validate:"omitempty,max=100"`
|
||||||
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
|
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
|
||||||
Status string `query:"status" validate:"omitempty,max=50"`
|
Status string `query:"status" validate:"omitempty,max=50"`
|
||||||
CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"`
|
CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"`
|
||||||
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
|
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
|
||||||
|
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
|
||||||
|
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryOrderApprove struct {
|
type DeliveryOrderApprove struct {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *RecordingController) GetAll(c *fiber.Ctx) error {
|
func (u *RecordingController) GetAll(c *fiber.Ctx) error {
|
||||||
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
|
projectFlockKandangID := c.QueryInt("project_flock_kandang_id", 0)
|
||||||
exportType := strings.TrimSpace(c.Query("export"))
|
exportType := strings.TrimSpace(c.Query("export"))
|
||||||
|
|
||||||
if exportprogress.IsProgressExportRequest(c) {
|
if exportprogress.IsProgressExportRequest(c) {
|
||||||
@@ -54,13 +54,19 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
|
|||||||
offset := (page - 1) * limit
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
query := &validation.Query{
|
query := &validation.Query{
|
||||||
Page: page,
|
Page: page,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
Search: c.Query("search"),
|
Search: strings.TrimSpace(c.Query("search")),
|
||||||
|
ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)),
|
||||||
|
AreaId: uint(c.QueryInt("area_id", 0)),
|
||||||
|
LocationId: uint(c.QueryInt("location_id", 0)),
|
||||||
|
KandangId: uint(c.QueryInt("kandang_id", 0)),
|
||||||
|
ProjectFlockCategory: strings.TrimSpace(c.Query("project_flock_category")),
|
||||||
|
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
|
||||||
}
|
}
|
||||||
if projectFlockID > 0 {
|
if projectFlockKandangID > 0 {
|
||||||
query.ProjectFlockKandangId = uint(projectFlockID)
|
query.ProjectFlockKandangId = uint(projectFlockKandangID)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, totalResults, err := u.RecordingService.GetAll(c, query)
|
result, totalResults, err := u.RecordingService.GetAll(c, query)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,10 +21,10 @@ type RecordingRepository interface {
|
|||||||
|
|
||||||
WithRelations(db *gorm.DB) *gorm.DB
|
WithRelations(db *gorm.DB) *gorm.DB
|
||||||
WithRelationsList(db *gorm.DB) *gorm.DB
|
WithRelationsList(db *gorm.DB) *gorm.DB
|
||||||
ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
|
ApplyListFilters(db *gorm.DB, params *validation.Query) *gorm.DB
|
||||||
ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
|
ApplyListCountFilters(db *gorm.DB, params *validation.Query) *gorm.DB
|
||||||
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
|
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
|
||||||
GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error)
|
GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error)
|
||||||
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
|
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
|
||||||
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
|
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
|
||||||
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
|
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
|
||||||
@@ -147,36 +149,89 @@ func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
|
|||||||
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
|
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
|
func (r *RecordingRepositoryImpl) latestApprovalSubQuery(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Session(&gorm.Session{NewDB: true}).
|
||||||
|
Table("approvals").
|
||||||
|
Select("DISTINCT ON (approvable_id) approvable_id, action, step_name, notes").
|
||||||
|
Where("approvable_type = ?", utils.ApprovalWorkflowRecording.String()).
|
||||||
|
Order("approvable_id, action_at DESC, id DESC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RecordingRepositoryImpl) applyStructuredListFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
|
||||||
|
if params == nil {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.ProjectFlockKandangId != 0 {
|
||||||
|
db = db.Where("recordings.project_flock_kandangs_id = ?", params.ProjectFlockKandangId)
|
||||||
|
}
|
||||||
|
if params.ProjectFlockId != 0 {
|
||||||
|
db = db.Where("pfk.project_flock_id = ?", params.ProjectFlockId)
|
||||||
|
}
|
||||||
|
if params.KandangId != 0 {
|
||||||
|
db = db.Where("pfk.kandang_id = ?", params.KandangId)
|
||||||
|
}
|
||||||
|
if params.LocationId != 0 {
|
||||||
|
db = db.Where("pf.location_id = ?", params.LocationId)
|
||||||
|
}
|
||||||
|
if params.AreaId != 0 {
|
||||||
|
db = db.Where("pf.area_id = ?", params.AreaId)
|
||||||
|
}
|
||||||
|
if params.ProjectFlockCategory != "" {
|
||||||
|
db = db.Where("UPPER(COALESCE(pf.category, '')) = ?", strings.ToUpper(strings.TrimSpace(params.ProjectFlockCategory)))
|
||||||
|
}
|
||||||
|
if params.ApprovalStatus != "" {
|
||||||
|
db = db.Where(
|
||||||
|
`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (?) AS latest_approval
|
||||||
|
WHERE latest_approval.approvable_id = recordings.id
|
||||||
|
AND UPPER(COALESCE(CAST(latest_approval.action AS TEXT), '')) = ?
|
||||||
|
)`,
|
||||||
|
r.latestApprovalSubQuery(db),
|
||||||
|
strings.ToUpper(strings.TrimSpace(params.ApprovalStatus)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
|
||||||
|
search := ""
|
||||||
|
if params != nil {
|
||||||
|
search = params.Search
|
||||||
|
}
|
||||||
db = r.WithRelationsList(db)
|
db = r.WithRelationsList(db)
|
||||||
db = db.
|
db = db.
|
||||||
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
|
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
|
||||||
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
|
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
|
||||||
if projectFlockKandangId != 0 {
|
Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id")
|
||||||
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
|
db = r.applyStructuredListFilters(db, params)
|
||||||
}
|
|
||||||
db = r.ApplySearchFilters(db, search)
|
db = r.ApplySearchFilters(db, search)
|
||||||
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
|
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
|
func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
|
||||||
|
search := ""
|
||||||
|
if params != nil {
|
||||||
|
search = params.Search
|
||||||
|
}
|
||||||
db = db.
|
db = db.
|
||||||
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
|
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
|
||||||
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
|
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
|
||||||
if projectFlockKandangId != 0 {
|
Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id")
|
||||||
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
|
db = r.applyStructuredListFilters(db, params)
|
||||||
}
|
|
||||||
db = r.ApplySearchFilters(db, search)
|
db = r.ApplySearchFilters(db, search)
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) {
|
func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) {
|
||||||
var (
|
var (
|
||||||
records []entity.Recording
|
records []entity.Recording
|
||||||
total int64
|
total int64
|
||||||
)
|
)
|
||||||
|
|
||||||
countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
|
countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), params)
|
||||||
if modifier != nil {
|
if modifier != nil {
|
||||||
countQ = modifier(countQ)
|
countQ = modifier(countQ)
|
||||||
}
|
}
|
||||||
@@ -184,7 +239,7 @@ func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset,
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
|
listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), params)
|
||||||
if modifier != nil {
|
if modifier != nil {
|
||||||
listQ = modifier(listQ)
|
listQ = modifier(listQ)
|
||||||
}
|
}
|
||||||
@@ -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 ws ON ws.id = pws.warehouse_id").
|
||||||
Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id").
|
Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id").
|
||||||
Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id").
|
Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id").
|
||||||
|
Joins("LEFT JOIN users cu ON cu.id = recordings.created_by").
|
||||||
|
Joins("LEFT JOIN (?) AS latest_approval ON latest_approval.approvable_id = recordings.id", r.latestApprovalSubQuery(db)).
|
||||||
Where(`
|
Where(`
|
||||||
LOWER(pf.flock_name) LIKE ?
|
LOWER(pf.flock_name) LIKE ?
|
||||||
OR LOWER(k.name) LIKE ?
|
OR LOWER(k.name) LIKE ?
|
||||||
@@ -225,8 +282,12 @@ func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch stri
|
|||||||
OR LOWER(l.address) LIKE ?
|
OR LOWER(l.address) LIKE ?
|
||||||
OR LOWER(ws.name) LIKE ?
|
OR LOWER(ws.name) LIKE ?
|
||||||
OR LOWER(wd.name) LIKE ?
|
OR LOWER(wd.name) LIKE ?
|
||||||
OR LOWER(we.name) LIKE ?`,
|
OR LOWER(we.name) LIKE ?
|
||||||
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
|
OR LOWER(COALESCE(cu.name, '')) LIKE ?
|
||||||
|
OR LOWER(COALESCE(latest_approval.step_name, '')) LIKE ?
|
||||||
|
OR LOWER(COALESCE(CAST(latest_approval.action AS TEXT), '')) LIKE ?
|
||||||
|
OR LOWER(COALESCE(latest_approval.notes, '')) LIKE ?`,
|
||||||
|
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
|
||||||
)
|
)
|
||||||
return db.Where("recordings.id IN (?)", subQuery)
|
return db.Where("recordings.id IN (?)", subQuery)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,8 +116,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
|||||||
c.Context(),
|
c.Context(),
|
||||||
params.Offset,
|
params.Offset,
|
||||||
params.Limit,
|
params.Limit,
|
||||||
params.Search,
|
params,
|
||||||
params.ProjectFlockKandangId,
|
|
||||||
func(db *gorm.DB) *gorm.DB {
|
func(db *gorm.DB) *gorm.DB {
|
||||||
db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id")
|
db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id")
|
||||||
return db
|
return db
|
||||||
@@ -450,12 +449,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks)
|
mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks)
|
||||||
stockDesired := resetStockQuantitiesForFIFO(mappedStocks)
|
stockDesired := resetStockQuantitiesForFIFO(mappedStocks)
|
||||||
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
|
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
|
||||||
s.Log.Errorf("Failed to persist stocks: %+v", err)
|
s.Log.Errorf("Failed to persist stocks: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range mappedStocks {
|
for i := range mappedStocks {
|
||||||
if i >= len(stockDesired) {
|
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)
|
s.Log.Errorf("Failed to compute recording metrics: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
||||||
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
|
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
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)
|
s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
action := entity.ApprovalActionCreated
|
action := entity.ApprovalActionCreated
|
||||||
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
|
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
|
||||||
@@ -587,8 +586,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
recordingEntity = recording
|
recordingEntity = recording
|
||||||
pfkForRoute := recordingEntity.ProjectFlockKandang
|
pfkForRoute := recordingEntity.ProjectFlockKandang
|
||||||
if pfkForRoute == nil || pfkForRoute.Id == 0 {
|
if pfkForRoute == nil || pfkForRoute.Id == 0 {
|
||||||
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
|
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
@@ -599,43 +598,43 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
return fetchErr
|
return fetchErr
|
||||||
}
|
}
|
||||||
pfkForRoute = fetchedPfk
|
pfkForRoute = fetchedPfk
|
||||||
}
|
}
|
||||||
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil {
|
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
routePayload := buildRecordingRoutePayloadFromUpdate(req)
|
routePayload := buildRecordingRoutePayloadFromUpdate(req)
|
||||||
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
|
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
hasStockChanges := req.Stocks != nil
|
hasStockChanges := req.Stocks != nil
|
||||||
hasDepletionChanges := req.Depletions != nil
|
hasDepletionChanges := req.Depletions != nil
|
||||||
hasEggChanges := req.Eggs != nil
|
hasEggChanges := req.Eggs != nil
|
||||||
|
|
||||||
var existingStocks []entity.RecordingStock
|
var existingStocks []entity.RecordingStock
|
||||||
var existingDepletions []entity.RecordingDepletion
|
var existingDepletions []entity.RecordingDepletion
|
||||||
var existingEggs []entity.RecordingEgg
|
var existingEggs []entity.RecordingEgg
|
||||||
var mappedDepletions []entity.RecordingDepletion
|
var mappedDepletions []entity.RecordingDepletion
|
||||||
var stockOwnerProjectFlockKandangID *uint
|
var stockOwnerProjectFlockKandangID *uint
|
||||||
|
|
||||||
note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
|
note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
|
||||||
|
|
||||||
if hasStockChanges {
|
if hasStockChanges {
|
||||||
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime)
|
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
|
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Log.Errorf("Failed to list existing stocks: %+v", err)
|
s.Log.Errorf("Failed to list existing stocks: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
existingUsage := recordingutil.StockUsageByWarehouse(existingStocks)
|
existingUsage := recordingutil.StockUsageByWarehouse(existingStocks)
|
||||||
incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks)
|
incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks)
|
||||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
|
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
|
||||||
if match {
|
if match {
|
||||||
hasStockChanges = false
|
hasStockChanges = false
|
||||||
} else {
|
} else {
|
||||||
if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil {
|
if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil {
|
||||||
return err
|
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 {
|
if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil {
|
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if hasDepletionChanges {
|
if hasDepletionChanges {
|
||||||
existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasStockChanges || hasDepletionChanges || hasEggChanges {
|
if hasStockChanges || hasDepletionChanges || hasEggChanges {
|
||||||
if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil {
|
if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil {
|
||||||
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
|
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||||
s.Log.Errorf("Failed to recalculate recordings after update: %+v", err)
|
s.Log.Errorf("Failed to recalculate recordings after update: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if hasStockChanges {
|
}
|
||||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
if hasStockChanges {
|
||||||
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
|
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||||
return err
|
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
action := entity.ApprovalActionUpdated
|
action := entity.ApprovalActionUpdated
|
||||||
actorID := recordingEntity.CreatedBy
|
actorID := recordingEntity.CreatedBy
|
||||||
@@ -1082,15 +1081,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
||||||
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
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)
|
s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
|
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -2905,32 +2904,32 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
|||||||
if len(list) > 0 {
|
if len(list) > 0 {
|
||||||
stock = list[0]
|
stock = list[0]
|
||||||
existingByWarehouse[item.ProductWarehouseId] = list[1:]
|
existingByWarehouse[item.ProductWarehouseId] = list[1:]
|
||||||
} else {
|
} else {
|
||||||
zero := 0.0
|
zero := 0.0
|
||||||
stock = entity.RecordingStock{
|
stock = entity.RecordingStock{
|
||||||
RecordingId: recordingID,
|
RecordingId: recordingID,
|
||||||
ProductWarehouseId: item.ProductWarehouseId,
|
ProductWarehouseId: item.ProductWarehouseId,
|
||||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||||
UsageQty: &zero,
|
UsageQty: &zero,
|
||||||
PendingQty: &zero,
|
PendingQty: &zero,
|
||||||
}
|
|
||||||
if err := s.Repository.CreateStock(tx, &stock); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
stock.ProjectFlockKandangId = ownerProjectFlockKandangID
|
if err := s.Repository.CreateStock(tx, &stock); err != nil {
|
||||||
if stock.Id != 0 {
|
return err
|
||||||
if err := tx.Model(&entity.RecordingStock{}).
|
|
||||||
Where("id = ?", stock.Id).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"project_flock_kandang_id": ownerProjectFlockKandangID,
|
|
||||||
}).Error; 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
|
desired := item.Qty
|
||||||
stock.UsageQty = &desired
|
stock.UsageQty = &desired
|
||||||
zero := 0.0
|
zero := 0.0
|
||||||
stock.PendingQty = &zero
|
stock.PendingQty = &zero
|
||||||
stocksToApply = append(stocksToApply, stock)
|
stocksToApply = append(stocksToApply, stock)
|
||||||
|
|||||||
@@ -39,8 +39,14 @@ type Query struct {
|
|||||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||||
Limit int `query:"limit" validate:"omitempty,number,min=1"`
|
Limit int `query:"limit" validate:"omitempty,number,min=1"`
|
||||||
Offset int `query:"-" validate:"omitempty,number,min=0"`
|
Offset int `query:"-" validate:"omitempty,number,min=0"`
|
||||||
|
ProjectFlockId uint `query:"project_flock_id" validate:"omitempty,number,min=1"`
|
||||||
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
|
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
|
||||||
Search string `query:"search" validate:"omitempty,max=50"`
|
AreaId uint `query:"area_id" validate:"omitempty,number,min=1"`
|
||||||
|
LocationId uint `query:"location_id" validate:"omitempty,number,min=1"`
|
||||||
|
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
|
||||||
|
ProjectFlockCategory string `query:"project_flock_category" validate:"omitempty,oneof=GROWING LAYING"`
|
||||||
|
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=50"`
|
||||||
|
Search string `query:"search" validate:"omitempty,max=100"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Approve struct {
|
type Approve struct {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
|
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
|
||||||
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
|
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
|
||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
|
||||||
@@ -24,6 +25,8 @@ type PurchaseController struct {
|
|||||||
service service.PurchaseService
|
service service.PurchaseService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const purchaseExcelExportFetchLimit = 100
|
||||||
|
|
||||||
func NewPurchaseController(s service.PurchaseService) *PurchaseController {
|
func NewPurchaseController(s service.PurchaseService) *PurchaseController {
|
||||||
return &PurchaseController{service: s}
|
return &PurchaseController{service: s}
|
||||||
}
|
}
|
||||||
@@ -48,20 +51,14 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusOK).Send(content)
|
return c.Status(fiber.StatusOK).Send(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := &validation.Query{
|
query := buildPurchaseQuery(c)
|
||||||
Page: c.QueryInt("page", 1),
|
|
||||||
Limit: c.QueryInt("limit", 10),
|
if isAllPurchaseExcelExportRequest(c) {
|
||||||
Search: strings.TrimSpace(c.Query("search")),
|
results, err := ctrl.getAllPurchasesForExcel(c, query)
|
||||||
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
|
if err != nil {
|
||||||
PoDate: strings.TrimSpace(c.Query("po_date")),
|
return err
|
||||||
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")),
|
}
|
||||||
PoDateTo: strings.TrimSpace(c.Query("po_date_to")),
|
return exportPurchaseListExcel(c, results)
|
||||||
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
|
|
||||||
CreatedTo: strings.TrimSpace(c.Query("created_to")),
|
|
||||||
SupplierID: uint(c.QueryInt("supplier_id", 0)),
|
|
||||||
AreaID: uint(c.QueryInt("area_id", 0)),
|
|
||||||
LocationID: uint(c.QueryInt("location_id", 0)),
|
|
||||||
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.Page < 1 || query.Limit < 1 {
|
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 {
|
func (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error {
|
||||||
param := c.Params("id")
|
param := c.Params("id")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
|
||||||
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type purchaseServiceStub struct {
|
||||||
|
getAllCalls []validation.Query
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ service.PurchaseService = (*purchaseServiceStub)(nil)
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) GetAll(_ *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) {
|
||||||
|
callCopy := *params
|
||||||
|
s.getAllCalls = append(s.getAllCalls, callCopy)
|
||||||
|
|
||||||
|
switch params.Page {
|
||||||
|
case 1:
|
||||||
|
return []entity.Purchase{
|
||||||
|
buildPurchaseForControllerTest(1, "PR-00001"),
|
||||||
|
buildPurchaseForControllerTest(2, "PR-00002"),
|
||||||
|
}, 3, nil
|
||||||
|
case 2:
|
||||||
|
return []entity.Purchase{
|
||||||
|
buildPurchaseForControllerTest(3, "PR-00003"),
|
||||||
|
}, 3, nil
|
||||||
|
default:
|
||||||
|
return []entity.Purchase{}, 3, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*entity.Purchase, error) {
|
||||||
|
return &entity.Purchase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.CreatePurchaseRequest) (*entity.Purchase, error) {
|
||||||
|
return &entity.Purchase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) ApproveStaffPurchase(_ *fiber.Ctx, _ uint, _ *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) {
|
||||||
|
return &entity.Purchase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) ApproveManagerPurchase(_ *fiber.Ctx, _ uint, _ *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) {
|
||||||
|
return &entity.Purchase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) ReceiveProducts(_ *fiber.Ctx, _ uint, _ *validation.ReceivePurchaseRequest) (*entity.Purchase, error) {
|
||||||
|
return &entity.Purchase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) DeleteItems(_ *fiber.Ctx, _ uint, _ *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) {
|
||||||
|
return &entity.Purchase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) DeletePurchase(_ *fiber.Ctx, _ uint) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *purchaseServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
stub := &purchaseServiceStub{}
|
||||||
|
ctrl := NewPurchaseController(stub)
|
||||||
|
app.Get("/purchases", ctrl.GetAll)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/purchases?export=excel&type=all&page=9&limit=1&search=po&supplier_id=7&area_id=4&location_id=2&product_category_id=1,2&approval_status=pending&po_date_from=2026-01-01&po_date_to=2026-01-31&created_from=2026-02-01&created_to=2026-02-20",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected app.Test error: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != fiber.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
|
||||||
|
t.Fatalf("unexpected content-type: %s", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
disposition := resp.Header.Get("Content-Disposition")
|
||||||
|
if !strings.Contains(disposition, "purchases_all_") {
|
||||||
|
t.Fatalf("unexpected content-disposition: %s", disposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stub.getAllCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 GetAll calls, got %d", len(stub.getAllCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
firstCall := stub.getAllCalls[0]
|
||||||
|
secondCall := stub.getAllCalls[1]
|
||||||
|
if firstCall.Page != 1 || secondCall.Page != 2 {
|
||||||
|
t.Fatalf("expected internal paging page 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
|
||||||
|
}
|
||||||
|
if firstCall.Limit != purchaseExcelExportFetchLimit || secondCall.Limit != purchaseExcelExportFetchLimit {
|
||||||
|
t.Fatalf("expected internal limit %d, got %d and %d", purchaseExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstCall.Search != "po" ||
|
||||||
|
firstCall.SupplierID != 7 ||
|
||||||
|
firstCall.AreaID != 4 ||
|
||||||
|
firstCall.LocationID != 2 ||
|
||||||
|
firstCall.ProductCategoryID != "1,2" ||
|
||||||
|
firstCall.ApprovalStatus != "pending" ||
|
||||||
|
firstCall.PoDateFrom != "2026-01-01" ||
|
||||||
|
firstCall.PoDateTo != "2026-01-31" ||
|
||||||
|
firstCall.CreatedFrom != "2026-02-01" ||
|
||||||
|
firstCall.CreatedTo != "2026-02-20" {
|
||||||
|
t.Fatalf("unexpected forwarded filters: %+v", firstCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read excel payload: %v", err)
|
||||||
|
}
|
||||||
|
file, err := excelize.OpenReader(bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse excel payload: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if got, _ := file.GetCellValue(purchaseExportSheetName, "A1"); got != "PR Number" {
|
||||||
|
t.Fatalf("expected A1 header to be PR Number, got %q", got)
|
||||||
|
}
|
||||||
|
if got, _ := file.GetCellValue(purchaseExportSheetName, "A2"); got != "PR-00001" {
|
||||||
|
t.Fatalf("expected first row PR-00001, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
stub := &purchaseServiceStub{}
|
||||||
|
ctrl := NewPurchaseController(stub)
|
||||||
|
app.Get("/purchases", ctrl.GetAll)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/purchases?page=1&limit=0", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected app.Test error: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != fiber.StatusBadRequest {
|
||||||
|
t.Fatalf("expected status 400, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPurchaseForControllerTest(id uint, prNumber string) entity.Purchase {
|
||||||
|
poNumber := "PO-" + strings.TrimPrefix(prNumber, "PR-")
|
||||||
|
poDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
|
||||||
|
notes := "catatan"
|
||||||
|
approvalAction := entity.ApprovalActionApproved
|
||||||
|
|
||||||
|
return entity.Purchase{
|
||||||
|
Id: id,
|
||||||
|
PrNumber: prNumber,
|
||||||
|
PoNumber: &poNumber,
|
||||||
|
PoDate: &poDate,
|
||||||
|
Notes: ¬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 {
|
if len(productCategoryIDs) > 0 {
|
||||||
db = db.Where(
|
db = db.Where(
|
||||||
`EXISTS (
|
`EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM purchase_items pi
|
FROM purchase_items pi
|
||||||
JOIN products p ON p.id = pi.product_id
|
JOIN products p ON p.id = pi.product_id
|
||||||
WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ?
|
WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ?
|
||||||
@@ -256,183 +256,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(approvalStatuses) > 0 {
|
db = applyPurchaseProjectFlockFilter(db, params.ProjectFlockID, params.ProjectFlockKandangID)
|
||||||
approvalConditions := make([]string, 0, len(approvalStatuses))
|
db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
|
||||||
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
|
db = applyPurchaseSearchFilter(db, search)
|
||||||
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
|
|
||||||
for _, status := range approvalStatuses {
|
|
||||||
if status == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
like := "%" + status + "%"
|
|
||||||
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
|
|
||||||
approvalArgs = append(approvalArgs, like, like, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(approvalConditions) > 0 {
|
|
||||||
approvalClause := strings.Join(approvalConditions, " OR ")
|
|
||||||
approvalQuery := fmt.Sprintf(
|
|
||||||
`EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM approvals a
|
|
||||||
WHERE a.approvable_type = ?
|
|
||||||
AND a.approvable_id = purchases.id
|
|
||||||
AND a.id = (
|
|
||||||
SELECT a2.id
|
|
||||||
FROM approvals a2
|
|
||||||
WHERE a2.approvable_type = ?
|
|
||||||
AND a2.approvable_id = purchases.id
|
|
||||||
ORDER BY a2.action_at DESC, a2.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
AND (%s)
|
|
||||||
)`,
|
|
||||||
approvalClause,
|
|
||||||
)
|
|
||||||
db = db.Where(approvalQuery, approvalArgs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if search != "" {
|
|
||||||
like := "%" + search + "%"
|
|
||||||
db = db.Where(
|
|
||||||
`(
|
|
||||||
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
|
|
||||||
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM suppliers s
|
|
||||||
WHERE s.id = purchases.supplier_id
|
|
||||||
AND LOWER(COALESCE(s.name, '')) LIKE ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM users u
|
|
||||||
WHERE u.id = purchases.created_by
|
|
||||||
AND LOWER(COALESCE(u.name, '')) LIKE ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_items pi
|
|
||||||
JOIN products p ON p.id = pi.product_id
|
|
||||||
WHERE pi.purchase_id = purchases.id
|
|
||||||
AND LOWER(COALESCE(p.name, '')) LIKE ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_items pi
|
|
||||||
JOIN warehouses w ON w.id = pi.warehouse_id
|
|
||||||
JOIN locations l ON l.id = w.location_id
|
|
||||||
WHERE pi.purchase_id = purchases.id
|
|
||||||
AND LOWER(COALESCE(l.name, '')) LIKE ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_items pi
|
|
||||||
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
|
|
||||||
JOIN expenses e ON e.id = en.expense_id
|
|
||||||
WHERE pi.purchase_id = purchases.id
|
|
||||||
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
|
|
||||||
)
|
|
||||||
)`,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(approvalStatuses) > 0 {
|
|
||||||
approvalConditions := make([]string, 0, len(approvalStatuses))
|
|
||||||
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
|
|
||||||
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
|
|
||||||
for _, status := range approvalStatuses {
|
|
||||||
if status == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
like := "%" + status + "%"
|
|
||||||
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
|
|
||||||
approvalArgs = append(approvalArgs, like, like, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(approvalConditions) > 0 {
|
|
||||||
approvalClause := strings.Join(approvalConditions, " OR ")
|
|
||||||
approvalQuery := fmt.Sprintf(
|
|
||||||
`EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM approvals a
|
|
||||||
WHERE a.approvable_type = ?
|
|
||||||
AND a.approvable_id = purchases.id
|
|
||||||
AND a.id = (
|
|
||||||
SELECT a2.id
|
|
||||||
FROM approvals a2
|
|
||||||
WHERE a2.approvable_type = ?
|
|
||||||
AND a2.approvable_id = purchases.id
|
|
||||||
ORDER BY a2.action_at DESC, a2.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
AND (%s)
|
|
||||||
)`,
|
|
||||||
approvalClause,
|
|
||||||
)
|
|
||||||
db = db.Where(approvalQuery, approvalArgs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if search != "" {
|
|
||||||
like := "%" + search + "%"
|
|
||||||
db = db.Where(
|
|
||||||
`(
|
|
||||||
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
|
|
||||||
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM suppliers s
|
|
||||||
WHERE s.id = purchases.supplier_id
|
|
||||||
AND LOWER(COALESCE(s.name, '')) LIKE ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM users u
|
|
||||||
WHERE u.id = purchases.created_by
|
|
||||||
AND LOWER(COALESCE(u.name, '')) LIKE ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_items pi
|
|
||||||
JOIN products p ON p.id = pi.product_id
|
|
||||||
WHERE pi.purchase_id = purchases.id
|
|
||||||
AND LOWER(COALESCE(p.name, '')) LIKE ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_items pi
|
|
||||||
JOIN warehouses w ON w.id = pi.warehouse_id
|
|
||||||
JOIN locations l ON l.id = w.location_id
|
|
||||||
WHERE pi.purchase_id = purchases.id
|
|
||||||
AND LOWER(COALESCE(l.name, '')) LIKE ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_items pi
|
|
||||||
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
|
|
||||||
JOIN expenses e ON e.id = en.expense_id
|
|
||||||
WHERE pi.purchase_id = purchases.id
|
|
||||||
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
|
|
||||||
)
|
|
||||||
)`,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
like,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.Order("created_at DESC").Order("purchases.id DESC")
|
return db.Order("created_at DESC").Order("purchases.id DESC")
|
||||||
})
|
})
|
||||||
@@ -2361,6 +2187,155 @@ func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, er
|
|||||||
return fromPtr, toPtr, nil
|
return fromPtr, toPtr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyPurchaseProjectFlockFilter(db *gorm.DB, projectFlockID, projectFlockKandangID uint) *gorm.DB {
|
||||||
|
if projectFlockID > 0 {
|
||||||
|
db = db.Where(
|
||||||
|
`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM purchase_items pi
|
||||||
|
LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id
|
||||||
|
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
|
||||||
|
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
|
||||||
|
WHERE pi.purchase_id = purchases.id
|
||||||
|
AND COALESCE(pfk_explicit.project_flock_id, pfk_active.project_flock_id) = ?
|
||||||
|
)`,
|
||||||
|
projectFlockID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectFlockKandangID > 0 {
|
||||||
|
db = db.Where(
|
||||||
|
`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM purchase_items pi
|
||||||
|
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
|
||||||
|
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
|
||||||
|
WHERE pi.purchase_id = purchases.id
|
||||||
|
AND COALESCE(pi.project_flock_kandang_id, pfk_active.id) = ?
|
||||||
|
)`,
|
||||||
|
projectFlockKandangID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPurchaseApprovalStatusFilter(db *gorm.DB, approvalStatuses []string) *gorm.DB {
|
||||||
|
if len(approvalStatuses) == 0 {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalConditions := make([]string, 0, len(approvalStatuses))
|
||||||
|
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
|
||||||
|
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
|
||||||
|
for _, status := range approvalStatuses {
|
||||||
|
if status == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
like := "%" + status + "%"
|
||||||
|
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
|
||||||
|
approvalArgs = append(approvalArgs, like, like, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(approvalConditions) == 0 {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalClause := strings.Join(approvalConditions, " OR ")
|
||||||
|
approvalQuery := fmt.Sprintf(
|
||||||
|
`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM approvals a
|
||||||
|
WHERE a.approvable_type = ?
|
||||||
|
AND a.approvable_id = purchases.id
|
||||||
|
AND a.id = (
|
||||||
|
SELECT a2.id
|
||||||
|
FROM approvals a2
|
||||||
|
WHERE a2.approvable_type = ?
|
||||||
|
AND a2.approvable_id = purchases.id
|
||||||
|
ORDER BY a2.action_at DESC, a2.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
AND (%s)
|
||||||
|
)`,
|
||||||
|
approvalClause,
|
||||||
|
)
|
||||||
|
|
||||||
|
return db.Where(approvalQuery, approvalArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPurchaseSearchFilter(db *gorm.DB, search string) *gorm.DB {
|
||||||
|
if search == "" {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
like := "%" + search + "%"
|
||||||
|
return db.Where(
|
||||||
|
`(
|
||||||
|
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
|
||||||
|
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM suppliers s
|
||||||
|
WHERE s.id = purchases.supplier_id
|
||||||
|
AND LOWER(COALESCE(s.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users u
|
||||||
|
WHERE u.id = purchases.created_by
|
||||||
|
AND LOWER(COALESCE(u.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM purchase_items pi
|
||||||
|
JOIN products p ON p.id = pi.product_id
|
||||||
|
WHERE pi.purchase_id = purchases.id
|
||||||
|
AND LOWER(COALESCE(p.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM purchase_items pi
|
||||||
|
JOIN warehouses w ON w.id = pi.warehouse_id
|
||||||
|
JOIN locations l ON l.id = w.location_id
|
||||||
|
WHERE pi.purchase_id = purchases.id
|
||||||
|
AND LOWER(COALESCE(l.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM purchase_items pi
|
||||||
|
LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id
|
||||||
|
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
|
||||||
|
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
|
||||||
|
LEFT JOIN project_flocks pf ON pf.id = COALESCE(pfk_explicit.project_flock_id, pfk_active.project_flock_id)
|
||||||
|
LEFT JOIN kandangs k ON k.id = COALESCE(pfk_explicit.kandang_id, pfk_active.kandang_id, w.kandang_id)
|
||||||
|
WHERE pi.purchase_id = purchases.id
|
||||||
|
AND (
|
||||||
|
LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
|
||||||
|
LOWER(COALESCE(k.name, '')) LIKE ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM purchase_items pi
|
||||||
|
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
|
||||||
|
JOIN expenses e ON e.id = en.expense_id
|
||||||
|
WHERE pi.purchase_id = purchases.id
|
||||||
|
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
like,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeApprovalStatusFilter(raw string) string {
|
func normalizeApprovalStatusFilter(raw string) string {
|
||||||
value := strings.ToLower(strings.TrimSpace(raw))
|
value := strings.ToLower(strings.TrimSpace(raw))
|
||||||
switch value {
|
switch value {
|
||||||
|
|||||||
@@ -61,17 +61,19 @@ type DeletePurchaseItemsRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Query struct {
|
type Query struct {
|
||||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
||||||
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
|
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
|
||||||
AreaID uint `query:"area_id" validate:"omitempty,gt=0"`
|
AreaID uint `query:"area_id" validate:"omitempty,gt=0"`
|
||||||
LocationID uint `query:"location_id" validate:"omitempty,gt=0"`
|
LocationID uint `query:"location_id" validate:"omitempty,gt=0"`
|
||||||
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
|
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
|
||||||
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
|
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
|
||||||
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
|
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
|
||||||
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
|
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
|
||||||
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"`
|
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
|
||||||
Search string `query:"search" validate:"omitempty,max=100"`
|
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
|
||||||
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"`
|
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"`
|
||||||
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"`
|
Search string `query:"search" validate:"omitempty,max=100"`
|
||||||
|
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"`
|
||||||
|
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type RepportController struct {
|
|||||||
RepportService service.RepportService
|
RepportService service.RepportService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expenseReportExcelExportFetchLimit = 100
|
||||||
|
|
||||||
func NewRepportController(repportService service.RepportService) *RepportController {
|
func NewRepportController(repportService service.RepportService) *RepportController {
|
||||||
return &RepportController{
|
return &RepportController{
|
||||||
RepportService: repportService,
|
RepportService: repportService,
|
||||||
@@ -66,6 +68,14 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
|
|||||||
query.AllowedAreaIDs = toInt64Slice(areaScope.IDs)
|
query.AllowedAreaIDs = toInt64Slice(areaScope.IDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isAllExpenseExcelExportRequest(ctx) {
|
||||||
|
allResults, err := c.getAllExpenseRowsForExcel(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exportExpenseReportListExcel(ctx, allResults)
|
||||||
|
}
|
||||||
|
|
||||||
if query.Page < 1 || query.Limit < 1 {
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
|
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
|
||||||
}
|
}
|
||||||
@@ -90,6 +100,33 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *RepportController) getAllExpenseRowsForExcel(ctx *fiber.Ctx, baseQuery *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, error) {
|
||||||
|
query := *baseQuery
|
||||||
|
query.Page = 1
|
||||||
|
query.Limit = expenseReportExcelExportFetchLimit
|
||||||
|
|
||||||
|
results := make([]dto.RepportExpenseListDTO, 0)
|
||||||
|
for {
|
||||||
|
pageResults, total, err := c.RepportService.GetExpense(ctx, &query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pageResults) == 0 || total == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, pageResults...)
|
||||||
|
if int64(len(results)) >= total {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Page++
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error {
|
func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error {
|
||||||
rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx)
|
rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
|
||||||
|
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
|
||||||
|
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
|
||||||
|
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
|
||||||
|
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
|
||||||
|
service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services"
|
||||||
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
||||||
|
)
|
||||||
|
|
||||||
|
type repportServiceStub struct {
|
||||||
|
service.RepportService
|
||||||
|
getExpenseCalls []validation.ExpenseQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repportServiceStub) GetExpense(_ *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) {
|
||||||
|
callCopy := *params
|
||||||
|
callCopy.AllowedAreaIDs = append([]int64(nil), params.AllowedAreaIDs...)
|
||||||
|
callCopy.AllowedLocationIDs = append([]int64(nil), params.AllowedLocationIDs...)
|
||||||
|
s.getExpenseCalls = append(s.getExpenseCalls, callCopy)
|
||||||
|
|
||||||
|
switch params.Page {
|
||||||
|
case 1:
|
||||||
|
return []dto.RepportExpenseListDTO{
|
||||||
|
buildExpenseListForControllerTest("REF-00001", "TRANSPORT 2"),
|
||||||
|
buildExpenseListForControllerTest("REF-00002", "TRANSPORT"),
|
||||||
|
}, 3, nil
|
||||||
|
case 2:
|
||||||
|
return []dto.RepportExpenseListDTO{
|
||||||
|
buildExpenseListForControllerTest("REF-00003", "TRANSPORT"),
|
||||||
|
}, 3, nil
|
||||||
|
default:
|
||||||
|
return []dto.RepportExpenseListDTO{}, 3, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repportServiceStub) DB() *gorm.DB {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepportControllerGetExpenseExportAllIgnoresRequestLimit(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
stub := &repportServiceStub{}
|
||||||
|
ctrl := NewRepportController(stub)
|
||||||
|
app.Get("/reports/expense", ctrl.GetExpense)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/reports/expense?export=excel&type=all&page=9&limit=1&search=operasional&category=BOP&supplier_id=7&kandang_id=4&project_flock_kandang_id=2&nonstock_id=5&area_id=3&location_id=9&realization_date=2026-04-22",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected app.Test error: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != fiber.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if contentType != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" {
|
||||||
|
t.Fatalf("unexpected content-type: %s", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
disposition := resp.Header.Get("Content-Disposition")
|
||||||
|
if !bytes.Contains([]byte(disposition), []byte("reports_expense_all_")) {
|
||||||
|
t.Fatalf("unexpected content-disposition: %s", disposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stub.getExpenseCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 GetExpense calls, got %d", len(stub.getExpenseCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
firstCall := stub.getExpenseCalls[0]
|
||||||
|
secondCall := stub.getExpenseCalls[1]
|
||||||
|
if firstCall.Page != 1 || secondCall.Page != 2 {
|
||||||
|
t.Fatalf("expected internal pages 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
|
||||||
|
}
|
||||||
|
if firstCall.Limit != expenseReportExcelExportFetchLimit || secondCall.Limit != expenseReportExcelExportFetchLimit {
|
||||||
|
t.Fatalf("expected internal limit %d, got %d and %d", expenseReportExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstCall.Search != "operasional" ||
|
||||||
|
firstCall.Category != "BOP" ||
|
||||||
|
firstCall.SupplierId != 7 ||
|
||||||
|
firstCall.KandangId != 4 ||
|
||||||
|
firstCall.ProjectFlockKandangId != 2 ||
|
||||||
|
firstCall.NonstockId != 5 ||
|
||||||
|
firstCall.AreaId != 3 ||
|
||||||
|
firstCall.LocationId != 9 ||
|
||||||
|
firstCall.RealizationDate != "2026-04-22" {
|
||||||
|
t.Fatalf("unexpected forwarded filters: %+v", firstCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read excel payload: %v", err)
|
||||||
|
}
|
||||||
|
file, err := excelize.OpenReader(bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse excel payload: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
sheetList := file.GetSheetList()
|
||||||
|
expectedSheets := []string{"EKSPEDISI ADE", "EKSPEDISI LTI"}
|
||||||
|
if !reflect.DeepEqual(sheetList, expectedSheets) {
|
||||||
|
t.Fatalf("unexpected sheet list: got %v, expected %v", sheetList, expectedSheets)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, _ := file.GetCellValue("EKSPEDISI ADE", "A1"); got != "No" {
|
||||||
|
t.Fatalf("expected EKSPEDISI ADE A1 to be No, got %q", got)
|
||||||
|
}
|
||||||
|
if got, _ := file.GetCellValue("EKSPEDISI ADE", "G2"); got != "TRANSPORT 2" {
|
||||||
|
t.Fatalf("expected EKSPEDISI ADE G2 to be TRANSPORT 2, got %q", got)
|
||||||
|
}
|
||||||
|
if got, _ := file.GetCellValue("EKSPEDISI LTI", "G2"); got != "TRANSPORT" {
|
||||||
|
t.Fatalf("expected EKSPEDISI LTI G2 to be TRANSPORT, got %q", got)
|
||||||
|
}
|
||||||
|
if got, _ := file.GetCellValue("EKSPEDISI LTI", "A4"); got != "Total" {
|
||||||
|
t.Fatalf("expected EKSPEDISI LTI A4 to be Total, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepportControllerGetExpenseKeepsPaginationValidationForNonExportAll(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
stub := &repportServiceStub{}
|
||||||
|
ctrl := NewRepportController(stub)
|
||||||
|
app.Get("/reports/expense", ctrl.GetExpense)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/reports/expense?page=1&limit=0", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected app.Test error: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != fiber.StatusBadRequest {
|
||||||
|
t.Fatalf("expected status 400, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExpenseListForControllerTest(reference, product string) dto.RepportExpenseListDTO {
|
||||||
|
realizationDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
|
||||||
|
return dto.RepportExpenseListDTO{
|
||||||
|
RepportExpenseBaseDTO: dto.RepportExpenseBaseDTO{
|
||||||
|
ReferenceNumber: reference,
|
||||||
|
PoNumber: "PO-001",
|
||||||
|
Category: "BOP",
|
||||||
|
Notes: "catatan expense",
|
||||||
|
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
|
||||||
|
RealizationDate: &realizationDate,
|
||||||
|
Supplier: &supplierDTO.SupplierRelationDTO{
|
||||||
|
Name: "Supplier A",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Kandang: &kandangDTO.KandangRelationDTO{
|
||||||
|
Name: "Kandang A",
|
||||||
|
Location: &locationDTO.LocationRelationDTO{
|
||||||
|
Name: "Darawati",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Pengajuan: dto.RepportExpensePengajuanDTO{
|
||||||
|
Qty: 1,
|
||||||
|
Price: 50000,
|
||||||
|
Notes: "catatan pengajuan",
|
||||||
|
Nonstock: &nonstockDTO.NonstockRelationDTO{
|
||||||
|
Name: product,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Realisasi: dto.RepportExpenseRealisasiDTO{
|
||||||
|
Qty: 1,
|
||||||
|
Price: 50000,
|
||||||
|
Notes: "catatan realisasi",
|
||||||
|
Nonstock: &nonstockDTO.NonstockRelationDTO{
|
||||||
|
Name: product,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TotalPengajuan: 50000,
|
||||||
|
TotalRealisasi: 50000,
|
||||||
|
LatestApproval: &approvalDTO.ApprovalRelationDTO{
|
||||||
|
StepName: "Realisasi",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const expenseReportExportSheetName = "Expense Reports"
|
||||||
|
|
||||||
|
var expenseReportTemplateSheetOrder = []string{
|
||||||
|
"UANG MAKAN",
|
||||||
|
"UPAH",
|
||||||
|
"EKSPEDISI ADE",
|
||||||
|
"GALON",
|
||||||
|
"GAS",
|
||||||
|
"KEBUTUHAN",
|
||||||
|
"EKSPEDISI LTI",
|
||||||
|
"KONTRIBUSI",
|
||||||
|
"PRODUKSI",
|
||||||
|
"KOMPENSASI",
|
||||||
|
"LAIN-LAIN",
|
||||||
|
"PERBAIKAN",
|
||||||
|
"LISTRIK",
|
||||||
|
"PAJAK",
|
||||||
|
"SOLAR",
|
||||||
|
}
|
||||||
|
|
||||||
|
var expenseReportSheetAliasMap = map[string]string{
|
||||||
|
"TRANSPORT 2": "EKSPEDISI ADE",
|
||||||
|
"TRANSPORT": "EKSPEDISI LTI",
|
||||||
|
"GAS BROODING": "GAS",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllExpenseExcelExportRequest(c *fiber.Ctx) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportExpenseReportListExcel(c *fiber.Ctx, items []dto.RepportExpenseListDTO) error {
|
||||||
|
content, err := buildExpenseReportExportWorkbook(items)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("reports_expense_all_%s.xlsx", time.Now().Format("20060102_150405"))
|
||||||
|
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
return c.Status(fiber.StatusOK).Send(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExpenseReportExportWorkbook(items []dto.RepportExpenseListDTO) ([]byte, error) {
|
||||||
|
file := excelize.NewFile()
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
|
||||||
|
groups := groupExpenseReportRowsBySheet(items)
|
||||||
|
orderedSheetNames := orderExpenseReportSheetNames(groups)
|
||||||
|
|
||||||
|
if len(orderedSheetNames) == 0 {
|
||||||
|
if defaultSheet != expenseReportExportSheetName {
|
||||||
|
if err := file.SetSheetName(defaultSheet, expenseReportExportSheetName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeExpenseReportSheet(file, expenseReportExportSheetName, []dto.RepportExpenseListDTO{}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for idx, sheetName := range orderedSheetNames {
|
||||||
|
if idx == 0 {
|
||||||
|
if defaultSheet != sheetName {
|
||||||
|
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := file.NewSheet(sheetName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeExpenseReportSheet(file, sheetName, groups[sheetName]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer, err := file.WriteToBuffer()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupExpenseReportRowsBySheet(items []dto.RepportExpenseListDTO) map[string][]dto.RepportExpenseListDTO {
|
||||||
|
groups := make(map[string][]dto.RepportExpenseListDTO)
|
||||||
|
for _, item := range items {
|
||||||
|
product := resolveExpenseReportProduct(item)
|
||||||
|
sheetName := resolveExpenseReportSheetName(product)
|
||||||
|
groups[sheetName] = append(groups[sheetName], item)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderExpenseReportSheetNames(groups map[string][]dto.RepportExpenseListDTO) []string {
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
templateSet := make(map[string]struct{}, len(expenseReportTemplateSheetOrder))
|
||||||
|
ordered := make([]string, 0, len(groups))
|
||||||
|
|
||||||
|
for _, sheet := range expenseReportTemplateSheetOrder {
|
||||||
|
templateSet[sheet] = struct{}{}
|
||||||
|
if _, ok := groups[sheet]; ok {
|
||||||
|
ordered = append(ordered, sheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extras := make([]string, 0)
|
||||||
|
for sheet := range groups {
|
||||||
|
if _, ok := templateSet[sheet]; !ok {
|
||||||
|
extras = append(extras, sheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(extras, func(i, j int) bool {
|
||||||
|
return strings.ToUpper(extras[i]) < strings.ToUpper(extras[j])
|
||||||
|
})
|
||||||
|
|
||||||
|
ordered = append(ordered, extras...)
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExpenseReportSheetName(product string) string {
|
||||||
|
normalizedProduct := strings.ToUpper(strings.TrimSpace(product))
|
||||||
|
if alias, exists := expenseReportSheetAliasMap[normalizedProduct]; exists {
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
if normalizedProduct == "" {
|
||||||
|
normalizedProduct = "-"
|
||||||
|
}
|
||||||
|
return sanitizeExpenseReportSheetName(normalizedProduct)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeExpenseReportSheetName(name string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
":", " ",
|
||||||
|
"\\", " ",
|
||||||
|
"/", " ",
|
||||||
|
"?", " ",
|
||||||
|
"*", " ",
|
||||||
|
"[", " ",
|
||||||
|
"]", " ",
|
||||||
|
)
|
||||||
|
|
||||||
|
sanitized := strings.TrimSpace(replacer.Replace(name))
|
||||||
|
if sanitized == "" {
|
||||||
|
sanitized = "Sheet"
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(sanitized)
|
||||||
|
if len(runes) > 31 {
|
||||||
|
sanitized = string(runes[:31])
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExpenseReportSheet(file *excelize.File, sheet string, items []dto.RepportExpenseListDTO) error {
|
||||||
|
if err := setExpenseReportTemplateColumns(file, sheet); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := setExpenseReportTemplateHeaders(file, sheet); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return setExpenseReportTemplateRows(file, sheet, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setExpenseReportTemplateColumns(file *excelize.File, sheet string) error {
|
||||||
|
columnWidths := map[string]float64{
|
||||||
|
"A": 5.83203125,
|
||||||
|
"B": 20.83203125,
|
||||||
|
"C": 20.83203125,
|
||||||
|
"D": 15.83203125,
|
||||||
|
"E": 15.83203125,
|
||||||
|
"F": 15.83203125,
|
||||||
|
"G": 30.83203125,
|
||||||
|
"H": 20.83203125,
|
||||||
|
"I": 15.83203125,
|
||||||
|
"J": 15.83203125,
|
||||||
|
"K": 15.83203125,
|
||||||
|
"L": 20.83203125,
|
||||||
|
"M": 15.83203125,
|
||||||
|
"N": 20.83203125,
|
||||||
|
}
|
||||||
|
|
||||||
|
for col, width := range columnWidths {
|
||||||
|
if err := file.SetColWidth(sheet, col, col, width); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setExpenseReportTemplateHeaders(file *excelize.File, sheet string) error {
|
||||||
|
headers := []string{
|
||||||
|
"No",
|
||||||
|
"No. PO",
|
||||||
|
"No. Referensi",
|
||||||
|
"Tanggal Realisasi",
|
||||||
|
"Tanggal Transaksi",
|
||||||
|
"Kategori",
|
||||||
|
"Produk",
|
||||||
|
"Lokasi",
|
||||||
|
"Kandang",
|
||||||
|
"Qty Pengajuan",
|
||||||
|
"Harga Pengajuan",
|
||||||
|
"Total Pengajuan",
|
||||||
|
"Qty Realisasi",
|
||||||
|
"Harga Realisasi",
|
||||||
|
"Total Realisasi",
|
||||||
|
"Status Pencairan",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, header := range headers {
|
||||||
|
columnName, err := excelize.ColumnNumberToName(i + 1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, columnName+"1", header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setExpenseReportTemplateRows(file *excelize.File, sheet string, items []dto.RepportExpenseListDTO) error {
|
||||||
|
totalQtyPengajuan := 0.0
|
||||||
|
totalPengajuan := 0.0
|
||||||
|
totalQtyRealisasi := 0.0
|
||||||
|
totalRealisasi := 0.0
|
||||||
|
|
||||||
|
for idx, item := range items {
|
||||||
|
row := idx + 2
|
||||||
|
rowString := strconv.Itoa(row)
|
||||||
|
|
||||||
|
produk := resolveExpenseReportProduct(item)
|
||||||
|
status := formatExpenseReportStatus(item)
|
||||||
|
lokasi := resolveExpenseReportLocation(item)
|
||||||
|
kandang := resolveExpenseReportKandang(item)
|
||||||
|
|
||||||
|
if err := file.SetCellValue(sheet, "A"+rowString, idx+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "B"+rowString, safeExpenseReportExportText(item.PoNumber)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "C"+rowString, safeExpenseReportExportText(item.ReferenceNumber)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "D"+rowString, formatExpenseReportOptionalDate(item.RealizationDate)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "E"+rowString, formatExpenseReportDate(item.TransactionDate)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "F"+rowString, safeExpenseReportExportText(item.Category)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "G"+rowString, produk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "H"+rowString, lokasi); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "I"+rowString, kandang); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "J"+rowString, item.Pengajuan.Qty); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "K"+rowString, item.Pengajuan.Price); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "L"+rowString, item.TotalPengajuan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "M"+rowString, item.Realisasi.Qty); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "N"+rowString, item.Realisasi.Price); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "O"+rowString, item.TotalRealisasi); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "P"+rowString, status); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalQtyPengajuan += item.Pengajuan.Qty
|
||||||
|
totalPengajuan += item.TotalPengajuan
|
||||||
|
totalQtyRealisasi += item.Realisasi.Qty
|
||||||
|
totalRealisasi += item.TotalRealisasi
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRow := strconv.Itoa(len(items) + 2)
|
||||||
|
if err := file.SetCellValue(sheet, "A"+totalRow, "Total"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "J"+totalRow, totalQtyPengajuan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "K"+totalRow, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "L"+totalRow, totalPengajuan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "M"+totalRow, totalQtyRealisasi); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "N"+totalRow, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "O"+totalRow, totalRealisasi); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExpenseReportProduct(item dto.RepportExpenseListDTO) string {
|
||||||
|
if item.Realisasi.Nonstock != nil {
|
||||||
|
name := strings.TrimSpace(item.Realisasi.Nonstock.Name)
|
||||||
|
if name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.Pengajuan.Nonstock != nil {
|
||||||
|
name := strings.TrimSpace(item.Pengajuan.Nonstock.Name)
|
||||||
|
if name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExpenseReportLocation(item dto.RepportExpenseListDTO) string {
|
||||||
|
if item.Kandang != nil && item.Kandang.Location != nil {
|
||||||
|
name := strings.TrimSpace(item.Kandang.Location.Name)
|
||||||
|
if name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExpenseReportKandang(item dto.RepportExpenseListDTO) string {
|
||||||
|
if item.Kandang != nil {
|
||||||
|
name := strings.TrimSpace(item.Kandang.Name)
|
||||||
|
if name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatExpenseReportStatus(item dto.RepportExpenseListDTO) string {
|
||||||
|
if item.LatestApproval == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
if item.LatestApproval.Action != nil &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
|
||||||
|
return "Ditolak"
|
||||||
|
}
|
||||||
|
|
||||||
|
stepName := strings.TrimSpace(item.LatestApproval.StepName)
|
||||||
|
if stepName == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return stepName
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatExpenseReportDate(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
if err == nil {
|
||||||
|
value = value.In(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Format("02 Jan 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatExpenseReportOptionalDate(value *time.Time) string {
|
||||||
|
if value == nil || value.IsZero() {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return formatExpenseReportDate(*value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeExpenseReportExportText(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
|
||||||
|
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
|
||||||
|
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
|
||||||
|
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
|
||||||
|
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildExpenseReportExportWorkbookHeadersAndRows(t *testing.T) {
|
||||||
|
realizationDate := time.Date(2026, time.April, 23, 0, 0, 0, 0, time.UTC)
|
||||||
|
items := []dto.RepportExpenseListDTO{
|
||||||
|
buildExpenseExportTestItem(
|
||||||
|
"REF-0001",
|
||||||
|
"PO-0001",
|
||||||
|
"BOP",
|
||||||
|
"UPAH",
|
||||||
|
"Darawati",
|
||||||
|
"Darawati C1",
|
||||||
|
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
|
||||||
|
&realizationDate,
|
||||||
|
2,
|
||||||
|
10000,
|
||||||
|
20000,
|
||||||
|
2,
|
||||||
|
9000,
|
||||||
|
18000,
|
||||||
|
"Realisasi",
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
buildExpenseExportTestItem(
|
||||||
|
"REF-0002",
|
||||||
|
"PO-0002",
|
||||||
|
"BOP",
|
||||||
|
"TRANSPORT 2",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
|
||||||
|
&realizationDate,
|
||||||
|
1,
|
||||||
|
50000,
|
||||||
|
50000,
|
||||||
|
1,
|
||||||
|
50000,
|
||||||
|
50000,
|
||||||
|
"Pengajuan",
|
||||||
|
strPtr("REJECTED"),
|
||||||
|
),
|
||||||
|
buildExpenseExportTestItem(
|
||||||
|
"REF-0003",
|
||||||
|
"PO-0003",
|
||||||
|
"BOP",
|
||||||
|
"TRANSPORT",
|
||||||
|
"Jamali",
|
||||||
|
"Jamali 1",
|
||||||
|
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
|
||||||
|
&realizationDate,
|
||||||
|
3,
|
||||||
|
12000,
|
||||||
|
36000,
|
||||||
|
2,
|
||||||
|
11000,
|
||||||
|
22000,
|
||||||
|
"Selesai",
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
buildExpenseExportTestItem(
|
||||||
|
"REF-0004",
|
||||||
|
"PO-0004",
|
||||||
|
"BOP",
|
||||||
|
"TRANSPORT",
|
||||||
|
"Jamali",
|
||||||
|
"Jamali 2",
|
||||||
|
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
|
||||||
|
&realizationDate,
|
||||||
|
1,
|
||||||
|
8000,
|
||||||
|
8000,
|
||||||
|
1,
|
||||||
|
8000,
|
||||||
|
8000,
|
||||||
|
"Realisasi",
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
buildExpenseExportTestItem(
|
||||||
|
"REF-0005",
|
||||||
|
"PO-0005",
|
||||||
|
"BOP",
|
||||||
|
"ZZZ CUSTOM",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
|
||||||
|
nil,
|
||||||
|
1,
|
||||||
|
7000,
|
||||||
|
7000,
|
||||||
|
1,
|
||||||
|
7000,
|
||||||
|
7000,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := buildExpenseReportExportWorkbook(items)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildExpenseReportExportWorkbook returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := excelize.OpenReader(bytes.NewReader(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open workbook bytes: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
expectedSheetOrder := []string{"UPAH", "EKSPEDISI ADE", "EKSPEDISI LTI", "ZZZ CUSTOM"}
|
||||||
|
if got := file.GetSheetList(); !reflect.DeepEqual(got, expectedSheetOrder) {
|
||||||
|
t.Fatalf("unexpected sheet order: got %v expected %v", got, expectedSheetOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHeaders := map[string]string{
|
||||||
|
"A1": "No",
|
||||||
|
"B1": "No. PO",
|
||||||
|
"C1": "No. Referensi",
|
||||||
|
"D1": "Tanggal Realisasi",
|
||||||
|
"E1": "Tanggal Transaksi",
|
||||||
|
"F1": "Kategori",
|
||||||
|
"G1": "Produk",
|
||||||
|
"H1": "Lokasi",
|
||||||
|
"I1": "Kandang",
|
||||||
|
"J1": "Qty Pengajuan",
|
||||||
|
"K1": "Harga Pengajuan",
|
||||||
|
"L1": "Total Pengajuan",
|
||||||
|
"M1": "Qty Realisasi",
|
||||||
|
"N1": "Harga Realisasi",
|
||||||
|
"O1": "Total Realisasi",
|
||||||
|
"P1": "Status Pencairan",
|
||||||
|
}
|
||||||
|
for cell, expected := range expectedHeaders {
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", cell, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "A2", "1")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "B2", "PO-0001")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "C2", "REF-0001")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "D2", "23 Apr 2026")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "E2", "22 Apr 2026")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "F2", "BOP")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "G2", "UPAH")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "H2", "Darawati")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "I2", "Darawati C1")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "J2", "2")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "K2", "10000")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "L2", "20000")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "M2", "2")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "N2", "9000")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "O2", "18000")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "P2", "Realisasi")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "A3", "Total")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "J3", "2")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "K3", "0")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "L3", "20000")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "M3", "2")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "N3", "0")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "UPAH", "O3", "18000")
|
||||||
|
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "G2", "TRANSPORT 2")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "P2", "Ditolak")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "A3", "Total")
|
||||||
|
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A2", "1")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A3", "2")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "G2", "TRANSPORT")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "G3", "TRANSPORT")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A4", "Total")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "J4", "4")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "L4", "44000")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "M4", "3")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "O4", "30000")
|
||||||
|
|
||||||
|
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "H2", "-")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "I2", "-")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "D2", "-")
|
||||||
|
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "P2", "-")
|
||||||
|
|
||||||
|
for _, cell := range []string{"K2", "L2", "N2", "O2"} {
|
||||||
|
val, err := file.GetCellValue("UPAH", cell)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCellValue(UPAH,%s) failed: %v", cell, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(val, "Rp") {
|
||||||
|
t.Fatalf("expected numeric plain value in %s, got %q", cell, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertExpenseSheetCellEquals(t *testing.T, file *excelize.File, sheet, cell, expected string) {
|
||||||
|
t.Helper()
|
||||||
|
got, err := file.GetCellValue(sheet, cell)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCellValue(%s,%s) failed: %v", sheet, cell, err)
|
||||||
|
}
|
||||||
|
if got != expected {
|
||||||
|
t.Fatalf("expected %s!%s=%q, got %q", sheet, cell, expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExpenseExportTestItem(
|
||||||
|
reference,
|
||||||
|
poNumber,
|
||||||
|
category,
|
||||||
|
product,
|
||||||
|
location,
|
||||||
|
kandang string,
|
||||||
|
transactionDate time.Time,
|
||||||
|
realizationDate *time.Time,
|
||||||
|
qtyPengajuan,
|
||||||
|
hargaPengajuan,
|
||||||
|
totalPengajuan,
|
||||||
|
qtyRealisasi,
|
||||||
|
hargaRealisasi,
|
||||||
|
totalRealisasi float64,
|
||||||
|
stepName string,
|
||||||
|
action *string,
|
||||||
|
) dto.RepportExpenseListDTO {
|
||||||
|
item := dto.RepportExpenseListDTO{
|
||||||
|
RepportExpenseBaseDTO: dto.RepportExpenseBaseDTO{
|
||||||
|
ReferenceNumber: reference,
|
||||||
|
PoNumber: poNumber,
|
||||||
|
Category: category,
|
||||||
|
TransactionDate: transactionDate,
|
||||||
|
RealizationDate: realizationDate,
|
||||||
|
Supplier: &supplierDTO.SupplierRelationDTO{
|
||||||
|
Name: "Supplier A",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Pengajuan: dto.RepportExpensePengajuanDTO{
|
||||||
|
Qty: qtyPengajuan,
|
||||||
|
Price: hargaPengajuan,
|
||||||
|
Nonstock: &nonstockDTO.NonstockRelationDTO{
|
||||||
|
Name: product,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Realisasi: dto.RepportExpenseRealisasiDTO{
|
||||||
|
Qty: qtyRealisasi,
|
||||||
|
Price: hargaRealisasi,
|
||||||
|
Nonstock: &nonstockDTO.NonstockRelationDTO{
|
||||||
|
Name: product,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TotalPengajuan: totalPengajuan,
|
||||||
|
TotalRealisasi: totalRealisasi,
|
||||||
|
LatestApproval: &approvalDTO.ApprovalRelationDTO{
|
||||||
|
StepName: stepName,
|
||||||
|
Action: action,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if kandang != "" {
|
||||||
|
item.Kandang = &kandangDTO.KandangRelationDTO{Name: kandang}
|
||||||
|
if location != "" {
|
||||||
|
item.Kandang.Location = &locationDTO.LocationRelationDTO{Name: location}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type RepportExpenseBaseDTO struct {
|
|||||||
ReferenceNumber string `json:"reference_number"`
|
ReferenceNumber string `json:"reference_number"`
|
||||||
PoNumber string `json:"po_number"`
|
PoNumber string `json:"po_number"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"`
|
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"`
|
||||||
RealizationDate *time.Time `json:"realization_date,omitempty"`
|
RealizationDate *time.Time `json:"realization_date,omitempty"`
|
||||||
TransactionDate time.Time `json:"transaction_date"`
|
TransactionDate time.Time `json:"transaction_date"`
|
||||||
@@ -74,6 +75,7 @@ func ToRepportExpenseBaseDTO(e *entity.Expense) RepportExpenseBaseDTO {
|
|||||||
ReferenceNumber: e.ReferenceNumber,
|
ReferenceNumber: e.ReferenceNumber,
|
||||||
PoNumber: e.PoNumber,
|
PoNumber: e.PoNumber,
|
||||||
Category: e.Category,
|
Category: e.Category,
|
||||||
|
Notes: e.Notes,
|
||||||
Supplier: supplier,
|
Supplier: supplier,
|
||||||
RealizationDate: realizationDate,
|
RealizationDate: realizationDate,
|
||||||
TransactionDate: e.TransactionDate,
|
TransactionDate: e.TransactionDate,
|
||||||
|
|||||||
Reference in New Issue
Block a user