Compare commits

..

5 Commits

Author SHA1 Message Date
Giovanni Gabriel Septriadi c9e3905a65 Merge branch 'feat/filter' into 'development'
adjust export format purchase and filter

See merge request mbugroup/lti-api!547
2026-05-21 04:49:46 +00:00
giovanni 495f5f5cc1 adjust export format purchase and filter 2026-05-21 11:48:24 +07:00
Giovanni Gabriel Septriadi 71e80634b1 Merge branch 'feat/bop-finance' into 'development'
add vendor ekspedisi to laporan keuangan

See merge request mbugroup/lti-api!546
2026-05-21 01:42:48 +00:00
giovanni af2b3366ba add vendor ekspedisi to laporan keuanga 2026-05-20 23:10:08 +07:00
Giovanni Gabriel Septriadi e015e20b5c Merge branch 'fix/expenses' into 'development'
[FIX][BE]: adjust response get report expense

See merge request mbugroup/lti-api!545
2026-05-20 07:40:50 +00:00
14 changed files with 1000 additions and 275 deletions
@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations"
@@ -13,6 +14,8 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
const transactionExcelExportFetchLimit = 99999999
type TransactionController struct { type TransactionController struct {
TransactionService service.TransactionService TransactionService service.TransactionService
} }
@@ -107,6 +110,14 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
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")
} }
if isTransactionExcelExportRequest(c) {
results, err := u.getAllTransactionsForExcel(c, query)
if err != nil {
return err
}
return exportTransactionListExcel(c, results)
}
result, totalResults, err := u.TransactionService.GetAll(c, query) result, totalResults, err := u.TransactionService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -149,6 +160,32 @@ func (u *TransactionController) GetOne(c *fiber.Ctx) error {
}) })
} }
func isTransactionExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func (u *TransactionController) getAllTransactionsForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]entity.Payment, error) {
query := *baseQuery
query.Page = 1
query.Limit = transactionExcelExportFetchLimit
results := make([]entity.Payment, 0)
for {
pageResults, total, err := u.TransactionService.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 *TransactionController) DeleteOne(c *fiber.Ctx) error { func (u *TransactionController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -0,0 +1,307 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
const transactionExportSheetName = "Transaksi"
func exportTransactionListExcel(c *fiber.Ctx, payments []entity.Payment) error {
content, err := buildTransactionExportWorkbook(payments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("transaksi_%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 buildTransactionExportWorkbook(payments []entity.Payment) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != transactionExportSheetName {
if err := file.SetSheetName(defaultSheet, transactionExportSheetName); err != nil {
return nil, err
}
}
if err := setTransactionExportColumns(file); err != nil {
return nil, err
}
if err := setTransactionExportHeaders(file); err != nil {
return nil, err
}
if err := setTransactionExportRows(file, payments); err != nil {
return nil, err
}
if err := file.SetPanes(transactionExportSheetName, &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 setTransactionExportColumns(file *excelize.File) error {
columnWidths := map[string]float64{
"A": 20,
"B": 22,
"C": 18,
"D": 25,
"E": 14,
"F": 16,
"G": 16,
"H": 22,
"I": 22,
"J": 18,
"K": 18,
"L": 18,
"M": 30,
"N": 22,
"O": 20,
}
sheet := transactionExportSheetName
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setTransactionExportHeaders(file *excelize.File) error {
sheet := transactionExportSheetName
headers := []string{
"Kode Pembayaran",
"No. Referensi",
"Tipe Transaksi",
"Pihak",
"Tipe Pihak",
"Tanggal Bayar",
"Metode Bayar",
"Bank",
"No. Rekening Bank",
"Pemasukan",
"Pengeluaran",
"Nominal",
"Catatan",
"Dibuat Oleh",
"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", "O1", headerStyle)
}
func setTransactionExportRows(file *excelize.File, payments []entity.Payment) error {
if len(payments) == 0 {
return nil
}
sheet := transactionExportSheetName
for i, p := range payments {
row := strconv.Itoa(i + 2)
if err := writeTransactionExportRow(file, sheet, row, p); err != nil {
return err
}
}
lastRow := strconv.Itoa(len(payments) + 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", "O"+lastRow, dataStyle); err != nil {
return err
}
numericStyle, 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, "J2", "L"+lastRow, numericStyle)
}
func writeTransactionExportRow(file *excelize.File, sheet, row string, p entity.Payment) error {
incomeAmount, expenseAmount := txAmounts(p.Direction, p.Nominal)
values := []interface{}{
safeTxText(p.PaymentCode),
safeTxRefNumber(p.ReferenceNumber),
safeTxText(txTransactionType(p)),
safeTxText(txPartyName(p)),
safeTxText(p.PartyType),
formatTxDate(p.PaymentDate),
safeTxText(p.PaymentMethod),
safeTxBank(p),
safeTxBankAccount(p),
incomeAmount,
expenseAmount,
p.Nominal,
safeTxText(p.Notes),
safeTxText(txCreatedBy(p)),
formatTxStatus(p),
}
for colIdx, val := range values {
colName, err := excelize.ColumnNumberToName(colIdx + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+row, val); err != nil {
return err
}
}
return nil
}
func safeTxText(s string) string {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return "-"
}
return trimmed
}
func safeTxRefNumber(s *string) string {
if s == nil {
return "-"
}
return safeTxText(*s)
}
func safeTxBank(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.Name)
}
func safeTxBankAccount(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.AccountNumber)
}
func formatTxDate(t time.Time) string {
if t.IsZero() {
return "-"
}
loc, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(loc)
}
return t.Format("02-01-2006")
}
func formatTxStatus(p entity.Payment) string {
if p.LatestApproval == nil {
return "-"
}
return safeTxText(p.LatestApproval.StepName)
}
func txTransactionType(p entity.Payment) string {
if p.TransactionType != "" {
return p.TransactionType
}
return p.Direction
}
func txPartyName(p entity.Payment) string {
switch p.PartyType {
case "CUSTOMER":
if p.Customer != nil && p.Customer.Id != 0 {
return p.Customer.Name
}
case "SUPPLIER":
if p.Supplier != nil && p.Supplier.Id != 0 {
return p.Supplier.Name
}
}
return ""
}
func txCreatedBy(p entity.Payment) string {
if p.CreatedUser.Id == 0 {
return ""
}
return p.CreatedUser.Name
}
func txAmounts(direction string, nominal float64) (income, expense float64) {
switch strings.ToUpper(direction) {
case "IN":
return nominal, 0
case "OUT":
return 0, nominal
default:
return 0, 0
}
}
@@ -10,7 +10,7 @@ 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,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"` TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"` BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
@@ -2,7 +2,6 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -153,7 +152,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil { if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil { if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), sumMarketingGrandTotal(item.SalesOrder)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil { if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil {
@@ -266,40 +265,6 @@ func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
return total 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 { func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
@@ -91,11 +91,9 @@ func buildPurchaseQuery(c *fiber.Ctx) *validation.Query {
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search")), Search: strings.TrimSpace(c.Query("search")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")), ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
PoDate: strings.TrimSpace(c.Query("po_date")), StartDate: strings.TrimSpace(c.Query("start_date")),
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")), EndDate: strings.TrimSpace(c.Query("end_date")),
PoDateTo: strings.TrimSpace(c.Query("po_date_to")), FilterBy: strings.TrimSpace(c.Query("filter_by")),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)), SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)), AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)), LocationID: uint(c.QueryInt("location_id", 0)),
@@ -2,7 +2,6 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -43,15 +42,13 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
} }
} }
grandTotals := buildPurchaseGrandTotalMap(purchases)
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil { if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
return nil, err return nil, err
} }
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil { if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
return nil, err return nil, err
} }
if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases, grandTotals); err != nil { if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases); err != nil {
return nil, err return nil, err
} }
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{ if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
@@ -80,9 +77,17 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
"F": 22, "F": 22,
"G": 22, "G": 22,
"H": 32, "H": 32,
"I": 18, "I": 10,
"J": 18, "J": 12,
"K": 24, "K": 16,
"L": 16,
"M": 22,
"N": 12,
"O": 16,
"P": 16,
"Q": 18,
"R": 18,
"S": 24,
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -99,17 +104,25 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
func setPurchaseExportHeaders(file *excelize.File, sheet string) error { func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{ headers := []string{
"PR Number", "PR Number", // A
"PO Number", "PO Number", // B
"Tanggal PO", "Tanggal PO", // C
"Tanggal Terima", "Tanggal Terima", // D
"Supplier", "Supplier", // E
"Lokasi", "Lokasi", // F
"Gudang", "Gudang", // G
"Product", "Product", // H
"Status", "Qty", // I
"Grand Total", "Satuan", // J
"Notes", "Price", // K
"Total Produk", // L
"Vendor Ekspedisi",// M
"Qty Ekspedisi", // N
"Price Ekspedisi", // O
"Total Ekspedisi", // P
"Grand Total All", // Q
"Status", // R
"Notes", // S
} }
for i, header := range headers { for i, header := range headers {
@@ -137,34 +150,36 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "K1", headerStyle) return file.SetCellStyle(sheet, "A1", "S1", headerStyle)
} }
func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase, grandTotals map[uint]float64) error { func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase) error {
if len(purchases) == 0 { if len(purchases) == 0 {
return nil return nil
} }
var sumL, sumP, sumQ float64
rowIdx := 2 rowIdx := 2
for p := range purchases { for p := range purchases {
purchase := &purchases[p] purchase := &purchases[p]
total := grandTotals[purchase.Id]
if len(purchase.Items) == 0 { if len(purchase.Items) == 0 {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, total); err != nil { if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, &sumL, &sumP, &sumQ); err != nil {
return err return err
} }
rowIdx++ rowIdx++
continue continue
} }
for it := range purchase.Items { for it := range purchase.Items {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], total); err != nil { if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], &sumL, &sumP, &sumQ); err != nil {
return err return err
} }
rowIdx++ rowIdx++
} }
} }
lastRow := rowIdx - 1 lastDataRow := rowIdx - 1
dataStyle, err := file.NewStyle(&excelize.Style{ dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{ Alignment: &excelize.Alignment{
Horizontal: "left", Horizontal: "left",
@@ -181,7 +196,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A2", "K"+strconv.Itoa(lastRow), dataStyle); err != nil { if err := file.SetCellStyle(sheet, "A2", "S"+strconv.Itoa(lastDataRow), dataStyle); err != nil {
return err return err
} }
@@ -200,14 +215,17 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "K2", "Q"+strconv.Itoa(lastDataRow), moneyStyle); err != nil {
return err
}
return file.SetCellStyle(sheet, "J2", "J"+strconv.Itoa(lastRow), moneyStyle) return addPurchaseExportSumRow(file, sheet, rowIdx, sumL, sumP, sumQ)
} }
func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, grandTotal float64) error { func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, sumL, sumP, sumQ *float64) error {
row := strconv.Itoa(rowIdx) row := strconv.Itoa(rowIdx)
// Purchase-level columns (repeat across rows of the same purchase) // Purchase-level columns (repeat for every item row of the same purchase)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil { if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil {
return err return err
} }
@@ -220,26 +238,40 @@ func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purch
if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil { if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "I"+row, formatPurchaseExportEntityStatus(purchase)); err != nil { if err := file.SetCellValue(sheet, "R"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "J"+row, formatPurchaseRupiah(grandTotal)); err != nil { if err := file.SetCellValue(sheet, "S"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
return err return err
} }
// Item-level columns
if item == nil { if item == nil {
for _, col := range []string{"D", "F", "G", "H"} { for _, col := range []string{"D", "F", "G", "H", "J", "M"} {
if err := file.SetCellValue(sheet, col+row, "-"); err != nil { if err := file.SetCellValue(sheet, col+row, "-"); err != nil {
return err return err
} }
} }
for _, col := range []string{"I", "K", "L", "N", "O", "P", "Q"} {
if err := file.SetCellValue(sheet, col+row, 0); err != nil {
return err
}
}
return nil return nil
} }
// Item-level columns
var expeditionQty, expeditionPrice, expeditionTotal float64
if item.ExpenseNonstock != nil {
expeditionQty = item.ExpenseNonstock.Qty
expeditionPrice = item.ExpenseNonstock.Price
expeditionTotal = expeditionQty * expeditionPrice
}
itemGrandTotal := item.TotalPrice + expeditionTotal
*sumL += item.TotalPrice
*sumP += expeditionTotal
*sumQ += itemGrandTotal
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil { if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
return err return err
} }
@@ -252,20 +284,96 @@ func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purch
if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil { if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "I"+row, item.TotalQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, safePurchaseItemUomName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+row, item.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, item.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+row, safePurchaseItemExpeditionVendorName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+row, expeditionQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+row, expeditionPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, expeditionTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+row, itemGrandTotal); err != nil {
return err
}
return nil return nil
} }
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 { func addPurchaseExportSumRow(file *excelize.File, sheet string, rowIdx int, sumL, sumP, sumQ float64) error {
result := make(map[uint]float64, len(items)) row := strconv.Itoa(rowIdx)
for i := range items {
total := 0.0 sumStyle, err := file.NewStyle(&excelize.Style{
for j := range items[i].Items { Font: &excelize.Font{Bold: true, Color: "1F2937"},
total += items[i].Items[j].TotalPrice Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
} Alignment: &excelize.Alignment{
result[items[i].Id] = total Horizontal: "left",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
} }
return result
sumMoneyStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+row, "S"+row, sumStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "L"+row, "L"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "P"+row, "Q"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+row, "TOTAL"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, sumL); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, sumP); err != nil {
return err
}
return file.SetCellValue(sheet, "Q"+row, sumQ)
} }
func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string { func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string {
@@ -296,6 +404,24 @@ func safePurchaseItemProductName(item *entity.PurchaseItem) string {
return safePurchaseExportText(item.Product.Name) return safePurchaseExportText(item.Product.Name)
} }
func safePurchaseItemUomName(item *entity.PurchaseItem) string {
if item.Product == nil || item.Product.Uom.Id == 0 {
return "-"
}
return safePurchaseExportText(item.Product.Uom.Name)
}
func safePurchaseItemExpeditionVendorName(item *entity.PurchaseItem) string {
if item.ExpenseNonstock == nil || item.ExpenseNonstock.Expense == nil {
return "-"
}
exp := item.ExpenseNonstock.Expense
if exp.Supplier == nil || exp.Supplier.Id == 0 {
return "-"
}
return safePurchaseExportText(exp.Supplier.Name)
}
func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string { func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string {
if purchase.LatestApproval == nil { if purchase.LatestApproval == nil {
return "-" return "-"
@@ -338,37 +464,3 @@ func safePurchaseExportText(value string) string {
return trimmed 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()
}
@@ -22,9 +22,8 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
nil, nil,
"catatan", "catatan",
[]entity.PurchaseItem{ []entity.PurchaseItem{
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"), buildPurchaseItemForExportTest(11, "Pakan Starter", 500, 2, 1000000, "Location A", "kg"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"), buildPurchaseItemForExportTest(12, "Vitamin A", 350, 1, 350000, "Location B", "botol"),
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
}, },
), ),
buildPurchaseForExportTest( buildPurchaseForExportTest(
@@ -37,7 +36,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
ptrApprovalAction(entity.ApprovalActionRejected), ptrApprovalAction(entity.ApprovalActionRejected),
"", "",
[]entity.PurchaseItem{ []entity.PurchaseItem{
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""), buildPurchaseItemForExportTest(21, "Obat X", 75000, 1, 75000, "", ""),
}, },
), ),
}) })
@@ -51,16 +50,27 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
} }
defer file.Close() defer file.Close()
// Verify all 19 headers
expectedHeaders := map[string]string{ expectedHeaders := map[string]string{
"A1": "PR Number", "A1": "PR Number",
"B1": "PO Number", "B1": "PO Number",
"C1": "Tanggal PO", "C1": "Tanggal PO",
"D1": "Supplier", "D1": "Tanggal Terima",
"E1": "Lokasi", "E1": "Supplier",
"F1": "Status", "F1": "Lokasi",
"G1": "Grand Total", "G1": "Gudang",
"H1": "Products", "H1": "Product",
"I1": "Notes", "I1": "Qty",
"J1": "Satuan",
"K1": "Price",
"L1": "Total Produk",
"M1": "Vendor Ekspedisi",
"N1": "Qty Ekspedisi",
"O1": "Price Ekspedisi",
"P1": "Total Ekspedisi",
"Q1": "Grand Total All",
"R1": "Status",
"S1": "Notes",
} }
for cell, expected := range expectedHeaders { for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(purchaseExportSheetName, cell) got, err := file.GetCellValue(purchaseExportSheetName, cell)
@@ -72,24 +82,46 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
} }
} }
// Row 2: Purchase 1, Item 1 (Pakan Starter)
assertPurchaseCellEquals(t, file, "A2", "PR-00011") assertPurchaseCellEquals(t, file, "A2", "PR-00011")
assertPurchaseCellEquals(t, file, "B2", "PO-00011") assertPurchaseCellEquals(t, file, "B2", "PO-00011")
assertPurchaseCellEquals(t, file, "C2", "22-04-2026") assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
assertPurchaseCellEquals(t, file, "D2", "Supplier A") assertPurchaseCellEquals(t, file, "E2", "Supplier A")
assertPurchaseCellEquals(t, file, "E2", "Location A") assertPurchaseCellEquals(t, file, "F2", "Location A")
assertPurchaseCellEquals(t, file, "F2", "Manager Purchase") assertPurchaseCellEquals(t, file, "H2", "Pakan Starter")
assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000") assertPurchaseCellEquals(t, file, "J2", "kg")
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A") assertPurchaseCellEquals(t, file, "K2", "500")
assertPurchaseCellEquals(t, file, "I2", "catatan") assertPurchaseCellEquals(t, file, "L2", "1000000")
assertPurchaseCellEquals(t, file, "M2", "-")
assertPurchaseCellEquals(t, file, "P2", "0")
assertPurchaseCellEquals(t, file, "Q2", "1000000")
assertPurchaseCellEquals(t, file, "R2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "S2", "catatan")
assertPurchaseCellEquals(t, file, "A3", "PR-00012") // Row 3: Purchase 1, Item 2 (Vitamin A)
assertPurchaseCellEquals(t, file, "B3", "-") assertPurchaseCellEquals(t, file, "A3", "PR-00011")
assertPurchaseCellEquals(t, file, "C3", "-") assertPurchaseCellEquals(t, file, "H3", "Vitamin A")
assertPurchaseCellEquals(t, file, "E3", "-") assertPurchaseCellEquals(t, file, "J3", "botol")
assertPurchaseCellEquals(t, file, "F3", "Ditolak") assertPurchaseCellEquals(t, file, "L3", "350000")
assertPurchaseCellEquals(t, file, "G3", "Rp 75.000") assertPurchaseCellEquals(t, file, "Q3", "350000")
assertPurchaseCellEquals(t, file, "H3", "Obat X")
assertPurchaseCellEquals(t, file, "I3", "-") // Row 4: Purchase 2, Item 1 (Obat X) — no location, rejected
assertPurchaseCellEquals(t, file, "A4", "PR-00012")
assertPurchaseCellEquals(t, file, "B4", "-")
assertPurchaseCellEquals(t, file, "C4", "-")
assertPurchaseCellEquals(t, file, "F4", "-")
assertPurchaseCellEquals(t, file, "H4", "Obat X")
assertPurchaseCellEquals(t, file, "J4", "-")
assertPurchaseCellEquals(t, file, "L4", "75000")
assertPurchaseCellEquals(t, file, "Q4", "75000")
assertPurchaseCellEquals(t, file, "R4", "Ditolak")
assertPurchaseCellEquals(t, file, "S4", "-")
// Row 5: SUM row — total produk=1425000, ekspedisi=0, grand total all=1425000
assertPurchaseCellEquals(t, file, "A5", "TOTAL")
assertPurchaseCellEquals(t, file, "L5", "1425000")
assertPurchaseCellEquals(t, file, "P5", "0")
assertPurchaseCellEquals(t, file, "Q5", "1425000")
} }
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) { func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -144,13 +176,20 @@ func buildPurchaseForExportTest(
} }
} }
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem { func buildPurchaseItemForExportTest(productID uint, productName string, price, totalQty, totalPrice float64, locationName, uomName string) entity.PurchaseItem {
uomID := uint(0)
if uomName != "" {
uomID = productID + 2000
}
item := entity.PurchaseItem{ item := entity.PurchaseItem{
ProductId: productID, ProductId: productID,
Price: price,
TotalQty: totalQty,
TotalPrice: totalPrice, TotalPrice: totalPrice,
Product: &entity.Product{ Product: &entity.Product{
Id: productID, Id: productID,
Name: productName, Name: productName,
Uom: entity.Uom{Id: uomID, Name: uomName},
}, },
} }
+26 -10
View File
@@ -32,12 +32,15 @@ type PurchaseListDTO struct {
RequesterName string `json:"requester_name"` RequesterName string `json:"requester_name"`
PoExpedition []PoExpeditionDTO `json:"po_expedition"` PoExpedition []PoExpeditionDTO `json:"po_expedition"`
Items []PurchaseItemDTO `json:"items"` Items []PurchaseItemDTO `json:"items"`
Products []productDTO.ProductRelationDTO `json:"products"` Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"` Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"` Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
ProductsTotal float64 `json:"products_total"`
ExpeditionTotal float64 `json:"expedition_total"`
GrandTotalAll float64 `json:"grand_total_all"`
} }
type PurchaseDetailDTO struct { type PurchaseDetailDTO struct {
@@ -69,6 +72,8 @@ type PurchaseItemDTO struct {
VehicleNumber *string `json:"vehicle_number"` VehicleNumber *string `json:"vehicle_number"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"` TransportPerItem *float64 `json:"transport_per_item,omitempty"`
ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"`
ExpeditionQty float64 `json:"expedition_qty"`
ExpeditionTotal float64 `json:"expedition_total"`
HasChickin bool `json:"has_chickin"` HasChickin bool `json:"has_chickin"`
} }
@@ -127,6 +132,8 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO {
if item.ExpenseNonstock != nil { if item.ExpenseNonstock != nil {
priceCopy := item.ExpenseNonstock.Price priceCopy := item.ExpenseNonstock.Price
dto.TransportPerItem = &priceCopy dto.TransportPerItem = &priceCopy
dto.ExpeditionQty = item.ExpenseNonstock.Qty
dto.ExpeditionTotal = item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
if item.ExpenseNonstock.Expense != nil { if item.ExpenseNonstock.Expense != nil {
exp := item.ExpenseNonstock.Expense exp := item.ExpenseNonstock.Expense
@@ -173,15 +180,21 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
} }
var ( var (
poExpedition = make([]PoExpeditionDTO, 0) poExpedition = make([]PoExpeditionDTO, 0)
location *locationDTO.LocationRelationDTO location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO area *areaDTO.AreaRelationDTO
receivedDate *time.Time receivedDate *time.Time
productsTotal float64
expeditionTotal float64
) )
productMap := make(map[uint]productDTO.ProductRelationDTO) productMap := make(map[uint]productDTO.ProductRelationDTO)
expeditionRefSet := make(map[uint64]struct{}) expeditionRefSet := make(map[uint64]struct{})
for i := range p.Items { for i := range p.Items {
item := p.Items[i] item := p.Items[i]
productsTotal += item.TotalPrice
if item.ExpenseNonstock != nil {
expeditionTotal += item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
}
if item.Product != nil && item.Product.Id != 0 { if item.Product != nil && item.Product.Id != 0 {
if _, exists := productMap[item.Product.Id]; !exists { if _, exists := productMap[item.Product.Id]; !exists {
productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product) productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product)
@@ -235,6 +248,9 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
CreatedAt: p.CreatedAt, CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval, LatestApproval: latestApproval,
ProductsTotal: productsTotal,
ExpeditionTotal: expeditionTotal,
GrandTotalAll: productsTotal + expeditionTotal,
} }
} }
@@ -145,33 +145,16 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id") productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id")
if err != nil { if err != nil {
return nil, 0, utils.BadRequest(err.Error()) return nil, 0, utils.BadRequest(err.Error())
} }
var poDateStart *time.Time dateStart, dateEnd, err := parsePurchaseDateRangeForQuery(params.StartDate, params.EndDate, "date")
var poDateEnd *time.Time if err != nil {
return nil, 0, utils.BadRequest(err.Error())
if strings.TrimSpace(params.PoDate) != "" {
poDate, parseErr := utils.ParseDateString(strings.TrimSpace(params.PoDate))
if parseErr != nil {
return nil, 0, utils.BadRequest("po_date must use format YYYY-MM-DD")
}
poDateStart = &poDate
poDateEndValue := poDate.AddDate(0, 0, 1)
poDateEnd = &poDateEndValue
} else {
poDateStart, poDateEnd, err = parsePoDateRangeForQuery(params.PoDateFrom, params.PoDateTo)
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
} }
filterBy := strings.TrimSpace(params.FilterBy)
search := strings.ToLower(strings.TrimSpace(params.Search)) search := strings.ToLower(strings.TrimSpace(params.Search))
approvalStatuses := parseStringCSVFilter(params.ApprovalStatus) approvalStatuses := parseStringCSVFilter(params.ApprovalStatus)
@@ -187,23 +170,41 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = db.Where("supplier_id = ?", params.SupplierID) db = db.Where("supplier_id = ?", params.SupplierID)
} }
if createdFrom != nil { switch filterBy {
db = db.Where("created_at >= ?", *createdFrom) case "po_date":
} if dateStart != nil {
db = db.Where("purchases.po_date >= ?", *dateStart)
if createdTo != nil { }
db = db.Where("created_at < ?", *createdTo) if dateEnd != nil {
} db = db.Where("purchases.po_date < ?", *dateEnd)
if poDateStart != nil { }
db = db.Where("purchases.po_date >= ?", *poDateStart) case "due_date":
} if dateStart != nil {
db = db.Where("purchases.due_date >= ?", *dateStart)
if poDateStart != nil { }
db = db.Where("purchases.po_date >= ?", *poDateStart) if dateEnd != nil {
} db = db.Where("purchases.due_date < ?", *dateEnd)
}
if poDateEnd != nil { case "received_date":
db = db.Where("purchases.po_date < ?", *poDateEnd) if dateStart != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date >= ?)`,
*dateStart,
)
}
if dateEnd != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date < ?)`,
*dateEnd,
)
}
default:
if dateStart != nil {
db = db.Where("purchases.created_at >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where("purchases.created_at < ?", *dateEnd)
}
} }
if scope.Restrict { if scope.Restrict {
@@ -2238,30 +2239,29 @@ func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []ent
return nil return nil
} }
func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) { func parsePurchaseDateRangeForQuery(fromStr, toStr, fieldName string) (*time.Time, *time.Time, error) {
var fromPtr *time.Time var fromPtr *time.Time
var toPtr *time.Time var toPtr *time.Time
if strings.TrimSpace(fromStr) != "" { if strings.TrimSpace(fromStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr)) parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr))
if err != nil { if err != nil {
return nil, nil, errors.New("po_date_from must use format YYYY-MM-DD") return nil, nil, errors.New(fieldName + "_from must use format YYYY-MM-DD")
} }
fromValue := parsed fromPtr = &parsed
fromPtr = &fromValue
} }
if strings.TrimSpace(toStr) != "" { if strings.TrimSpace(toStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(toStr)) parsed, err := utils.ParseDateString(strings.TrimSpace(toStr))
if err != nil { if err != nil {
return nil, nil, errors.New("po_date_to must use format YYYY-MM-DD") return nil, nil, errors.New(fieldName + "_to must use format YYYY-MM-DD")
} }
nextDay := parsed.AddDate(0, 0, 1) nextDay := parsed.AddDate(0, 0, 1)
toPtr = &nextDay toPtr = &nextDay
} }
if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
return nil, nil, errors.New("po_date_from must be earlier than po_date_to") return nil, nil, errors.New(fieldName + "_from must be earlier than " + fieldName + "_to")
} }
return fromPtr, toPtr, nil return fromPtr, toPtr, nil
@@ -75,12 +75,10 @@ type Query struct {
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=po_date due_date received_date created_at"`
Search string `query:"search" validate:"omitempty,max=100"` 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"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=po_expedition supplier requester_name products location po_date received_date due_date status created_at po_number"` SortBy string `query:"sort_by" validate:"omitempty,oneof=po_expedition supplier requester_name products location po_date received_date due_date status created_at po_number"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
} }
@@ -214,8 +214,7 @@ func writeCustomerPaymentSheet(file *excelize.File, sheet string, item dto.Custo
} }
// Row 2: saldo awal // Row 2: saldo awal
initialFormatted := formatCPRupiah(item.InitialBalance) if err := file.SetCellValue(sheet, "N2", item.InitialBalance); err != nil {
if err := file.SetCellValue(sheet, "N2", initialFormatted); err != nil {
return err return err
} }
if item.InitialBalance < 0 { if item.InitialBalance < 0 {
@@ -248,14 +247,14 @@ func writeCustomerPaymentSheet(file *excelize.File, sheet string, item dto.Custo
totalRowNum := len(item.Rows) + 3 totalRowNum := len(item.Rows) + 3
totalRowStr := fmt.Sprintf("%d", totalRowNum) totalRowStr := fmt.Sprintf("%d", totalRowNum)
totalCells := map[string]string{ totalCells := map[string]interface{}{
"A": "Total", "A": "Total",
"G": formatCPIDInteger(item.Summary.TotalQty), "G": formatCPIDInteger(item.Summary.TotalQty),
"H": formatCPIDInteger(item.Summary.TotalWeight), "H": formatCPIDInteger(item.Summary.TotalWeight),
"K": formatCPRupiah(item.Summary.TotalFinalAmount), "K": item.Summary.TotalFinalAmount,
"L": formatCPRupiah(item.Summary.TotalGrandAmount), "L": item.Summary.TotalGrandAmount,
"M": formatCPRupiah(item.Summary.TotalPayment), "M": item.Summary.TotalPayment,
"N": formatCPRupiah(item.Summary.TotalAccountsReceivable), "N": item.Summary.TotalAccountsReceivable,
} }
for col, val := range totalCells { for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
@@ -369,8 +368,7 @@ func writeCustomerPaymentAllRows(file *excelize.File, sheet string, items []dto.
if err := file.SetCellValue(sheet, "A"+saldoStr, name); err != nil { if err := file.SetCellValue(sheet, "A"+saldoStr, name); err != nil {
return err return err
} }
initialFormatted := formatCPRupiah(item.InitialBalance) if err := file.SetCellValue(sheet, "O"+saldoStr, item.InitialBalance); err != nil {
if err := file.SetCellValue(sheet, "O"+saldoStr, initialFormatted); err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A"+saldoStr, lastHeaderCol+saldoStr, dataStyle); err != nil { if err := file.SetCellStyle(sheet, "A"+saldoStr, lastHeaderCol+saldoStr, dataStyle); err != nil {
@@ -409,15 +407,15 @@ func writeCustomerPaymentAllRows(file *excelize.File, sheet string, items []dto.
// Total row // Total row
totalStr := fmt.Sprintf("%d", currentRow) totalStr := fmt.Sprintf("%d", currentRow)
totalCells := map[string]string{ totalCells := map[string]interface{}{
"A": name, "A": name,
"B": "Total", "B": "Total",
"H": formatCPIDInteger(item.Summary.TotalQty), "H": formatCPIDInteger(item.Summary.TotalQty),
"I": formatCPIDInteger(item.Summary.TotalWeight), "I": formatCPIDInteger(item.Summary.TotalWeight),
"L": formatCPRupiah(item.Summary.TotalFinalAmount), "L": item.Summary.TotalFinalAmount,
"M": formatCPRupiah(item.Summary.TotalGrandAmount), "M": item.Summary.TotalGrandAmount,
"N": formatCPRupiah(item.Summary.TotalPayment), "N": item.Summary.TotalPayment,
"O": formatCPRupiah(item.Summary.TotalAccountsReceivable), "O": item.Summary.TotalAccountsReceivable,
} }
for col, val := range totalCells { for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalStr, val); err != nil { if err := file.SetCellValue(sheet, col+totalStr, val); err != nil {
@@ -453,11 +451,11 @@ func customerPaymentRowCells(row dto.CustomerPaymentReportRow, seq int) []interf
formatCPIDInteger(row.Qty), formatCPIDInteger(row.Qty),
formatCPIDInteger(row.Weight), formatCPIDInteger(row.Weight),
formatCPAvg(row.AverageWeight), formatCPAvg(row.AverageWeight),
formatCPRupiah(row.UnitPrice), row.UnitPrice,
formatCPRupiah(row.FinalPrice), row.FinalPrice,
formatCPRupiah(row.TotalPrice), row.TotalPrice,
formatCPRupiah(row.PaymentAmount), row.PaymentAmount,
formatCPRupiah(row.AccountsReceivable), row.AccountsReceivable,
safeCPText(row.Status), safeCPText(row.Status),
joinCPStrings(row.PickupInfo), joinCPStrings(row.PickupInfo),
safeCPText(row.SalesPerson), safeCPText(row.SalesPerson),
@@ -546,13 +544,6 @@ func formatCPIDInteger(v float64) string {
return b.String() return b.String()
} }
func formatCPRupiah(v float64) string {
const nbsp = " "
if v < 0 {
return "-Rp" + nbsp + formatCPIDInteger(-v)
}
return "Rp" + nbsp + formatCPIDInteger(v)
}
func formatCPAvg(v float64) string { func formatCPAvg(v float64) string {
if v == 0 { if v == 0 {
@@ -2,7 +2,6 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -197,9 +196,9 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte
item.Qty, item.Qty,
item.AverageWeightKg, item.AverageWeightKg,
item.TotalWeightKg, item.TotalWeightKg,
formatMarketingRupiah(item.SalesPricePerKg), item.SalesPricePerKg,
formatMarketingRupiah(item.HppPricePerKg), item.HppPricePerKg,
formatMarketingRupiah(item.SalesAmount), item.SalesAmount,
} }
for colIdx, val := range values { for colIdx, val := range values {
@@ -229,13 +228,13 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte
if err := file.SetCellValue(sheet, "N"+totalRow, summary.TotalWeightKg); err != nil { if err := file.SetCellValue(sheet, "N"+totalRow, summary.TotalWeightKg); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "O"+totalRow, formatMarketingRupiah(summary.AverageSalesPrice)); err != nil { if err := file.SetCellValue(sheet, "O"+totalRow, summary.AverageSalesPrice); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "P"+totalRow, formatMarketingRupiah(summary.TotalHppPricePerKg)); err != nil { if err := file.SetCellValue(sheet, "P"+totalRow, summary.TotalHppPricePerKg); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil { if err := file.SetCellValue(sheet, "Q"+totalRow, float64(summary.TotalSalesAmount)); err != nil {
return err return err
} }
} }
@@ -333,30 +332,3 @@ func safeMarketingExportText(value string) string {
return trimmed return trimmed
} }
// formatMarketingRupiah formats a float64 as Indonesian Rupiah string.
// e.g. 1000000 → "Rp 1.000.000"
func formatMarketingRupiah(value float64) string {
rounded := int64(math.Round(value))
negative := rounded < 0
abs := rounded
if negative {
abs = -rounded
}
numStr := strconv.FormatInt(abs, 10)
n := len(numStr)
var b strings.Builder
for i, c := range numStr {
if i > 0 && (n-i)%3 == 0 {
b.WriteByte('.')
}
b.WriteRune(c)
}
if negative {
return "Rp -" + b.String()
}
return "Rp " + b.String()
}
@@ -15,13 +15,16 @@ import (
type DebtSupplierRepository interface { type DebtSupplierRepository interface {
GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error)
GetSuppliersWithDebts(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error)
GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error)
GetExpensesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Expense, error)
GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error)
GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error)
GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error)
GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error) GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error)
GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
GetExpenseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
} }
type debtSupplierRepositoryImpl struct { type debtSupplierRepositoryImpl struct {
@@ -490,3 +493,218 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Cont
return result, nil return result, nil
} }
func (r *debtSupplierRepositoryImpl) latestExpenseApproval(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).
Table("approvals AS a").
Select("a.approvable_id, a.step_number, a.action").
Joins(`
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = ?
GROUP BY approvable_id
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
string(utils.ApprovalWorkflowExpense),
)
}
func (r *debtSupplierRepositoryImpl) baseExpenseSupplierIDs(ctx context.Context, filters *validation.DebtSupplierQuery) *gorm.DB {
db := r.db.WithContext(ctx).
Table("expenses").
Select("DISTINCT expenses.supplier_id").
Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)).
Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("expenses.deleted_at IS NULL")
if len(filters.SupplierIDs) > 0 {
db = db.Where("expenses.supplier_id IN ?", filters.SupplierIDs)
}
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("expenses.location_id IN ?", filters.AllowedLocationIDs)
}
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Joins("JOIN locations exp_loc ON exp_loc.id = expenses.location_id").
Where("exp_loc.area_id IN ?", filters.AllowedAreaIDs)
}
}
if filters.StartDate != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where("DATE(expenses.transaction_date) >= ?", dateFrom)
}
}
if filters.EndDate != "" {
if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil {
db = db.Where("DATE(expenses.transaction_date) <= ?", dateTo)
}
}
return db
}
func (r *debtSupplierRepositoryImpl) GetSuppliersWithDebts(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) {
purchaseSubquery := r.baseSupplierQuery(ctx, filters).
Select("suppliers.id")
expenseSubquery := r.baseExpenseSupplierIDs(ctx, filters)
db := r.db.WithContext(ctx).
Model(&entity.Supplier{}).
Where("suppliers.id IN (? UNION ?) AND suppliers.deleted_at IS NULL",
purchaseSubquery, expenseSubquery)
var totalSuppliers int64
if err := db.Distinct("suppliers.id").Count(&totalSuppliers).Error; err != nil {
return nil, 0, err
}
if totalSuppliers == 0 {
return []entity.Supplier{}, 0, nil
}
if offset < 0 {
offset = 0
}
type supplierIDResult struct {
ID uint `gorm:"column:id"`
Name string `gorm:"column:name"`
}
var idResults []supplierIDResult
if err := r.db.WithContext(ctx).
Model(&entity.Supplier{}).
Where("suppliers.id IN (? UNION ?) AND suppliers.deleted_at IS NULL",
purchaseSubquery, expenseSubquery).
Select("suppliers.id, suppliers.name").
Group("suppliers.id, suppliers.name").
Order(resolveDebtSupplierSortClause(filters)).
Offset(offset).
Limit(limit).
Scan(&idResults).Error; err != nil {
return nil, 0, err
}
supplierIDs := make([]uint, 0, len(idResults))
for _, r := range idResults {
supplierIDs = append(supplierIDs, r.ID)
}
if len(supplierIDs) == 0 {
return []entity.Supplier{}, totalSuppliers, nil
}
var suppliers []entity.Supplier
if err := r.db.WithContext(ctx).
Where("id IN ?", supplierIDs).
Order(resolveDebtSupplierSortClause(filters)).
Find(&suppliers).Error; err != nil {
return nil, 0, err
}
return suppliers, totalSuppliers, nil
}
func (r *debtSupplierRepositoryImpl) GetExpensesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Expense, error) {
if len(supplierIDs) == 0 {
return []entity.Expense{}, nil
}
db := r.db.WithContext(ctx).
Model(&entity.Expense{}).
Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)).
Where("expenses.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("expenses.deleted_at IS NULL")
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("expenses.location_id IN ?", filters.AllowedLocationIDs)
}
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Joins("JOIN locations exp_loc ON exp_loc.id = expenses.location_id").
Where("exp_loc.area_id IN ?", filters.AllowedAreaIDs)
}
}
if filters.StartDate != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where("DATE(expenses.transaction_date) >= ?", dateFrom)
}
}
if filters.EndDate != "" {
if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil {
db = db.Where("DATE(expenses.transaction_date) <= ?", dateTo)
}
}
var expenses []entity.Expense
if err := db.
Preload("Supplier").
Preload("Nonstocks").
Preload("Location").
Preload("Location.Area").
Order("expenses.transaction_date ASC, expenses.id ASC").
Find(&expenses).Error; err != nil {
return nil, err
}
return expenses, nil
}
func (r *debtSupplierRepositoryImpl) GetExpenseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) {
if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" {
return map[uint]float64{}, nil
}
dateFrom, err := utils.ParseDateString(filters.StartDate)
if err != nil {
return map[uint]float64{}, nil
}
type expenseTotalRow struct {
SupplierID uint `gorm:"column:supplier_id"`
Total float64 `gorm:"column:total"`
}
rows := make([]expenseTotalRow, 0)
if err := r.db.WithContext(ctx).
Table("expenses").
Select("expenses.supplier_id AS supplier_id, SUM(en.qty * en.price) AS total").
Joins("JOIN expense_nonstocks en ON en.expense_id = expenses.id").
Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)).
Where("expenses.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("expenses.deleted_at IS NULL").
Where("DATE(expenses.transaction_date) < ?", dateFrom).
Group("expenses.supplier_id").
Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]float64, len(rows))
for _, row := range rows {
result[row.SupplierID] = row.Total
}
return result, nil
}
@@ -1782,7 +1782,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
offset = 0 offset = 0
} }
suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithPurchases(c.Context(), offset, params.Limit, params) suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithDebts(c.Context(), offset, params.Limit, params)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -1807,11 +1807,21 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
return nil, 0, err return nil, 0, err
} }
expenses, err := s.DebtSupplierRepo.GetExpensesBySuppliers(c.Context(), supplierIDs, params)
if err != nil {
return nil, 0, err
}
purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs))
for _, purchase := range purchases { for _, purchase := range purchases {
purchasesBySupplier[purchase.SupplierId] = append(purchasesBySupplier[purchase.SupplierId], purchase) purchasesBySupplier[purchase.SupplierId] = append(purchasesBySupplier[purchase.SupplierId], purchase)
} }
expensesBySupplier := make(map[uint][]entity.Expense, len(supplierIDs))
for _, exp := range expenses {
expensesBySupplier[uint(exp.SupplierId)] = append(expensesBySupplier[uint(exp.SupplierId)], exp)
}
paymentsBySupplier := make(map[uint][]entity.Payment, len(supplierIDs)) paymentsBySupplier := make(map[uint][]entity.Payment, len(supplierIDs))
for _, payment := range payments { for _, payment := range payments {
paymentsBySupplier[payment.PartyId] = append(paymentsBySupplier[payment.PartyId], payment) paymentsBySupplier[payment.PartyId] = append(paymentsBySupplier[payment.PartyId], payment)
@@ -1827,6 +1837,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
return nil, 0, err return nil, 0, err
} }
initialExpenseTotals, err := s.DebtSupplierRepo.GetExpenseTotalsBeforeDate(c.Context(), supplierIDs, params)
if err != nil {
return nil, 0, err
}
initialBalanceTotals, err := s.DebtSupplierRepo.GetInitialBalanceTotals(c.Context(), supplierIDs) initialBalanceTotals, err := s.DebtSupplierRepo.GetInitialBalanceTotals(c.Context(), supplierIDs)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -1847,10 +1862,10 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
CountTotals bool CountTotals bool
} }
type debtSupplierAllocation struct { type debtSupplierAllocation struct {
RowIndex int RowIndex int
SortTime time.Time SortTime time.Time
Amount float64 Amount float64
Purchase entity.Purchase CalcAging func(endDate time.Time) int
} }
type paymentAllocation struct { type paymentAllocation struct {
Date time.Time Date time.Time
@@ -1863,7 +1878,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
continue continue
} }
initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]) initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] - initialExpenseTotals[supplierID])
items := purchasesBySupplier[supplierID] items := purchasesBySupplier[supplierID]
paymentItems := paymentsBySupplier[supplierID] paymentItems := paymentsBySupplier[supplierID]
total := dto.DebtSupplierTotalDTO{} total := dto.DebtSupplierTotalDTO{}
@@ -1881,11 +1896,32 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
DeltaBalance: -row.TotalPrice, DeltaBalance: -row.TotalPrice,
CountTotals: true, CountTotals: true,
}) })
capturedPurchase := purchase
purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{ purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{
RowIndex: rowIndex, RowIndex: rowIndex,
SortTime: sortTime, SortTime: sortTime,
Amount: row.TotalPrice, Amount: row.TotalPrice,
Purchase: purchase, CalcAging: func(endDate time.Time) int { return calculateDebtSupplierAging(capturedPurchase, endDate, location) },
})
}
for _, exp := range expensesBySupplier[supplierID] {
row := buildDebtSupplierExpenseRow(exp, now, location)
sortTime := exp.TransactionDate.In(location)
rowIndex := len(combinedRows)
combinedRows = append(combinedRows, debtSupplierRowItem{
Row: row,
SortTime: sortTime,
Order: 0,
DeltaBalance: -row.TotalPrice,
CountTotals: true,
})
capturedExp := exp
purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{
RowIndex: rowIndex,
SortTime: sortTime,
Amount: row.TotalPrice,
CalcAging: func(endDate time.Time) int { return calculateExpenseAging(capturedExp, endDate, location) },
}) })
} }
@@ -1950,7 +1986,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
if remaining[purchaseIndex] <= 0.000001 { if remaining[purchaseIndex] <= 0.000001 {
allocation := purchaseAllocations[purchaseIndex] allocation := purchaseAllocations[purchaseIndex]
combinedRows[allocation.RowIndex].Row.Status = "Lunas" combinedRows[allocation.RowIndex].Row.Status = "Lunas"
combinedRows[allocation.RowIndex].Row.Aging = calculateDebtSupplierAging(allocation.Purchase, pay.Date, location) combinedRows[allocation.RowIndex].Row.Aging = allocation.CalcAging(pay.Date)
purchaseIndex++ purchaseIndex++
} }
} }
@@ -2224,6 +2260,62 @@ func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Locatio
return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc) return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc)
} }
func buildDebtSupplierExpenseRow(exp entity.Expense, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO {
txDate := exp.TransactionDate.In(loc)
dateStr := txDate.Format("2006-01-02")
startDay := time.Date(txDate.Year(), txDate.Month(), txDate.Day(), 0, 0, 0, 0, loc)
endDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
aging := 0
if !startDay.IsZero() && !endDay.Before(startDay) {
aging = int(endDay.Sub(startDay).Hours() / 24)
}
totalPrice := 0.0
for _, ns := range exp.Nonstocks {
totalPrice += ns.Qty * ns.Price
}
var area *areaDTO.AreaRelationDTO
if exp.Location != nil && exp.Location.Area.Id != 0 {
mapped := areaDTO.ToAreaRelationDTO(exp.Location.Area)
area = &mapped
}
poNumber := ""
if strings.TrimSpace(exp.PoNumber) != "" {
poNumber = exp.PoNumber
}
return dto.DebtSupplierRowDTO{
PrNumber: exp.ReferenceNumber,
PoNumber: poNumber,
PoDate: dateStr,
ReceivedDate: dateStr,
Aging: aging,
Area: area,
Warehouse: nil,
DueDate: "-",
DueStatus: "-",
TotalPrice: totalPrice,
PaymentPrice: 0,
DebtPrice: 0,
Status: "Belum Lunas",
TravelNumber: "-",
Balance: 0,
}
}
func calculateExpenseAging(exp entity.Expense, endDate time.Time, loc *time.Location) int {
start := exp.TransactionDate.In(loc)
startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc)
stopDay := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc)
if stopDay.Before(startDay) {
return 0
}
return int(stopDay.Sub(startDay).Hours() / 24)
}
func (s *repportService) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) { func (s *repportService) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())