fix perhitunga sapronak

This commit is contained in:
giovanni
2026-04-21 19:30:03 +07:00
parent 1e34a0e7b2
commit ded8be198a
8 changed files with 433 additions and 35 deletions
@@ -153,6 +153,20 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
return normalized
}
normalizeCutOverToken := func(raw string) string {
normalized := strings.ToUpper(strings.TrimSpace(raw))
normalized = strings.ReplaceAll(normalized, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
return normalized
}
containsCutOver := func(values ...string) bool {
for _, value := range values {
if strings.Contains(normalizeCutOverToken(value), "CUTOVER") {
return true
}
}
return false
}
filter := normalizeFlag(flag)
byFlag := map[string]**SapronakCategoryDTO{}
@@ -258,6 +272,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
UnitPrice: item.Harga,
Notes: "-",
}
isCutOver := containsCutOver(baseRow.ProductCategory, baseRow.Description, item.ProductName)
row := getOrCreateRow(productKey, baseRow)
@@ -289,11 +304,21 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
case "adjustment keluar", "mutasi keluar", "penjualan":
case "adjustment keluar":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
if row.UnitPrice == 0 {
row.UnitPrice = price
}
if isCutOver {
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
continue
}
row.QtyOut += item.QtyKeluar
case "mutasi keluar", "penjualan":
row.QtyOut += item.QtyKeluar
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
@@ -0,0 +1,124 @@
package dto
import "testing"
func TestToSapronakProjectAggregatedFromReportMovesCutOverAdjustmentOutToQtyUsed(t *testing.T) {
tests := []struct {
name string
groupFlag string
filter string
productFlag string
}{
{
name: "pakan cut-over",
groupFlag: "PAKAN",
filter: "PAKAN",
productFlag: "PAKAN CUT-OVER",
},
{
name: "ovk cut over",
groupFlag: "OVK",
filter: "OVK",
productFlag: "OVK CUT OVER",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
report := &SapronakReportDTO{
Groups: []SapronakGroupDTO{
{
Flag: tc.groupFlag,
Items: []SapronakDetailDTO{
{
ProductID: 1,
ProductName: "CUTOVER ITEM",
NoReferensi: "ADJ-CUT-01",
JenisTransaksi: "Adjustment Keluar",
QtyKeluar: 5,
Harga: 15000,
},
},
},
},
}
result := ToSapronakProjectAggregatedFromReport(
report,
tc.filter,
map[uint][]string{
1: {tc.productFlag},
},
)
var cat *SapronakCategoryDTO
if tc.groupFlag == "PAKAN" {
cat = result.Pakan
} else {
cat = result.Ovk
}
if cat == nil {
t.Fatalf("expected category payload for %s", tc.groupFlag)
}
if len(cat.Rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(cat.Rows))
}
row := cat.Rows[0]
if row.QtyOut != 0 {
t.Fatalf("expected qty_out 0 for cut-over adjustment, got %.2f", row.QtyOut)
}
if row.QtyUsed != 5 {
t.Fatalf("expected qty_used 5 for cut-over adjustment, got %.2f", row.QtyUsed)
}
if row.UnitPrice != 15000 {
t.Fatalf("expected unit_price 15000, got %.2f", row.UnitPrice)
}
if row.TotalAmount != 75000 {
t.Fatalf("expected total_amount 75000, got %.2f", row.TotalAmount)
}
})
}
}
func TestToSapronakProjectAggregatedFromReportKeepsNonCutOverAdjustmentOutInQtyOut(t *testing.T) {
report := &SapronakReportDTO{
Groups: []SapronakGroupDTO{
{
Flag: "PAKAN",
Items: []SapronakDetailDTO{
{
ProductID: 7,
ProductName: "PAKAN REGULER",
NoReferensi: "ADJ-REG-01",
JenisTransaksi: "Adjustment Keluar",
QtyKeluar: 3,
Harga: 12000,
},
},
},
},
}
result := ToSapronakProjectAggregatedFromReport(
report,
"PAKAN",
map[uint][]string{
7: {"PAKAN"},
},
)
if result.Pakan == nil || len(result.Pakan.Rows) != 1 {
t.Fatalf("expected 1 pakan row, got %+v", result.Pakan)
}
row := result.Pakan.Rows[0]
if row.QtyOut != 3 {
t.Fatalf("expected qty_out 3 for non cut-over adjustment, got %.2f", row.QtyOut)
}
if row.QtyUsed != 0 {
t.Fatalf("expected qty_used 0 for non cut-over adjustment, got %.2f", row.QtyUsed)
}
if row.TotalAmount != 0 {
t.Fatalf("expected total_amount 0 for non cut-over adjustment, got %.2f", row.TotalAmount)
}
}
@@ -1576,11 +1576,20 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
poByWarehouse := r.DB().
Table("(?) AS ranked_po",
r.DB().
Table("purchase_items pi").
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
Select(`
pi.product_warehouse_id,
po.po_number,
pi.received_date,
ROW_NUMBER() OVER (PARTITION BY pi.product_warehouse_id ORDER BY pi.received_date ASC, pi.id ASC) AS rn
`).
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL").
Order("pi.product_warehouse_id, pi.received_date ASC")
Where("pi.received_date IS NOT NULL"),
).
Select("ranked_po.product_warehouse_id, ranked_po.po_number, ranked_po.received_date").
Where("ranked_po.rn = 1")
incomingQuery := r.withCtx(ctx).
Table("adjustment_stocks AS ast").
@@ -1589,10 +1598,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
p.name AS product_name,
f.name AS flag,
ast.created_at AS date,
CONCAT('ADJ-', ast.id) AS reference,
'ADJ-' || CAST(ast.id AS TEXT) AS reference,
COALESCE(ast.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
COALESCE(ast.price, p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
@@ -1615,10 +1624,18 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
p.name AS product_name,
f.name AS flag,
COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
pfp_po.po_number,
CASE WHEN ast_in.id IS NOT NULL THEN 'ADJ-' || CAST(ast_in.id AS TEXT) END,
CASE WHEN ast.id IS NOT NULL THEN 'ADJ-' || CAST(ast.id AS TEXT) END,
CASE WHEN pc.id IS NOT NULL THEN 'CHICKIN-' || CAST(pc.id AS TEXT) END
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
COALESCE(pi.price, ast_in.price, ast.price, p.product_price, 0) AS price
`).
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
@@ -1639,7 +1656,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price")
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, pi.price, ast_in.price, ast.price, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery)
@@ -89,6 +89,63 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
}
}
func TestFetchSapronakAdjustmentsUsesAdjustmentReferenceAndPrice(t *testing.T) {
db := setupClosingRepositoryTestDB(t)
repo := NewClosingRepository(db)
ctx := context.Background()
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (5, 5)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'OBT')`,
`INSERT INTO products (id, name, product_category_id, product_price) VALUES (17, 'OVK CUT-OVER', 1, 1)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES (1, 17, 'products', 'OVK')`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id) VALUES (1365, 17, 5, 66)`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, usage_qty, price) VALUES
(1139, 1365, 1, 0, 298594487),
(1140, 1365, 0, 1, 298594487)`,
fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES
(25990, 1365, '%s', 1139, '%s', 1140, 1, 'CONSUME', 'ACTIVE')`,
fifo.StockableKeyAdjustmentIn.String(),
fifo.UsableKeyAdjustmentOut.String(),
),
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding schema: %v", err)
}
}
incoming, outgoing, err := repo.FetchSapronakAdjustments(ctx, 5, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
incomingRows := incoming[17]
if len(incomingRows) != 1 {
t.Fatalf("expected 1 incoming row for product 17, got %d", len(incomingRows))
}
if incomingRows[0].Reference != "ADJ-1139" {
t.Fatalf("expected incoming reference ADJ-1139, got %q", incomingRows[0].Reference)
}
if incomingRows[0].Price != 298594487 {
t.Fatalf("expected incoming price 298594487 from adjustment_stocks.price, got %.3f", incomingRows[0].Price)
}
outgoingRows := outgoing[17]
if len(outgoingRows) != 1 {
t.Fatalf("expected 1 outgoing row for product 17, got %d", len(outgoingRows))
}
if outgoingRows[0].Reference != "ADJ-1139" {
t.Fatalf("expected outgoing reference ADJ-1139, got %q", outgoingRows[0].Reference)
}
if outgoingRows[0].Reference == "CHICKIN-" {
t.Fatalf("expected outgoing reference to avoid CHICKIN- placeholder")
}
if outgoingRows[0].Price != 298594487 {
t.Fatalf("expected outgoing price 298594487 from adjustment_stocks.price, got %.3f", outgoingRows[0].Price)
}
}
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
@@ -134,6 +191,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
@@ -152,7 +210,13 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL
project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NULL
)`,
`CREATE TABLE project_flock_populations (
id INTEGER PRIMARY KEY,
project_chickin_id INTEGER NULL,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
@@ -179,6 +243,16 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
movement_number TEXT NULL,
reason TEXT NULL
)`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
transfer_date TIMESTAMP NULL,
transfer_number TEXT NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NOT NULL,
@@ -193,6 +267,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
product_warehouse_id INTEGER NOT NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
adj_number TEXT NULL,
created_at TIMESTAMP NULL
)`,
@@ -573,17 +573,21 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return nil, nil, err
}
var minChickin *time.Time
var firstChickin struct {
ChickInDate time.Time `gorm:"column:chick_in_date"`
}
if err := db.Table("project_chickins").
Select("MIN(chick_in_date)").
Where("project_flock_kandang_id = ?", pfk.Id).
Scan(&minChickin).Error; err != nil {
Select("chick_in_date").
Where("project_flock_kandang_id = ? AND chick_in_date IS NOT NULL", pfk.Id).
Order("chick_in_date ASC").
Limit(1).
Scan(&firstChickin).Error; err != nil {
return nil, nil, err
}
start := pfk.CreatedAt
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
if !firstChickin.ChickInDate.IsZero() {
start = firstChickin.ChickInDate
}
startDate := dateOnlyUTC(start)
@@ -596,26 +600,34 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return &startDate, endDate, nil
}
var minCreated time.Time
var firstPFK entity.ProjectFlockKandang
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MIN(created_at)").
Select("created_at").
Where("project_flock_id = ?", projectFlockID).
Scan(&minCreated).Error; err != nil {
Order("created_at ASC").
First(&firstPFK).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil
}
return nil, nil, err
}
var minChickin *time.Time
var firstChickin struct {
ChickInDate time.Time `gorm:"column:chick_in_date"`
}
if err := db.Table("project_chickins pc").
Select("MIN(pc.chick_in_date)").
Select("pc.chick_in_date").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Scan(&minChickin).Error; err != nil {
Where("pfk.project_flock_id = ? AND pc.chick_in_date IS NOT NULL", projectFlockID).
Order("pc.chick_in_date ASC").
Limit(1).
Scan(&firstChickin).Error; err != nil {
return nil, nil, err
}
start := minCreated
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
start := firstPFK.CreatedAt
if !firstChickin.ChickInDate.IsZero() {
start = firstChickin.ChickInDate
}
startDate := dateOnlyUTC(start)
@@ -627,15 +639,19 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return nil, nil, err
}
if openCount == 0 {
var maxClosed *time.Time
var latestClosed entity.ProjectFlockKandang
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MAX(closed_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&maxClosed).Error; err != nil {
Select("closed_at").
Where("project_flock_id = ? AND closed_at IS NOT NULL", projectFlockID).
Order("closed_at DESC").
First(&latestClosed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &startDate, nil, nil
}
return nil, nil, err
}
if maxClosed != nil && !maxClosed.IsZero() {
d := dateOnlyUTC(*maxClosed)
if latestClosed.ClosedAt != nil && !latestClosed.ClosedAt.IsZero() {
d := dateOnlyUTC(*latestClosed.ClosedAt)
endDate = &d
}
}
@@ -0,0 +1,94 @@
package service
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
"gorm.io/gorm"
)
func TestGetSapronakDateRange_ProjectWithoutChickin_DoesNotError(t *testing.T) {
db := setupClosingServiceTestDB(t)
repo := repository.NewClosingRepository(db)
svc := closingService{Repository: repo}
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
}
start, end, err := svc.getSapronakDateRange(context.Background(), 47, nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if start == nil {
t.Fatalf("expected non-nil start date")
}
expected := dateOnlyUTC(createdAt)
if !start.Equal(expected) {
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
}
if end != nil {
t.Fatalf("expected nil end date for open kandang, got %v", end)
}
}
func TestGetSapronakDateRange_KandangWithoutChickin_DoesNotError(t *testing.T) {
db := setupClosingServiceTestDB(t)
repo := repository.NewClosingRepository(db)
svc := closingService{Repository: repo}
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
}
pfkID := uint(66)
start, end, err := svc.getSapronakDateRange(context.Background(), 47, &pfkID)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if start == nil {
t.Fatalf("expected non-nil start date")
}
expected := dateOnlyUTC(createdAt)
if !start.Equal(expected) {
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
}
if end != nil {
t.Fatalf("expected nil end date for open kandang, got %v", end)
}
}
func setupClosingServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
stmts := []string{
`CREATE TABLE project_flock_kandangs (
id INTEGER PRIMARY KEY,
project_flock_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
closed_at TIMESTAMP NULL
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NULL
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -371,7 +371,9 @@ func buildSapronakDetails(
addRows(result.Incoming, incomingRows, "Pembelian", true)
addRows(result.Usage, usageRows, "Pemakaian", false)
addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true)
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false)
// Outgoing adjustment rows here are sourced from stock allocation
// consume flow (adjustment_stocks.usage_qty), so treat them as usage.
addRows(result.AdjOutgoing, adjOutgoingRows, "Pemakaian", false)
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
addRows(result.SalesOut, salesOutRows, "Penjualan", false)
@@ -0,0 +1,45 @@
package service
import (
"testing"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
)
func TestBuildSapronakDetailsMapsAdjustmentOutgoingAsUsage(t *testing.T) {
res := buildSapronakDetails(
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{
17: {
{
ProductID: 17,
ProductName: "PAKAN GROWING CRUMBLE 8603 MALINDO",
Flag: "PAKAN",
QtyOut: 9000,
Price: 6450,
},
},
},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
)
rows := res.AdjOutgoing[17]
if len(rows) != 1 {
t.Fatalf("expected 1 adjustment outgoing row, got %d", len(rows))
}
row := rows[0]
if row.JenisTransaksi != "Pemakaian" {
t.Fatalf("expected jenis_transaksi Pemakaian, got %q", row.JenisTransaksi)
}
if row.QtyKeluar != 9000 {
t.Fatalf("expected qty_keluar 9000, got %.3f", row.QtyKeluar)
}
if row.Nilai != 58050000 {
t.Fatalf("expected nilai 58050000, got %.3f", row.Nilai)
}
}