diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index e15f3247..5b813ae7 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -98,6 +98,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context return db. Preload("Expense"). Preload("Expense.Supplier"). + Preload("Expense.Location"). Preload("Kandang"). Preload("Kandang.Location"). Preload("Nonstock"). diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go index 200f7c54..d8c48872 100644 --- a/internal/modules/finance/transactions/controllers/transaction.controller.go +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" @@ -13,6 +14,8 @@ import ( "github.com/gofiber/fiber/v2" ) +const transactionExcelExportFetchLimit = 99999999 + type TransactionController struct { 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") } + 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) if err != nil { 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 { param := c.Params("id") diff --git a/internal/modules/finance/transactions/controllers/transaction.export.go b/internal/modules/finance/transactions/controllers/transaction.export.go new file mode 100644 index 00000000..4bbfbd94 --- /dev/null +++ b/internal/modules/finance/transactions/controllers/transaction.export.go @@ -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 + } +} diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go index 4b6d7c5a..93750e3d 100644 --- a/internal/modules/finance/transactions/validations/transaction.validation.go +++ b/internal/modules/finance/transactions/validations/transaction.validation.go @@ -10,7 +10,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"` BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"` diff --git a/internal/modules/marketing/controllers/deliveryorder.export.go b/internal/modules/marketing/controllers/deliveryorder.export.go index 48751929..7d26191a 100644 --- a/internal/modules/marketing/controllers/deliveryorder.export.go +++ b/internal/modules/marketing/controllers/deliveryorder.export.go @@ -2,7 +2,6 @@ package controller import ( "fmt" - "math" "strconv" "strings" "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 { 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 } 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 } -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) diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index 9906523e..5cc1dd10 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -91,11 +91,9 @@ func buildPurchaseQuery(c *fiber.Ctx) *validation.Query { 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")), + StartDate: strings.TrimSpace(c.Query("start_date")), + EndDate: strings.TrimSpace(c.Query("end_date")), + FilterBy: strings.TrimSpace(c.Query("filter_by")), SupplierID: uint(c.QueryInt("supplier_id", 0)), AreaID: uint(c.QueryInt("area_id", 0)), LocationID: uint(c.QueryInt("location_id", 0)), diff --git a/internal/modules/purchases/controllers/purchase.export.go b/internal/modules/purchases/controllers/purchase.export.go index 046291df..44b2c8eb 100644 --- a/internal/modules/purchases/controllers/purchase.export.go +++ b/internal/modules/purchases/controllers/purchase.export.go @@ -2,7 +2,6 @@ package controller import ( "fmt" - "math" "strconv" "strings" "time" @@ -43,15 +42,13 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) { } } - 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, purchases, grandTotals); err != nil { + if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases); err != nil { return nil, err } if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{ @@ -80,9 +77,17 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error { "F": 22, "G": 22, "H": 32, - "I": 18, - "J": 18, - "K": 24, + "I": 10, + "J": 12, + "K": 16, + "L": 16, + "M": 22, + "N": 12, + "O": 16, + "P": 16, + "Q": 18, + "R": 18, + "S": 24, } 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 { headers := []string{ - "PR Number", - "PO Number", - "Tanggal PO", - "Tanggal Terima", - "Supplier", - "Lokasi", - "Gudang", - "Product", - "Status", - "Grand Total", - "Notes", + "PR Number", // A + "PO Number", // B + "Tanggal PO", // C + "Tanggal Terima", // D + "Supplier", // E + "Lokasi", // F + "Gudang", // G + "Product", // H + "Qty", // I + "Satuan", // J + "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 { @@ -137,34 +150,36 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error { 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 { return nil } + var sumL, sumP, sumQ float64 + rowIdx := 2 for p := range purchases { purchase := &purchases[p] - total := grandTotals[purchase.Id] 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 } rowIdx++ continue } 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 } rowIdx++ } } - lastRow := rowIdx - 1 + lastDataRow := rowIdx - 1 + dataStyle, err := file.NewStyle(&excelize.Style{ Alignment: &excelize.Alignment{ Horizontal: "left", @@ -181,7 +196,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity if err != nil { 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 } @@ -200,14 +215,17 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity if err != nil { 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) - // 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 { 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 { 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 } - if err := file.SetCellValue(sheet, "J"+row, formatPurchaseRupiah(grandTotal)); err != nil { - return err - } - if err := file.SetCellValue(sheet, "K"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil { + if err := file.SetCellValue(sheet, "S"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil { return err } - // Item-level columns 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 { 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 } + // 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 { 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 { 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 } -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 +func addPurchaseExportSumRow(file *excelize.File, sheet string, rowIdx int, sumL, sumP, sumQ float64) error { + row := strconv.Itoa(rowIdx) + + sumStyle, 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: "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 { @@ -296,6 +404,24 @@ func safePurchaseItemProductName(item *entity.PurchaseItem) string { 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 { if purchase.LatestApproval == nil { return "-" @@ -309,6 +435,21 @@ func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string { return safePurchaseExportText(purchase.LatestApproval.StepName) } +var purchaseIndonesianMonths = map[time.Month]string{ + time.January: "Jan", + time.February: "Feb", + time.March: "Mar", + time.April: "Apr", + time.May: "Mei", + time.June: "Jun", + time.July: "Jul", + time.August: "Ags", + time.September: "Sep", + time.October: "Okt", + time.November: "Nov", + time.December: "Des", +} + func formatPurchaseExportDate(value *time.Time) string { if value == nil || value.IsZero() { return "-" @@ -320,7 +461,8 @@ func formatPurchaseExportDate(value *time.Time) string { t = t.In(location) } - return t.Format("02-01-2006") + month := purchaseIndonesianMonths[t.Month()] + return fmt.Sprintf("%d-%s-%02d", t.Day(), month, t.Year()%100) } func safePurchaseExportPointerText(value *string) string { @@ -338,37 +480,3 @@ func safePurchaseExportText(value string) string { 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() -} diff --git a/internal/modules/purchases/controllers/purchase.export_test.go b/internal/modules/purchases/controllers/purchase.export_test.go index d93bf108..fad9a84f 100644 --- a/internal/modules/purchases/controllers/purchase.export_test.go +++ b/internal/modules/purchases/controllers/purchase.export_test.go @@ -22,9 +22,8 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) { nil, "catatan", []entity.PurchaseItem{ - buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"), - buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"), - buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""), + buildPurchaseItemForExportTest(11, "Pakan Starter", 500, 2, 1000000, "Location A", "kg"), + buildPurchaseItemForExportTest(12, "Vitamin A", 350, 1, 350000, "Location B", "botol"), }, ), buildPurchaseForExportTest( @@ -37,7 +36,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) { ptrApprovalAction(entity.ApprovalActionRejected), "", []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() + // Verify all 19 headers expectedHeaders := map[string]string{ "A1": "PR Number", "B1": "PO Number", "C1": "Tanggal PO", - "D1": "Supplier", - "E1": "Lokasi", - "F1": "Status", - "G1": "Grand Total", - "H1": "Products", - "I1": "Notes", + "D1": "Tanggal Terima", + "E1": "Supplier", + "F1": "Lokasi", + "G1": "Gudang", + "H1": "Product", + "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 { 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, "B2", "PO-00011") assertPurchaseCellEquals(t, file, "C2", "22-04-2026") - assertPurchaseCellEquals(t, file, "D2", "Supplier A") - assertPurchaseCellEquals(t, file, "E2", "Location A") - assertPurchaseCellEquals(t, file, "F2", "Manager Purchase") - assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000") - assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A") - assertPurchaseCellEquals(t, file, "I2", "catatan") + assertPurchaseCellEquals(t, file, "E2", "Supplier A") + assertPurchaseCellEquals(t, file, "F2", "Location A") + assertPurchaseCellEquals(t, file, "H2", "Pakan Starter") + assertPurchaseCellEquals(t, file, "J2", "kg") + assertPurchaseCellEquals(t, file, "K2", "500") + 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") - assertPurchaseCellEquals(t, file, "B3", "-") - assertPurchaseCellEquals(t, file, "C3", "-") - assertPurchaseCellEquals(t, file, "E3", "-") - assertPurchaseCellEquals(t, file, "F3", "Ditolak") - assertPurchaseCellEquals(t, file, "G3", "Rp 75.000") - assertPurchaseCellEquals(t, file, "H3", "Obat X") - assertPurchaseCellEquals(t, file, "I3", "-") + // Row 3: Purchase 1, Item 2 (Vitamin A) + assertPurchaseCellEquals(t, file, "A3", "PR-00011") + assertPurchaseCellEquals(t, file, "H3", "Vitamin A") + assertPurchaseCellEquals(t, file, "J3", "botol") + assertPurchaseCellEquals(t, file, "L3", "350000") + assertPurchaseCellEquals(t, file, "Q3", "350000") + + // 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) { @@ -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{ ProductId: productID, + Price: price, + TotalQty: totalQty, TotalPrice: totalPrice, Product: &entity.Product{ Id: productID, Name: productName, + Uom: entity.Uom{Id: uomID, Name: uomName}, }, } diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index fd1c859a..25336562 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -32,12 +32,15 @@ type PurchaseListDTO struct { RequesterName string `json:"requester_name"` PoExpedition []PoExpeditionDTO `json:"po_expedition"` Items []PurchaseItemDTO `json:"items"` - Products []productDTO.ProductRelationDTO `json:"products"` - Location *locationDTO.LocationRelationDTO `json:"location"` - Area *areaDTO.AreaRelationDTO `json:"area"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` + Products []productDTO.ProductRelationDTO `json:"products"` + Location *locationDTO.LocationRelationDTO `json:"location"` + Area *areaDTO.AreaRelationDTO `json:"area"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + 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 { @@ -69,6 +72,8 @@ type PurchaseItemDTO struct { VehicleNumber *string `json:"vehicle_number"` TransportPerItem *float64 `json:"transport_per_item,omitempty"` ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` + ExpeditionQty float64 `json:"expedition_qty"` + ExpeditionTotal float64 `json:"expedition_total"` HasChickin bool `json:"has_chickin"` } @@ -127,6 +132,8 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { if item.ExpenseNonstock != nil { priceCopy := item.ExpenseNonstock.Price dto.TransportPerItem = &priceCopy + dto.ExpeditionQty = item.ExpenseNonstock.Qty + dto.ExpeditionTotal = item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price if item.ExpenseNonstock.Expense != nil { exp := item.ExpenseNonstock.Expense @@ -173,15 +180,21 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { } var ( - poExpedition = make([]PoExpeditionDTO, 0) - location *locationDTO.LocationRelationDTO - area *areaDTO.AreaRelationDTO - receivedDate *time.Time + poExpedition = make([]PoExpeditionDTO, 0) + location *locationDTO.LocationRelationDTO + area *areaDTO.AreaRelationDTO + receivedDate *time.Time + productsTotal float64 + expeditionTotal float64 ) productMap := make(map[uint]productDTO.ProductRelationDTO) expeditionRefSet := make(map[uint64]struct{}) for i := range p.Items { 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 _, exists := productMap[item.Product.Id]; !exists { productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product) @@ -235,6 +248,9 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, LatestApproval: latestApproval, + ProductsTotal: productsTotal, + ExpeditionTotal: expeditionTotal, + GrandTotalAll: productsTotal + expeditionTotal, } } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index a0a5284b..8c13d606 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -145,33 +145,16 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti 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") if err != nil { return nil, 0, utils.BadRequest(err.Error()) } - var poDateStart *time.Time - var poDateEnd *time.Time - - 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()) - } + dateStart, dateEnd, err := parsePurchaseDateRangeForQuery(params.StartDate, params.EndDate, "date") + if err != nil { + return nil, 0, utils.BadRequest(err.Error()) } + filterBy := strings.TrimSpace(params.FilterBy) search := strings.ToLower(strings.TrimSpace(params.Search)) 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) } - if createdFrom != nil { - db = db.Where("created_at >= ?", *createdFrom) - } - - if createdTo != nil { - db = db.Where("created_at < ?", *createdTo) - } - if poDateStart != nil { - db = db.Where("purchases.po_date >= ?", *poDateStart) - } - - if poDateStart != nil { - db = db.Where("purchases.po_date >= ?", *poDateStart) - } - - if poDateEnd != nil { - db = db.Where("purchases.po_date < ?", *poDateEnd) + switch filterBy { + case "po_date": + if dateStart != nil { + db = db.Where("purchases.po_date >= ?", *dateStart) + } + if dateEnd != nil { + db = db.Where("purchases.po_date < ?", *dateEnd) + } + case "due_date": + if dateStart != nil { + db = db.Where("purchases.due_date >= ?", *dateStart) + } + if dateEnd != nil { + db = db.Where("purchases.due_date < ?", *dateEnd) + } + case "received_date": + 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 { @@ -263,6 +264,14 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti sortBy := strings.TrimSpace(params.SortBy) sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder)) + + if sortBy == "" && (filterBy == "po_date" || filterBy == "due_date" || filterBy == "received_date" || filterBy == "created_at") { + sortBy = filterBy + if sortOrder == "" { + sortOrder = "ASC" + } + } + if sortOrder == "" { sortOrder = "DESC" } @@ -2238,30 +2247,36 @@ func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []ent return nil } -func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) { +func parsePurchaseDateRangeForQuery(fromStr, toStr, fieldName string) (*time.Time, *time.Time, error) { + jakartaLoc, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + jakartaLoc = time.FixedZone("WIB", 7*60*60) + } + var fromPtr *time.Time var toPtr *time.Time if strings.TrimSpace(fromStr) != "" { parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr)) 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 = &fromValue + t := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, jakartaLoc) + fromPtr = &t } if strings.TrimSpace(toStr) != "" { parsed, err := utils.ParseDateString(strings.TrimSpace(toStr)) 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) + t := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, jakartaLoc) + nextDay := t.AddDate(0, 0, 1) toPtr = &nextDay } 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 diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 1f49eaca..f891cc81 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -75,12 +75,10 @@ type Query struct { ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` - PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` - PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` - PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" 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"` - 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"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"` } diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index cc505ee8..7a66f247 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -392,6 +392,13 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error { return err } + if isDebtSupplierExcelExportRequest(ctx) { + return exportDebtSupplierExcel(ctx, result) + } + if isDebtSupplierExcelAllExportRequest(ctx) { + return exportDebtSupplierExcelAll(ctx, result) + } + supplierIDs = query.SupplierIDs if supplierIDs == nil { supplierIDs = []int64{} @@ -478,6 +485,13 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { return err } + if isCustomerPaymentExcelExportRequest(ctx) { + return exportCustomerPaymentExcel(ctx, result) + } + if isCustomerPaymentExcelAllExportRequest(ctx) { + return exportCustomerPaymentExcelAll(ctx, result) + } + // If single customer mode (only 1 customer ID), return without pagination if len(customerIDs) == 1 { return ctx.Status(fiber.StatusOK). @@ -505,6 +519,83 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { }) } +type BalanceMonitoringResponse struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta response.Meta `json:"meta"` + Data []dto.BalanceMonitoringRowDTO `json:"data"` + Totals dto.BalanceMonitoringTotalsDTO `json:"totals"` +} + +func (c *RepportController) GetBalanceMonitoring(ctx *fiber.Ctx) error { + customerIDs, err := parseUintCSV(ctx.Query("customer_ids")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "customer_ids must be comma separated positive integers") + } + salesIDs, err := parseUintCSV(ctx.Query("sales_ids")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "sales_ids must be comma separated positive integers") + } + + query := &validation.BalanceMonitoringQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + CustomerIDs: customerIDs, + SalesIDs: salesIDs, + FilterBy: strings.ToLower(ctx.Query("filter_by", "")), + SortBy: ctx.Query("sort_by", ""), + SortOrder: ctx.Query("sort_order", ""), + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + } + + result, totals, totalResults, err := c.RepportService.GetBalanceMonitoring(ctx, query) + if err != nil { + return err + } + + limit := query.Limit + if limit < 1 { + limit = 10 + } + + return ctx.Status(fiber.StatusOK).JSON(BalanceMonitoringResponse{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get balance monitoring report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(limit))), + TotalResults: totalResults, + }, + Data: result, + Totals: totals, + }) +} + +func parseUintCSV(raw string) ([]uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + result := make([]uint, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseUint(part, 10, 32) + if err != nil || id == 0 { + return nil, fmt.Errorf("invalid id: %s", part) + } + result = append(result, uint(id)) + } + return result, nil +} + func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { idParam := ctx.Params("idProjectFlockKandang") if idParam == "" { diff --git a/internal/modules/repports/controllers/repport.customer_payment.export.go b/internal/modules/repports/controllers/repport.customer_payment.export.go new file mode 100644 index 00000000..55dbd667 --- /dev/null +++ b/internal/modules/repports/controllers/repport.customer_payment.export.go @@ -0,0 +1,576 @@ +package controller + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" +) + +func isCustomerPaymentExcelExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") +} + +func isCustomerPaymentExcelAllExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all") +} + +func exportCustomerPaymentExcel(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error { + content, err := buildCustomerPaymentWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-%s.xlsx", time.Now().Format("2006-01-02-1504")) + 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 exportCustomerPaymentExcelAll(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error { + content, err := buildCustomerPaymentAllWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-all-%s.xlsx", time.Now().Format("2006-01-02-1504")) + 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 buildCustomerPaymentWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + + if len(items) == 0 { + if err := writeCustomerPaymentSheet(file, defaultSheet, dto.CustomerPaymentReportItem{}); err != nil { + return nil, err + } + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil + } + + for idx, item := range items { + sheetName := sanitizeCustomerPaymentSheetName(customerPaymentName(item)) + if sheetName == "" { + sheetName = fmt.Sprintf("Customer %d", idx+1) + } + + 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 := writeCustomerPaymentSheet(file, sheetName, item); err != nil { + return nil, err + } + } + + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func buildCustomerPaymentAllWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + const sheet = "Kontrol Pembayaran Customer" + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != sheet { + if err := file.SetSheetName(defaultSheet, sheet); err != nil { + return nil, err + } + } + + if err := setCustomerPaymentAllColumns(file, sheet); err != nil { + return nil, err + } + if err := setCustomerPaymentAllHeaders(file, sheet); err != nil { + return nil, err + } + if err := writeCustomerPaymentAllRows(file, sheet, items); err != nil { + return nil, err + } + if err := file.SetPanes(sheet, &excelize.Panes{ + Freeze: true, + YSplit: 1, + TopLeftCell: "A2", + ActivePane: "bottomLeft", + }); err != nil { + return nil, err + } + + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +var cpSheetHeaders = []string{ + "No", + "Tanggal DO/Bayar", + "Tanggal Realisasi", + "Aging", + "Referensi", + "Nomor Polisi", + "Ekor/Qty", + "Berat (Kg)", + "AVG", + "Harga/Unit (Rp)", + "Harga Akhir (Rp)", + "Total (Rp)", + "Pembayaran (Rp)", + "Saldo Piutang (Rp)", + "Keterangan", + "Pengambilan", + "Sales/Marketing", +} + +var cpAllSheetHeaders = append([]string{"Customer"}, cpSheetHeaders...) + +var cpSheetColumnWidths = map[string]float64{ + "A": 5, + "B": 15, + "C": 12, + "D": 8, + "E": 12, + "F": 15, + "G": 10, + "H": 12, + "I": 10, + "J": 15, + "K": 15, + "L": 15, + "M": 15, + "N": 15, + "O": 20, + "P": 15, + "Q": 20, +} + +var cpAllSheetColumnWidths = map[string]float64{ + "A": 22, + "B": 6, + "C": 15, + "D": 15, + "E": 8, + "F": 12, + "G": 15, + "H": 10, + "I": 12, + "J": 10, + "K": 15, + "L": 15, + "M": 15, + "N": 15, + "O": 15, + "P": 20, + "Q": 15, + "R": 20, +} + +func writeCustomerPaymentSheet(file *excelize.File, sheet string, item dto.CustomerPaymentReportItem) error { + for col, width := range cpSheetColumnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + + // Row 1: headers + for i, h := range cpSheetHeaders { + col, _ := excelize.ColumnNumberToName(i + 1) + if err := file.SetCellValue(sheet, col+"1", h); err != nil { + return err + } + } + + redStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "FF0000"}, + }) + if err != nil { + return err + } + + // Row 2: saldo awal + if err := file.SetCellValue(sheet, "N2", item.InitialBalance); err != nil { + return err + } + if item.InitialBalance < 0 { + if err := file.SetCellStyle(sheet, "N2", "N2", redStyle); err != nil { + return err + } + } + + // Rows 3+: data rows + for i, row := range item.Rows { + rowNum := i + 3 + rowStr := fmt.Sprintf("%d", rowNum) + + cells := customerPaymentRowCells(row, i+1) + for colIdx, val := range cells { + col, _ := excelize.ColumnNumberToName(colIdx + 1) + if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { + return err + } + } + + if row.AccountsReceivable < 0 { + if err := file.SetCellStyle(sheet, "N"+rowStr, "N"+rowStr, redStyle); err != nil { + return err + } + } + } + + // Total row + totalRowNum := len(item.Rows) + 3 + totalRowStr := fmt.Sprintf("%d", totalRowNum) + + totalCells := map[string]interface{}{ + "A": "Total", + "G": formatCPIDInteger(item.Summary.TotalQty), + "H": formatCPIDInteger(item.Summary.TotalWeight), + "K": item.Summary.TotalFinalAmount, + "L": item.Summary.TotalGrandAmount, + "M": item.Summary.TotalPayment, + "N": item.Summary.TotalAccountsReceivable, + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { + return err + } + } + if item.Summary.TotalAccountsReceivable < 0 { + if err := file.SetCellStyle(sheet, "N"+totalRowStr, "N"+totalRowStr, redStyle); err != nil { + return err + } + } + + return nil +} + +func setCustomerPaymentAllColumns(file *excelize.File, sheet string) error { + for col, width := range cpAllSheetColumnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + return file.SetRowHeight(sheet, 1, 24) +} + +func setCustomerPaymentAllHeaders(file *excelize.File, sheet string) error { + borderStyle := []excelize.Border{ + {Type: "left", Color: "000000", Style: 1}, + {Type: "top", Color: "000000", Style: 1}, + {Type: "bottom", Color: "000000", Style: 1}, + {Type: "right", Color: "000000", Style: 1}, + } + headerStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + WrapText: true, + }, + Border: borderStyle, + }) + if err != nil { + return err + } + + for i, h := range cpAllSheetHeaders { + col, _ := excelize.ColumnNumberToName(i + 1) + if err := file.SetCellValue(sheet, col+"1", h); err != nil { + return err + } + } + + lastCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders)) + return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle) +} + +func writeCustomerPaymentAllRows(file *excelize.File, sheet string, items []dto.CustomerPaymentReportItem) error { + borderStyle := []excelize.Border{ + {Type: "left", Color: "000000", Style: 1}, + {Type: "top", Color: "000000", Style: 1}, + {Type: "bottom", Color: "000000", Style: 1}, + {Type: "right", Color: "000000", Style: 1}, + } + + dataStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + totalStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + redDataStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "FF0000", Family: "Arial", Size: 10}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + redTotalStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FF0000", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + lastHeaderCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders)) + currentRow := 2 + + for _, item := range items { + name := customerPaymentName(item) + + // Saldo awal row + saldoStr := fmt.Sprintf("%d", currentRow) + if err := file.SetCellValue(sheet, "A"+saldoStr, name); err != nil { + return err + } + if err := file.SetCellValue(sheet, "O"+saldoStr, item.InitialBalance); err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A"+saldoStr, lastHeaderCol+saldoStr, dataStyle); err != nil { + return err + } + if item.InitialBalance < 0 { + if err := file.SetCellStyle(sheet, "O"+saldoStr, "O"+saldoStr, redDataStyle); err != nil { + return err + } + } + currentRow++ + + // Data rows + for seq, row := range item.Rows { + rowStr := fmt.Sprintf("%d", currentRow) + if err := file.SetCellValue(sheet, "A"+rowStr, name); err != nil { + return err + } + cells := customerPaymentRowCells(row, seq+1) + for colIdx, val := range cells { + col, _ := excelize.ColumnNumberToName(colIdx + 2) + if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil { + return err + } + if row.AccountsReceivable < 0 { + if err := file.SetCellStyle(sheet, "O"+rowStr, "O"+rowStr, redDataStyle); err != nil { + return err + } + } + currentRow++ + } + + // Total row + totalStr := fmt.Sprintf("%d", currentRow) + totalCells := map[string]interface{}{ + "A": name, + "B": "Total", + "H": formatCPIDInteger(item.Summary.TotalQty), + "I": formatCPIDInteger(item.Summary.TotalWeight), + "L": item.Summary.TotalFinalAmount, + "M": item.Summary.TotalGrandAmount, + "N": item.Summary.TotalPayment, + "O": item.Summary.TotalAccountsReceivable, + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+totalStr, lastHeaderCol+totalStr, totalStyle); err != nil { + return err + } + if item.Summary.TotalAccountsReceivable < 0 { + if err := file.SetCellStyle(sheet, "O"+totalStr, "O"+totalStr, redTotalStyle); err != nil { + return err + } + } + currentRow++ + + // Empty separator row + currentRow++ + } + + return nil +} + +// customerPaymentRowCells returns 17 cell values for cols A..Q. +func customerPaymentRowCells(row dto.CustomerPaymentReportRow, seq int) []interface{} { + return []interface{}{ + seq, + formatCPDate(row.TransDate), + formatCPOptionalDate(row.DeliveryDate), + formatCPAging(row.AgingDay), + safeCPText(row.Reference), + joinCPStrings(row.VehicleNumbers), + formatCPIDInteger(row.Qty), + formatCPIDInteger(row.Weight), + formatCPAvg(row.AverageWeight), + row.UnitPrice, + row.FinalPrice, + row.TotalPrice, + row.PaymentAmount, + row.AccountsReceivable, + safeCPText(row.Status), + joinCPStrings(row.PickupInfo), + safeCPText(row.SalesPerson), + } +} + +func customerPaymentName(item dto.CustomerPaymentReportItem) string { + name := strings.TrimSpace(item.Customer.Name) + if name == "" { + return "Customer" + } + return name +} + +func sanitizeCustomerPaymentSheetName(name string) string { + replacer := strings.NewReplacer( + ":", " ", "\\", " ", "/", " ", + "?", " ", "*", " ", "[", " ", "]", " ", + ) + sanitized := strings.TrimSpace(replacer.Replace(name)) + if sanitized == "" { + return "Sheet" + } + runes := []rune(sanitized) + if len(runes) > 31 { + return string(runes[:31]) + } + return sanitized +} + +var cpIndonesianMonths = [12]string{ + "Jan", "Feb", "Mar", "Apr", "Mei", "Jun", + "Jul", "Agu", "Sep", "Okt", "Nov", "Des", +} + +func formatCPDate(t time.Time) string { + if t.IsZero() { + return "-" + } + loc, err := time.LoadLocation("Asia/Jakarta") + if err == nil { + t = t.In(loc) + } + return fmt.Sprintf("%02d %s %d", t.Day(), cpIndonesianMonths[t.Month()-1], t.Year()) +} + +func formatCPOptionalDate(t *time.Time) string { + if t == nil || t.IsZero() { + return "-" + } + return formatCPDate(*t) +} + +func formatCPAging(v *int) string { + if v == nil { + return "-" + } + return strconv.Itoa(*v) +} + +func formatCPIDInteger(v float64) string { + n := int64(math.Round(v)) + if n == 0 { + return "0" + } + negative := n < 0 + abs := n + if negative { + abs = -n + } + s := strconv.FormatInt(abs, 10) + // insert dots as thousand separators + var b strings.Builder + start := len(s) % 3 + if start == 0 { + start = 3 + } + b.WriteString(s[:start]) + for i := start; i < len(s); i += 3 { + b.WriteByte('.') + b.WriteString(s[i : i+3]) + } + if negative { + return "-" + b.String() + } + return b.String() +} + + +func formatCPAvg(v float64) string { + if v == 0 { + return "0" + } + s := strconv.FormatFloat(v, 'f', 2, 64) + return strings.ReplaceAll(s, ".", ",") +} + +func safeCPText(s string) string { + t := strings.TrimSpace(s) + if t == "" { + return "-" + } + return t +} + +func joinCPStrings(ss []string) string { + var parts []string + for _, s := range ss { + s = strings.TrimSpace(s) + if s != "" { + parts = append(parts, s) + } + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, "\n") +} diff --git a/internal/modules/repports/controllers/repport.debt_supplier.export.go b/internal/modules/repports/controllers/repport.debt_supplier.export.go new file mode 100644 index 00000000..37a8710b --- /dev/null +++ b/internal/modules/repports/controllers/repport.debt_supplier.export.go @@ -0,0 +1,452 @@ +package controller + +import ( + "fmt" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" +) + +func isDebtSupplierExcelExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") +} + +func isDebtSupplierExcelAllExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all") +} + +func exportDebtSupplierExcel(c *fiber.Ctx, items []dto.DebtSupplierDTO) error { + content, err := buildDebtSupplierWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-hutang-supplier-%s.xlsx", time.Now().Format("2006-01-02-1504")) + 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 exportDebtSupplierExcelAll(c *fiber.Ctx, items []dto.DebtSupplierDTO) error { + content, err := buildDebtSupplierAllWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-hutang-supplier-all-%s.xlsx", time.Now().Format("2006-01-02-1504")) + 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) +} + +// buildDebtSupplierWorkbook creates a workbook with one sheet per supplier. +func buildDebtSupplierWorkbook(items []dto.DebtSupplierDTO) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + + if len(items) == 0 { + if err := writeDebtSupplierSheet(file, defaultSheet, dto.DebtSupplierDTO{}); err != nil { + return nil, err + } + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil + } + + for idx, item := range items { + sheetName := sanitizeDebtSupplierSheetName(debtSupplierName(item)) + if sheetName == "" { + sheetName = fmt.Sprintf("Supplier %d", idx+1) + } + + 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 := writeDebtSupplierSheet(file, sheetName, item); err != nil { + return nil, err + } + } + + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// buildDebtSupplierAllWorkbook creates a single-sheet workbook with purchase-supplier styling. +func buildDebtSupplierAllWorkbook(items []dto.DebtSupplierDTO) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + const sheet = "Rekap Hutang Supplier" + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != sheet { + if err := file.SetSheetName(defaultSheet, sheet); err != nil { + return nil, err + } + } + + if err := setDebtSupplierAllColumns(file, sheet); err != nil { + return nil, err + } + if err := setDebtSupplierAllHeaders(file, sheet); err != nil { + return nil, err + } + if err := writeDebtSupplierAllRows(file, sheet, items); err != nil { + return nil, err + } + if err := file.SetPanes(sheet, &excelize.Panes{ + Freeze: true, + YSplit: 1, + TopLeftCell: "A2", + ActivePane: "bottomLeft", + }); err != nil { + return nil, err + } + + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +var debtSupplierSheetHeaders = []string{ + "No", + "Nomor PR", + "Nomor PO", + "Tanggal Terima/Bayar", + "Tanggal PO", + "Aging (Hari)", + "Area", + "Gudang", + "Jatuh Tempo", + "Status Jatuh Tempo", + "Nominal Pembelian (Rp)", + "Pembayaran (Rp)", + "Sisa Saldo Hutang (Rp)", + "Status", + "Nomor Perjalanan", +} + +var debtSupplierAllSheetHeaders = append([]string{"Supplier"}, debtSupplierSheetHeaders...) + +var debtSupplierSheetColumnWidths = map[string]float64{ + "A": 5, + "B": 14, + "C": 12, + "D": 20, + "E": 10, + "F": 12, + "G": 15, + "H": 20, + "I": 12, + "J": 20, + "K": 20, + "L": 15, + "M": 20, + "N": 12, + "O": 15, +} + +var debtSupplierAllSheetColumnWidths = map[string]float64{ + "A": 24, + "B": 6, + "C": 14, + "D": 14, + "E": 20, + "F": 12, + "G": 10, + "H": 16, + "I": 22, + "J": 12, + "K": 22, + "L": 20, + "M": 18, + "N": 22, + "O": 14, + "P": 18, +} + +func writeDebtSupplierSheet(file *excelize.File, sheet string, item dto.DebtSupplierDTO) error { + for col, width := range debtSupplierSheetColumnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + + // Row 1: headers + for i, h := range debtSupplierSheetHeaders { + col, _ := excelize.ColumnNumberToName(i + 1) + if err := file.SetCellValue(sheet, col+"1", h); err != nil { + return err + } + } + + // Row 2: saldo awal + if err := file.SetCellValue(sheet, "M2", item.InitialBalance); err != nil { + return err + } + + // Rows 3+: data + redStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "FF0000"}, + }) + if err != nil { + return err + } + + for i, row := range item.Rows { + rowNum := i + 3 + rowStr := fmt.Sprintf("%d", rowNum) + + values := debtSupplierRowCells(row, i+1) + for colIdx, val := range values { + col, _ := excelize.ColumnNumberToName(colIdx + 1) + if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { + return err + } + } + + if row.DebtPrice < 0 { + if err := file.SetCellStyle(sheet, "M"+rowStr, "M"+rowStr, redStyle); err != nil { + return err + } + } + } + + // Total row + totalRowNum := len(item.Rows) + 3 + totalRowStr := fmt.Sprintf("%d", totalRowNum) + totalCells := map[string]interface{}{ + "A": "Total", + "F": item.Total.Aging, + "K": item.Total.TotalPrice, + "L": item.Total.PaymentPrice, + "M": item.Total.DebtPrice, + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { + return err + } + } + if item.Total.DebtPrice < 0 { + if err := file.SetCellStyle(sheet, "M"+totalRowStr, "M"+totalRowStr, redStyle); err != nil { + return err + } + } + + return nil +} + +func setDebtSupplierAllColumns(file *excelize.File, sheet string) error { + for col, width := range debtSupplierAllSheetColumnWidths { + 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 setDebtSupplierAllHeaders(file *excelize.File, sheet string) error { + headerStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + WrapText: true, + }, + Border: []excelize.Border{ + {Type: "left", Color: "000000", Style: 1}, + {Type: "top", Color: "000000", Style: 1}, + {Type: "bottom", Color: "000000", Style: 1}, + {Type: "right", Color: "000000", Style: 1}, + }, + }) + if err != nil { + return err + } + + for i, h := range debtSupplierAllSheetHeaders { + col, _ := excelize.ColumnNumberToName(i + 1) + if err := file.SetCellValue(sheet, col+"1", h); err != nil { + return err + } + } + + lastCol, _ := excelize.ColumnNumberToName(len(debtSupplierAllSheetHeaders)) + return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle) +} + +func writeDebtSupplierAllRows(file *excelize.File, sheet string, items []dto.DebtSupplierDTO) error { + borderStyle := []excelize.Border{ + {Type: "left", Color: "000000", Style: 1}, + {Type: "top", Color: "000000", Style: 1}, + {Type: "bottom", Color: "000000", Style: 1}, + {Type: "right", Color: "000000", Style: 1}, + } + + dataStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + totalStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + lastHeaderCol, _ := excelize.ColumnNumberToName(len(debtSupplierAllSheetHeaders)) + + currentRow := 2 + for _, item := range items { + supplierName := debtSupplierName(item) + + // Saldo awal row + saldoRowStr := fmt.Sprintf("%d", currentRow) + if err := file.SetCellValue(sheet, "A"+saldoRowStr, supplierName); err != nil { + return err + } + if err := file.SetCellValue(sheet, "N"+saldoRowStr, item.InitialBalance); err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A"+saldoRowStr, lastHeaderCol+saldoRowStr, dataStyle); err != nil { + return err + } + currentRow++ + + // Data rows + for seq, row := range item.Rows { + rowStr := fmt.Sprintf("%d", currentRow) + if err := file.SetCellValue(sheet, "A"+rowStr, supplierName); err != nil { + return err + } + values := debtSupplierRowCells(row, seq+1) + for colIdx, val := range values { + col, _ := excelize.ColumnNumberToName(colIdx + 2) + if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil { + return err + } + currentRow++ + } + + // Total row + totalRowStr := fmt.Sprintf("%d", currentRow) + totalCells := map[string]interface{}{ + "A": supplierName, + "B": "Total", + "L": item.Total.TotalPrice, + "M": item.Total.PaymentPrice, + "N": item.Total.DebtPrice, + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+totalRowStr, lastHeaderCol+totalRowStr, totalStyle); err != nil { + return err + } + currentRow++ + + // Empty separator row + currentRow++ + } + + return nil +} + +// debtSupplierRowCells returns cell values for one data row (columns: No, PR, PO, ReceivedDate, PoDate, Aging, Area, Warehouse, DueDate, DueStatus, TotalPrice, PaymentPrice, DebtPrice, Status, TravelNumber). +func debtSupplierRowCells(row dto.DebtSupplierRowDTO, seq int) []interface{} { + areaName := "-" + if row.Area != nil && strings.TrimSpace(row.Area.Name) != "" { + areaName = row.Area.Name + } + warehouseName := "-" + if row.Warehouse != nil && strings.TrimSpace(row.Warehouse.Name) != "" { + warehouseName = row.Warehouse.Name + } + + return []interface{}{ + seq, + safeDebtSupplierText(row.PrNumber), + safeDebtSupplierText(row.PoNumber), + safeDebtSupplierText(row.ReceivedDate), + safeDebtSupplierText(row.PoDate), + row.Aging, + areaName, + warehouseName, + safeDebtSupplierText(row.DueDate), + safeDebtSupplierText(row.DueStatus), + row.TotalPrice, + row.PaymentPrice, + row.DebtPrice, + safeDebtSupplierText(row.Status), + safeDebtSupplierText(row.TravelNumber), + } +} + +func debtSupplierName(item dto.DebtSupplierDTO) string { + if item.Supplier != nil && strings.TrimSpace(item.Supplier.Name) != "" { + return item.Supplier.Name + } + return "Supplier" +} + +func sanitizeDebtSupplierSheetName(name string) string { + replacer := strings.NewReplacer( + ":", " ", "\\", " ", "/", " ", + "?", " ", "*", " ", "[", " ", "]", " ", + ) + sanitized := strings.TrimSpace(replacer.Replace(name)) + if sanitized == "" { + return "Sheet" + } + runes := []rune(sanitized) + if len(runes) > 31 { + return string(runes[:31]) + } + return sanitized +} + +func safeDebtSupplierText(s string) string { + t := strings.TrimSpace(s) + if t == "" { + return "-" + } + return t +} diff --git a/internal/modules/repports/controllers/repport.marketing.export.go b/internal/modules/repports/controllers/repport.marketing.export.go index 69a58643..5f52f3dc 100644 --- a/internal/modules/repports/controllers/repport.marketing.export.go +++ b/internal/modules/repports/controllers/repport.marketing.export.go @@ -2,7 +2,6 @@ package controller import ( "fmt" - "math" "strconv" "strings" "time" @@ -197,9 +196,9 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte item.Qty, item.AverageWeightKg, item.TotalWeightKg, - formatMarketingRupiah(item.SalesPricePerKg), - formatMarketingRupiah(item.HppPricePerKg), - formatMarketingRupiah(item.SalesAmount), + item.SalesPricePerKg, + item.HppPricePerKg, + item.SalesAmount, } 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 { 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 } - 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 } - 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 } } @@ -333,30 +332,3 @@ func safeMarketingExportText(value string) string { 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() -} diff --git a/internal/modules/repports/dto/repportBalanceMonitoring.dto.go b/internal/modules/repports/dto/repportBalanceMonitoring.dto.go new file mode 100644 index 00000000..8a63729b --- /dev/null +++ b/internal/modules/repports/dto/repportBalanceMonitoring.dto.go @@ -0,0 +1,71 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" +) + +type BalanceMonitoringAyamDTO struct { + Ekor float64 `json:"ekor"` + Kg float64 `json:"kg"` + Nominal float64 `json:"nominal"` +} + +type BalanceMonitoringTelurDTO struct { + Butir float64 `json:"butir"` + Kg float64 `json:"kg"` + Nominal float64 `json:"nominal"` +} + +type BalanceMonitoringTradingDTO struct { + Qty float64 `json:"qty"` + Kg float64 `json:"kg"` + Nominal float64 `json:"nominal"` +} + +type BalanceMonitoringRowDTO struct { + Customer customerDTO.CustomerRelationDTO `json:"customer"` + SaldoAwal float64 `json:"saldo_awal"` + PenjualanAyam BalanceMonitoringAyamDTO `json:"penjualan_ayam"` + PenjualanTelur BalanceMonitoringTelurDTO `json:"penjualan_telur"` + PenjualanTrading BalanceMonitoringTradingDTO `json:"penjualan_trading"` + Pembayaran float64 `json:"pembayaran"` + Aging int `json:"aging"` + AgingRataRata float64 `json:"aging_rata_rata"` + SaldoAkhir float64 `json:"saldo_akhir"` +} + +type BalanceMonitoringTotalsDTO struct { + SaldoAwal float64 `json:"saldo_awal"` + PenjualanAyam BalanceMonitoringAyamDTO `json:"penjualan_ayam"` + PenjualanTelur BalanceMonitoringTelurDTO `json:"penjualan_telur"` + PenjualanTrading BalanceMonitoringTradingDTO `json:"penjualan_trading"` + Pembayaran float64 `json:"pembayaran"` + Aging int `json:"aging"` + AgingRataRata float64 `json:"aging_rata_rata"` + SaldoAkhir float64 `json:"saldo_akhir"` +} + +func ToBalanceMonitoringRowDTO( + customer entity.Customer, + saldoAwal float64, + ayam BalanceMonitoringAyamDTO, + telur BalanceMonitoringTelurDTO, + trading BalanceMonitoringTradingDTO, + pembayaran float64, + aging int, + agingRataRata float64, +) BalanceMonitoringRowDTO { + saldoAkhir := saldoAwal + pembayaran - (ayam.Nominal + telur.Nominal + trading.Nominal) + return BalanceMonitoringRowDTO{ + Customer: customerDTO.ToCustomerRelationDTO(customer), + SaldoAwal: saldoAwal, + PenjualanAyam: ayam, + PenjualanTelur: telur, + PenjualanTrading: trading, + Pembayaran: pembayaran, + Aging: aging, + AgingRataRata: agingRataRata, + SaldoAkhir: saldoAkhir, + } +} diff --git a/internal/modules/repports/dto/repportExpense.dto.go b/internal/modules/repports/dto/repportExpense.dto.go index 00935929..6d26aa89 100644 --- a/internal/modules/repports/dto/repportExpense.dto.go +++ b/internal/modules/repports/dto/repportExpense.dto.go @@ -6,6 +6,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" 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" ) @@ -48,6 +49,7 @@ type RepportExpenseRealisasiDTO struct { type RepportExpenseListDTO struct { RepportExpenseBaseDTO + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` Pengajuan RepportExpensePengajuanDTO `json:"pengajuan"` Realisasi RepportExpenseRealisasiDTO `json:"realisasi"` @@ -133,6 +135,15 @@ func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNo totalRealisasi = ns.Realization.Qty * ns.Realization.Price } + var location *locationDTO.LocationRelationDTO + if ns.Expense != nil && ns.Expense.Location != nil && ns.Expense.Location.Id != 0 { + mapped := locationDTO.ToLocationRelationDTO(*ns.Expense.Location) + location = &mapped + } else if ns.Kandang != nil && ns.Kandang.Location.Id != 0 { + mapped := locationDTO.ToLocationRelationDTO(ns.Kandang.Location) + location = &mapped + } + // Get kandang data at the main level var kandang *kandangDTO.KandangRelationDTO if ns.Kandang != nil && ns.Kandang.Id != 0 { @@ -142,6 +153,7 @@ func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNo return RepportExpenseListDTO{ RepportExpenseBaseDTO: baseDTO, + Location: location, Kandang: kandang, Pengajuan: ToRepportExpensePengajuanDTO(ns), Realisasi: realisasi, diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 110bbc93..62f26794 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -40,6 +40,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) + balanceMonitoringRepository := repportRepo.NewBalanceMonitoringRepository(db) customerRepository := customerRepo.NewCustomerRepository(db) standardGrowthDetailRepository := productionStandardRepo.NewStandardGrowthDetailRepository(db) productionStandardDetailRepository := productionStandardRepo.NewProductionStandardDetailRepository(db) @@ -66,6 +67,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * hppPerKandangRepository, productionResultRepository, customerPaymentRepository, + balanceMonitoringRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository, diff --git a/internal/modules/repports/repositories/balance_monitoring.repository.go b/internal/modules/repports/repositories/balance_monitoring.repository.go new file mode 100644 index 00000000..7346a137 --- /dev/null +++ b/internal/modules/repports/repositories/balance_monitoring.repository.go @@ -0,0 +1,550 @@ +package repositories + +import ( + "context" + "fmt" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "gorm.io/gorm" +) + +type BalanceMonitoringCategoryRow struct { + CustomerID uint `gorm:"column:customer_id"` + AyamQty float64 `gorm:"column:ayam_qty"` + AyamKg float64 `gorm:"column:ayam_kg"` + AyamNominal float64 `gorm:"column:ayam_nominal"` + TelurQty float64 `gorm:"column:telur_qty"` + TelurKg float64 `gorm:"column:telur_kg"` + TelurNominal float64 `gorm:"column:telur_nominal"` + TradingQty float64 `gorm:"column:trading_qty"` + TradingKg float64 `gorm:"column:trading_kg"` + TradingNominal float64 `gorm:"column:trading_nominal"` +} + +type BalanceMonitoringAgingRow struct { + CustomerID uint `gorm:"column:customer_id"` + AgingMax int `gorm:"column:aging_max"` + AgingRataRata float64 `gorm:"column:aging_rata_rata"` +} + +type BalanceMonitoringGrandTotalsRow struct { + SaldoAwalLifetime float64 `gorm:"column:saldo_awal_lifetime"` + SalesBeforeStart float64 `gorm:"column:sales_before_start"` + PaymentBeforeStart float64 `gorm:"column:payment_before_start"` + AyamQty float64 `gorm:"column:ayam_qty"` + AyamKg float64 `gorm:"column:ayam_kg"` + AyamNominal float64 `gorm:"column:ayam_nominal"` + TelurQty float64 `gorm:"column:telur_qty"` + TelurKg float64 `gorm:"column:telur_kg"` + TelurNominal float64 `gorm:"column:telur_nominal"` + TradingQty float64 `gorm:"column:trading_qty"` + TradingKg float64 `gorm:"column:trading_kg"` + TradingNominal float64 `gorm:"column:trading_nominal"` + PaymentInPeriod float64 `gorm:"column:payment_in_period"` + AgingMax int `gorm:"column:aging_max"` + AgingRataRata float64 `gorm:"column:aging_rata_rata"` +} + +type BalanceMonitoringRepository interface { + GetCustomerIDsForBalanceMonitoring(ctx context.Context, offset, limit int, filters *validation.BalanceMonitoringQuery) ([]uint, int64, error) + GetAllFilteredCustomerIDs(ctx context.Context, filters *validation.BalanceMonitoringQuery) ([]uint, error) + GetSaldoAwalLifetime(ctx context.Context, customerIDs []uint) (map[uint]float64, error) + GetSalesTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) + GetPaymentTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) + GetSalesByCategoryInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringCategoryRow, error) + GetPaymentTotalsInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) + GetAgingPerCustomer(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringAgingRow, error) + GetGrandTotals(ctx context.Context, filters *validation.BalanceMonitoringQuery) (BalanceMonitoringGrandTotalsRow, error) +} + +type balanceMonitoringRepositoryImpl struct { + db *gorm.DB +} + +func NewBalanceMonitoringRepository(db *gorm.DB) BalanceMonitoringRepository { + return &balanceMonitoringRepositoryImpl{db: db} +} + +func resolveBalanceMonitoringDateColumn(filterBy string) string { + switch strings.ToLower(strings.TrimSpace(filterBy)) { + case "realized_at": + return "mdp.delivery_date" + case "sold_at", "": + return "m.so_date" + default: + return "m.so_date" + } +} + +func resolveBalanceMonitoringDateRange(filters *validation.BalanceMonitoringQuery) (time.Time, time.Time, error) { + var startDate time.Time + var endDate time.Time + var err error + + if strings.TrimSpace(filters.StartDate) != "" { + startDate, err = utils.ParseDateString(filters.StartDate) + if err != nil { + return time.Time{}, time.Time{}, err + } + } else { + startDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + if strings.TrimSpace(filters.EndDate) != "" { + endDate, err = utils.ParseDateString(filters.EndDate) + if err != nil { + return time.Time{}, time.Time{}, err + } + } else { + endDate = time.Now() + } + + return startDate, endDate, nil +} + +func resolveBalanceMonitoringSortClause(filters *validation.BalanceMonitoringQuery) string { + direction := "ASC" + if strings.EqualFold(strings.TrimSpace(filters.SortOrder), "desc") { + direction = "DESC" + } + switch strings.ToLower(strings.TrimSpace(filters.SortBy)) { + case "customer": + return "customers.name " + direction + default: + return "customers.name ASC" + } +} + +func (r *balanceMonitoringRepositoryImpl) baseCustomerQuery(ctx context.Context, filters *validation.BalanceMonitoringQuery) *gorm.DB { + db := r.db.WithContext(ctx). + Model(&entity.Customer{}). + Where("customers.deleted_at IS NULL") + + if len(filters.CustomerIDs) > 0 { + db = db.Where("customers.id IN ?", filters.CustomerIDs) + } + + if len(filters.SalesIDs) > 0 { + db = db.Where("EXISTS (SELECT 1 FROM marketings m WHERE m.customer_id = customers.id AND m.deleted_at IS NULL AND m.sales_person_id IN ?)", filters.SalesIDs) + } + + if filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil { + scopeSub := r.db.WithContext(ctx). + Table("marketings m"). + Select("1"). + Joins("JOIN marketing_products mp ON mp.marketing_id = m.id"). + Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). + Joins("JOIN product_warehouses pw ON pw.id = mdp.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("m.customer_id = customers.id"). + Where("m.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL") + + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + scopeSub = scopeSub.Where("w.area_id IN ?", filters.AllowedAreaIDs) + } + } + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + scopeSub = scopeSub.Where("w.location_id IN ?", filters.AllowedLocationIDs) + } + } + + db = db.Where("EXISTS (?)", scopeSub) + } + + return db +} + +func (r *balanceMonitoringRepositoryImpl) GetCustomerIDsForBalanceMonitoring(ctx context.Context, offset, limit int, filters *validation.BalanceMonitoringQuery) ([]uint, int64, error) { + var total int64 + if err := r.baseCustomerQuery(ctx, filters).Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return []uint{}, 0, nil + } + + if offset < 0 { + offset = 0 + } + + var customerIDs []uint + err := r.baseCustomerQuery(ctx, filters). + Order(resolveBalanceMonitoringSortClause(filters)). + Limit(limit). + Offset(offset). + Pluck("customers.id", &customerIDs). + Error + if err != nil { + return nil, 0, err + } + + return customerIDs, total, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetAllFilteredCustomerIDs(ctx context.Context, filters *validation.BalanceMonitoringQuery) ([]uint, error) { + var customerIDs []uint + if err := r.baseCustomerQuery(ctx, filters).Pluck("customers.id", &customerIDs).Error; err != nil { + return nil, err + } + return customerIDs, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetSaldoAwalLifetime(ctx context.Context, customerIDs []uint) (map[uint]float64, error) { + if len(customerIDs) == 0 { + return map[uint]float64{}, nil + } + + type row struct { + CustomerID uint `gorm:"column:customer_id"` + Total float64 `gorm:"column:total"` + } + rows := make([]row, 0) + err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total"). + Where("party_type = ?", string(utils.PaymentPartyCustomer)). + Where("transaction_type = ?", string(utils.TransactionTypeSaldoAwal)). + Where("party_id IN ?", customerIDs). + Group("party_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, r := range rows { + result[r.CustomerID] = r.Total + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetSalesTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) { + if len(customerIDs) == 0 { + return map[uint]float64{}, nil + } + + startDate, _, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]float64{}, nil + } + + type row struct { + CustomerID uint `gorm:"column:customer_id"` + Total float64 `gorm:"column:total"` + } + rows := make([]row, 0) + + var db *gorm.DB + if strings.ToLower(strings.TrimSpace(filters.FilterBy)) == "realized_at" { + // realized_at: gunakan data DO (mdp.total_price), filter by delivery_date < startDate + db = r.db.WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select("m.customer_id AS customer_id, COALESCE(SUM(mdp.total_price), 0) AS total"). + Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). + Where("m.customer_id IN ?", customerIDs). + Where("m.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL"). + Where("DATE(mdp.delivery_date) < ?", startDate) + } else { + // sold_at: SO-date sebelum startDate DAN approval terbaru sudah DO — gunakan data DO (mdp.total_price) + db = r.db.WithContext(ctx). + Table("marketing_products mp"). + Select("m.customer_id AS customer_id, COALESCE(SUM(mdp.total_price), 0) AS total"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). + Joins("INNER JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). + Where("m.customer_id IN ?", customerIDs). + Where("m.deleted_at IS NULL"). + Where("DATE(m.so_date) < ?", startDate). + Where("(SELECT step_number FROM approvals WHERE approvable_type = 'MARKETINGS' AND approvable_id = mp.marketing_id ORDER BY id DESC LIMIT 1) >= ?", uint16(utils.MarketingDeliveryOrder)) + } + + if len(filters.SalesIDs) > 0 { + db = db.Where("m.sales_person_id IN ?", filters.SalesIDs) + } + + if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr.Total + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) { + if len(customerIDs) == 0 { + return map[uint]float64{}, nil + } + + startDate, _, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]float64{}, nil + } + + type row struct { + CustomerID uint `gorm:"column:customer_id"` + Total float64 `gorm:"column:total"` + } + rows := make([]row, 0) + err = r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total"). + Where("party_type = ?", string(utils.PaymentPartyCustomer)). + Where("transaction_type = ?", string(utils.TransactionTypePenjualan)). + Where("direction = ?", "IN"). + Where("party_id IN ?", customerIDs). + Where("DATE(payment_date) < ?", startDate). + Group("party_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr.Total + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetSalesByCategoryInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringCategoryRow, error) { + if len(customerIDs) == 0 { + return map[uint]BalanceMonitoringCategoryRow{}, nil + } + + startDate, endDate, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]BalanceMonitoringCategoryRow{}, nil + } + + // Gunakan data DO (mdp) bukan SO (mp) agar nominal/qty/kg mencerminkan nilai aktual DO + const selectCols = `m.customer_id AS customer_id, + COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN (mdp.usage_qty + mdp.pending_qty) ELSE 0 END), 0) AS ayam_qty, + COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN mdp.total_weight ELSE 0 END), 0) AS ayam_kg, + COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN mdp.total_price ELSE 0 END), 0) AS ayam_nominal, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN (mdp.usage_qty + mdp.pending_qty) ELSE 0 END), 0) AS telur_qty, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN mdp.total_weight ELSE 0 END), 0) AS telur_kg, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN mdp.total_price ELSE 0 END), 0) AS telur_nominal, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN (mdp.usage_qty + mdp.pending_qty) ELSE 0 END), 0) AS trading_qty, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN mdp.total_weight ELSE 0 END), 0) AS trading_kg, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN mdp.total_price ELSE 0 END), 0) AS trading_nominal` + + rows := make([]BalanceMonitoringCategoryRow, 0) + + var db *gorm.DB + if strings.ToLower(strings.TrimSpace(filters.FilterBy)) == "realized_at" { + // realized_at: FROM mdp langsung, filter by delivery_date in period — data DO + db = r.db.WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select(selectCols). + Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). + Where("m.customer_id IN ?", customerIDs). + Where("m.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL"). + Where("DATE(mdp.delivery_date) >= ?", startDate). + Where("DATE(mdp.delivery_date) <= ?", endDate) + } else { + // sold_at: SO-date dalam period DAN approval terbaru DO — JOIN mdp untuk data DO + db = r.db.WithContext(ctx). + Table("marketing_products mp"). + Select(selectCols). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). + Joins("INNER JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). + Where("m.customer_id IN ?", customerIDs). + Where("m.deleted_at IS NULL"). + Where("DATE(m.so_date) >= ?", startDate). + Where("DATE(m.so_date) <= ?", endDate). + Where("(SELECT step_number FROM approvals WHERE approvable_type = 'MARKETINGS' AND approvable_id = mp.marketing_id ORDER BY id DESC LIMIT 1) >= ?", uint16(utils.MarketingDeliveryOrder)) + } + + if len(filters.SalesIDs) > 0 { + db = db.Where("m.sales_person_id IN ?", filters.SalesIDs) + } + + if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]BalanceMonitoringCategoryRow, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetPaymentTotalsInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) { + if len(customerIDs) == 0 { + return map[uint]float64{}, nil + } + + startDate, endDate, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]float64{}, nil + } + + type row struct { + CustomerID uint `gorm:"column:customer_id"` + Total float64 `gorm:"column:total"` + } + rows := make([]row, 0) + err = r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total"). + Where("party_type = ?", string(utils.PaymentPartyCustomer)). + Where("transaction_type = ?", string(utils.TransactionTypePenjualan)). + Where("direction = ?", "IN"). + Where("party_id IN ?", customerIDs). + Where("DATE(payment_date) >= ?", startDate). + Where("DATE(payment_date) <= ?", endDate). + Group("party_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr.Total + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetAgingPerCustomer(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringAgingRow, error) { + if len(customerIDs) == 0 { + return map[uint]BalanceMonitoringAgingRow{}, nil + } + + startDate, endDate, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]BalanceMonitoringAgingRow{}, nil + } + + dateColumn := resolveBalanceMonitoringDateColumn(filters.FilterBy) + + rows := make([]BalanceMonitoringAgingRow, 0) + db := r.db.WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select(`m.customer_id AS customer_id, + COALESCE(MAX(GREATEST(CURRENT_DATE - DATE(mdp.delivery_date), 0)), 0) AS aging_max, + COALESCE( + SUM(mdp.total_price * GREATEST(CURRENT_DATE - DATE(mdp.delivery_date), 0))::numeric + / NULLIF(SUM(mdp.total_price), 0), + 0 + )::numeric(15,2) AS aging_rata_rata`). + Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). + Where("m.customer_id IN ?", customerIDs). + Where("m.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL"). + Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), startDate). + Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), endDate) + + if len(filters.SalesIDs) > 0 { + db = db.Where("m.sales_person_id IN ?", filters.SalesIDs) + } + + if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]BalanceMonitoringAgingRow, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetGrandTotals(ctx context.Context, filters *validation.BalanceMonitoringQuery) (BalanceMonitoringGrandTotalsRow, error) { + customerIDs, err := r.GetAllFilteredCustomerIDs(ctx, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + if len(customerIDs) == 0 { + return BalanceMonitoringGrandTotalsRow{}, nil + } + + saldoAwalLifetimeMap, err := r.GetSaldoAwalLifetime(ctx, customerIDs) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + salesBeforeMap, err := r.GetSalesTotalsBeforeDate(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + paymentBeforeMap, err := r.GetPaymentTotalsBeforeDate(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + categoryMap, err := r.GetSalesByCategoryInPeriod(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + paymentInPeriodMap, err := r.GetPaymentTotalsInPeriod(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + agingMap, err := r.GetAgingPerCustomer(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + + totals := BalanceMonitoringGrandTotalsRow{} + for _, total := range saldoAwalLifetimeMap { + totals.SaldoAwalLifetime += total + } + for _, total := range salesBeforeMap { + totals.SalesBeforeStart += total + } + for _, total := range paymentBeforeMap { + totals.PaymentBeforeStart += total + } + for _, cat := range categoryMap { + totals.AyamQty += cat.AyamQty + totals.AyamKg += cat.AyamKg + totals.AyamNominal += cat.AyamNominal + totals.TelurQty += cat.TelurQty + totals.TelurKg += cat.TelurKg + totals.TelurNominal += cat.TelurNominal + totals.TradingQty += cat.TradingQty + totals.TradingKg += cat.TradingKg + totals.TradingNominal += cat.TradingNominal + } + for _, total := range paymentInPeriodMap { + totals.PaymentInPeriod += total + } + + for _, aging := range agingMap { + totals.AgingMax += aging.AgingMax + } + + weightedSum := 0.0 + weightTotal := 0.0 + for cid, cat := range categoryMap { + nominal := cat.AyamNominal + cat.TelurNominal + cat.TradingNominal + if aging, ok := agingMap[cid]; ok && nominal > 0 { + weightedSum += nominal * aging.AgingRataRata + weightTotal += nominal + } + } + if weightTotal > 0 { + totals.AgingRataRata = weightedSum / weightTotal + } + + return totals, nil +} diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index c5db5e09..a95ef1bc 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -15,13 +15,16 @@ import ( type DebtSupplierRepository interface { 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) + GetExpensesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Expense, 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) GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (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) + GetExpenseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) } type debtSupplierRepositoryImpl struct { @@ -490,3 +493,218 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Cont 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.ExpenseStepRealisasi)). + 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.ExpenseStepRealisasi)). + 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.ExpenseStepRealisasi)). + 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 +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 16c14de5..56faae35 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -26,4 +26,5 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) + route.Get("/balance-monitoring", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetBalanceMonitoring) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 3e203c65..4e2a9482 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -52,6 +52,7 @@ type RepportService interface { GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) + GetBalanceMonitoring(ctx *fiber.Ctx, params *validation.BalanceMonitoringQuery) ([]dto.BalanceMonitoringRowDTO, dto.BalanceMonitoringTotalsDTO, int64, error) DB() *gorm.DB } @@ -74,6 +75,7 @@ type repportService struct { HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository CustomerPaymentRepo repportRepo.CustomerPaymentRepository + BalanceMonitoringRepo repportRepo.BalanceMonitoringRepository CustomerRepo customerRepo.CustomerRepository StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository @@ -106,6 +108,7 @@ func NewRepportService( hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, customerPaymentRepo repportRepo.CustomerPaymentRepository, + balanceMonitoringRepo repportRepo.BalanceMonitoringRepository, customerRepo customerRepo.CustomerRepository, standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository, productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, @@ -129,6 +132,7 @@ func NewRepportService( HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, CustomerPaymentRepo: customerPaymentRepo, + BalanceMonitoringRepo: balanceMonitoringRepo, CustomerRepo: customerRepo, StandardGrowthDetailRepo: standardGrowthDetailRepo, ProductionStandardDetailRepo: productionStandardDetailRepo, @@ -1778,7 +1782,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu 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 { return nil, 0, err } @@ -1803,11 +1807,21 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu 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)) for _, purchase := range purchases { 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)) for _, payment := range payments { paymentsBySupplier[payment.PartyId] = append(paymentsBySupplier[payment.PartyId], payment) @@ -1823,6 +1837,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu 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) if err != nil { return nil, 0, err @@ -1843,10 +1862,10 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu CountTotals bool } type debtSupplierAllocation struct { - RowIndex int - SortTime time.Time - Amount float64 - Purchase entity.Purchase + RowIndex int + SortTime time.Time + Amount float64 + CalcAging func(endDate time.Time) int } type paymentAllocation struct { Date time.Time @@ -1859,7 +1878,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu continue } - initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]) + initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] - initialExpenseTotals[supplierID]) items := purchasesBySupplier[supplierID] paymentItems := paymentsBySupplier[supplierID] total := dto.DebtSupplierTotalDTO{} @@ -1877,11 +1896,32 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance: -row.TotalPrice, CountTotals: true, }) + capturedPurchase := purchase purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{ - RowIndex: rowIndex, - SortTime: sortTime, - Amount: row.TotalPrice, - Purchase: purchase, + RowIndex: rowIndex, + SortTime: sortTime, + Amount: row.TotalPrice, + 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) }, }) } @@ -1946,7 +1986,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu if remaining[purchaseIndex] <= 0.000001 { allocation := purchaseAllocations[purchaseIndex] 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++ } } @@ -2220,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) } +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) { if err := s.Validate.Struct(params); err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) @@ -2893,3 +2989,163 @@ func parseOptionalFloat64(raw string) (*float64, error) { return &value, nil } + +func (s *repportService) GetBalanceMonitoring(ctx *fiber.Ctx, params *validation.BalanceMonitoringQuery) ([]dto.BalanceMonitoringRowDTO, dto.BalanceMonitoringTotalsDTO, int64, error) { + if params.SortBy == "" { + params.SortBy = "customer" + } + if params.SortOrder == "" { + params.SortOrder = "asc" + } + if params.FilterBy == "" { + params.FilterBy = "sold_at" + } + if params.Page < 1 { + params.Page = 1 + } + if params.Limit < 1 { + params.Limit = 10 + } + + if err := s.Validate.Struct(params); err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + + locationScope, err := m.ResolveLocationScope(ctx, s.DB()) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.DB()) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + if locationScope.Restrict { + params.AllowedLocationIDs = toInt64Slice(locationScope.IDs) + } + if areaScope.Restrict { + params.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + + offset := (params.Page - 1) * params.Limit + + customerIDs, total, err := s.BalanceMonitoringRepo.GetCustomerIDsForBalanceMonitoring(ctx.Context(), offset, params.Limit, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + if len(customerIDs) == 0 { + emptyTotals, gtErr := s.computeBalanceMonitoringTotals(ctx.Context(), params) + if gtErr != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, gtErr + } + return []dto.BalanceMonitoringRowDTO{}, emptyTotals, total, nil + } + + saldoAwalLifetimeMap, err := s.BalanceMonitoringRepo.GetSaldoAwalLifetime(ctx.Context(), customerIDs) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + salesBeforeMap, err := s.BalanceMonitoringRepo.GetSalesTotalsBeforeDate(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + paymentBeforeMap, err := s.BalanceMonitoringRepo.GetPaymentTotalsBeforeDate(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + categoryMap, err := s.BalanceMonitoringRepo.GetSalesByCategoryInPeriod(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + paymentInPeriodMap, err := s.BalanceMonitoringRepo.GetPaymentTotalsInPeriod(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + agingMap, err := s.BalanceMonitoringRepo.GetAgingPerCustomer(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + + customers, err := s.CustomerRepo.GetByIDs(ctx.Context(), customerIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("Pic") + }) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + customerMap := make(map[uint]entity.Customer, len(customers)) + for _, c := range customers { + customerMap[c.Id] = c + } + + result := make([]dto.BalanceMonitoringRowDTO, 0, len(customerIDs)) + for _, customerID := range customerIDs { + customer, ok := customerMap[customerID] + if !ok { + continue + } + + saldoAwal := saldoAwalLifetimeMap[customerID] + paymentBeforeMap[customerID] - salesBeforeMap[customerID] + + category := categoryMap[customerID] + ayam := dto.BalanceMonitoringAyamDTO{ + Ekor: category.AyamQty, + Kg: category.AyamKg, + Nominal: category.AyamNominal, + } + telur := dto.BalanceMonitoringTelurDTO{ + Butir: category.TelurQty, + Kg: category.TelurKg, + Nominal: category.TelurNominal, + } + trading := dto.BalanceMonitoringTradingDTO{ + Qty: category.TradingQty, + Kg: category.TradingKg, + Nominal: category.TradingNominal, + } + + pembayaran := paymentInPeriodMap[customerID] + aging := agingMap[customerID] + + row := dto.ToBalanceMonitoringRowDTO(customer, saldoAwal, ayam, telur, trading, pembayaran, aging.AgingMax, aging.AgingRataRata) + result = append(result, row) + } + + totals, err := s.computeBalanceMonitoringTotals(ctx.Context(), params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + + return result, totals, total, nil +} + +func (s *repportService) computeBalanceMonitoringTotals(ctx context.Context, params *validation.BalanceMonitoringQuery) (dto.BalanceMonitoringTotalsDTO, error) { + grand, err := s.BalanceMonitoringRepo.GetGrandTotals(ctx, params) + if err != nil { + return dto.BalanceMonitoringTotalsDTO{}, err + } + + saldoAwal := grand.SaldoAwalLifetime + grand.PaymentBeforeStart - grand.SalesBeforeStart + saldoAkhir := saldoAwal + grand.PaymentInPeriod - (grand.AyamNominal + grand.TelurNominal + grand.TradingNominal) + + return dto.BalanceMonitoringTotalsDTO{ + SaldoAwal: saldoAwal, + PenjualanAyam: dto.BalanceMonitoringAyamDTO{ + Ekor: grand.AyamQty, + Kg: grand.AyamKg, + Nominal: grand.AyamNominal, + }, + PenjualanTelur: dto.BalanceMonitoringTelurDTO{ + Butir: grand.TelurQty, + Kg: grand.TelurKg, + Nominal: grand.TelurNominal, + }, + PenjualanTrading: dto.BalanceMonitoringTradingDTO{ + Qty: grand.TradingQty, + Kg: grand.TradingKg, + Nominal: grand.TradingNominal, + }, + Pembayaran: grand.PaymentInPeriod, + Aging: grand.AgingMax, + AgingRataRata: grand.AgingRataRata, + SaldoAkhir: saldoAkhir, + }, nil +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 0ef458e1..c2d06c12 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -116,3 +116,17 @@ type CustomerPaymentQuery struct { StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` } + +type BalanceMonitoringQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + CustomerIDs []uint `query:"-" validate:"omitempty,dive,gt=0"` + SalesIDs []uint `query:"-" validate:"omitempty,dive,gt=0"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=sold_at realized_at"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=customer"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + AllowedAreaIDs []int64 `query:"-"` + AllowedLocationIDs []int64 `query:"-"` +}