productstock

This commit is contained in:
ragilap
2025-12-31 07:08:03 +07:00
111 changed files with 6946 additions and 1060 deletions
+26 -24
View File
@@ -28,18 +28,19 @@ type ClosingDetailDTO struct {
}
type ClosingListItemDTO struct {
Id uint `json:"id"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
ProjectCategory string `json:"project_category"`
Period int `json:"period"`
ClosingDate string `json:"closing_date"`
ShedLabel string `json:"shed_label"`
ShedCount int `json:"shed_count"`
SalesPaidAmount int64 `json:"sales_paid_amount"`
SalesRemainingAmount int64 `json:"sales_remaining_amount"`
SalesPaymentStatus string `json:"sales_payment_status"`
ProjectStatus string `json:"project_status"`
Id uint `json:"id"`
ProjectName string `json:"project_name"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
ProjectCategory string `json:"project_category"`
Period int `json:"period"`
ClosingDate string `json:"closing_date"`
ShedLabel string `json:"shed_label"`
ShedCount int `json:"shed_count"`
// SalesPaidAmount int64 `json:"sales_paid_amount"`
// SalesRemainingAmount int64 `json:"sales_remaining_amount"`
// SalesPaymentStatus string `json:"sales_payment_status"`
ProjectStatus string `json:"project_status"`
}
type ClosingSummaryDTO struct {
@@ -133,18 +134,19 @@ func ToClosingListItemDTO(project entity.ProjectFlock, projectStatus string) Clo
shedCount := len(project.KandangHistory)
return ClosingListItemDTO{
Id: project.Id,
LocationID: project.LocationId,
LocationName: project.Location.Name,
ProjectCategory: project.Category,
Period: maxPeriod(project.KandangHistory),
ClosingDate: "17-Nov-2025",
ShedLabel: fmt.Sprintf("%d Kandang", shedCount),
ShedCount: shedCount,
SalesPaidAmount: 21993726,
SalesRemainingAmount: 11075919,
SalesPaymentStatus: "Lunas",
ProjectStatus: projectStatus,
Id: project.Id,
ProjectName: project.FlockName,
LocationID: project.LocationId,
LocationName: project.Location.Name,
ProjectCategory: project.Category,
Period: maxPeriod(project.KandangHistory),
ClosingDate: "17-Nov-2025",
ShedLabel: fmt.Sprintf("%d Kandang", shedCount),
ShedCount: shedCount,
// SalesPaidAmount: 21993726,
// SalesRemainingAmount: 11075919,
// SalesPaymentStatus: "Lunas",
ProjectStatus: projectStatus,
}
}
@@ -35,6 +35,7 @@ const (
type CalculationContext struct {
TotalPopulation float64
TotalWeightProduced float64
TotalEggWeightKg float64
TotalDepletion float64
TotalWeightSold float64
ActualPopulation float64
@@ -48,6 +49,7 @@ type ClosingKeuanganInput struct {
DeliveryProducts []entities.MarketingDeliveryProduct
Chickins []entities.ProjectChickin
TotalWeightProduced float64
TotalEggWeightKg float64
TotalDepletion float64
}
@@ -77,8 +79,10 @@ type HppGroup struct {
}
type SummaryHpp struct {
Label string `json:"label"`
Comparison
Label string `json:"label"`
Comparison `json:"-"`
EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"`
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
}
type HppPurchasesSection struct {
@@ -231,7 +235,7 @@ func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entiti
// === HPP SUMMARY ===
func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) SummaryHpp {
func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp {
purchaseTotal := sumPurchaseTotal(purchaseItems)
budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true })
totalBudget := purchaseTotal + budgetTotal
@@ -241,16 +245,34 @@ func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets [
budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced)
realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced)
return SummaryHpp{
summary := SummaryHpp{
Label: label,
Comparison: ToComparison(
ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget),
ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization),
),
}
if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 {
budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg)
realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg)
summary.EggBudgeting = &FinancialMetrics{
RpPerBird: 0,
RpPerKg: budgetEggRpPerKg,
Amount: totalBudget,
}
summary.EggRealization = &FinancialMetrics{
RpPerBird: 0,
RpPerKg: realizationEggRpPerKg,
Amount: totalRealization,
}
}
return summary
}
func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppPurchasesSection {
func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection {
hppGroups := []HppGroup{
{
GroupName: HPPGroupPengeluaran,
@@ -259,7 +281,7 @@ func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []enti
ToHppBahanBakuGroup(budgets, realizations, ctx),
}
summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, ctx)
summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx)
return HppPurchasesSection{
Hpp: hppGroups,
@@ -322,11 +344,9 @@ func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.M
func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
purchaseAmount := sumPurchaseTotal(purchases)
bopAmount := getOperationalExpenses(realizations)
totalCost := purchaseAmount + bopAmount
return []PLItem{
createPLItemWithMetrics(PLItemTypeSapronak, totalCost, ctx),
createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx),
}
}
@@ -414,12 +434,13 @@ func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse {
ctx := CalculationContext{
TotalPopulation: totalPopulation,
TotalWeightProduced: input.TotalWeightProduced,
TotalEggWeightKg: input.TotalEggWeightKg,
TotalDepletion: input.TotalDepletion,
TotalWeightSold: totalWeightSold,
ActualPopulation: totalPopulation - input.TotalDepletion,
}
hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, ctx)
hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx)
penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx)
pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx)
overheadItems := ToOverheadItems(input.Realizations, ctx)
@@ -31,6 +31,8 @@ type ClosingRepository interface {
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
}
type ClosingRepositoryImpl struct {
@@ -328,13 +330,33 @@ SELECT
COALESCE(p.po_number, '') AS reference_number,
'Purchase' AS transaction_type,
prod.name AS product_name,
pc.name AS product_category,
COALESCE((
SELECT string_agg(f.name, ' ')
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
'External Supplier' AS source_warehouse,
'-' AS source_warehouse,
w.name AS destination_warehouse,
'' AS destination,
pi.total_qty AS quantity,
@@ -343,7 +365,6 @@ SELECT
FROM purchase_items pi
JOIN purchases p ON p.id = pi.purchase_id
JOIN products prod ON prod.id = pi.product_id
JOIN product_categories pc ON pc.id = prod.product_category_id
JOIN uoms u ON u.id = prod.uom_id
JOIN warehouses w ON w.id = pi.warehouse_id
WHERE pi.warehouse_id IN ?
@@ -357,9 +378,29 @@ SELECT
st.movement_number AS reference_number,
'Internal Transfer In' AS transaction_type,
prod.name AS product_name,
pc.name AS product_category,
COALESCE((
SELECT string_agg(f.name, ' ')
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
@@ -374,7 +415,6 @@ JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id
JOIN products prod ON prod.id = std.product_id
JOIN product_categories pc ON pc.id = prod.product_category_id
JOIN uoms u ON u.id = prod.uom_id
WHERE st.to_warehouse_id IN ?
`
@@ -387,9 +427,29 @@ SELECT
st.movement_number AS reference_number,
'Internal Transfer Out' AS transaction_type,
prod.name AS product_name,
pc.name AS product_category,
COALESCE((
SELECT string_agg(f.name, ' ')
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
@@ -404,7 +464,6 @@ JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id
JOIN products prod ON prod.id = std.product_id
JOIN product_categories pc ON pc.id = prod.product_category_id
JOIN uoms u ON u.id = prod.uom_id
WHERE st.from_warehouse_id IN ?
`
@@ -417,9 +476,29 @@ SELECT
m.so_number AS reference_number,
'Trading Sales' AS transaction_type,
prod.name AS product_name,
pc.name AS product_category,
COALESCE((
SELECT string_agg(f.name, ' ')
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
@@ -433,7 +512,6 @@ FROM marketing_products mp
JOIN marketings m ON m.id = mp.marketing_id
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN product_categories pc ON pc.id = prod.product_category_id
JOIN uoms u ON u.id = prod.uom_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE pw.project_flock_kandang_id IN ?
@@ -804,3 +882,150 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
})
return in, out, nil
}
type ActualUsageCostRow struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
FlagName string `gorm:"column:flag_name"`
TotalQty float64 `gorm:"column:total_qty"`
TotalPrice float64 `gorm:"column:total_price"`
AveragePrice float64 `gorm:"column:average_price"`
}
func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) {
if projectFlockID == 0 {
return []ActualUsageCostRow{}, nil
}
db := r.DB().WithContext(ctx)
// Get all project flock kandang IDs for this project flock
var pfkIDs []uint
err := db.Table("project_flock_kandangs").
Where("project_flock_id = ?", projectFlockID).
Pluck("id", &pfkIDs).Error
if err != nil {
return nil, err
}
if len(pfkIDs) == 0 {
return []ActualUsageCostRow{}, nil
}
var rows []ActualUsageCostRow
// Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll)
purchaseStockableKey := "PURCHASE_ITEMS"
transferStockableKey := "STOCK_TRANSFER_DETAILS"
recordingQuery := db.
Table("recordings AS r").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(f.name, tf.name) AS flag_name,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
ELSE 0
END
), 0) AS total_qty,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
END
), 0) AS total_price,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
ELSE 0
END
), 0) AS qty_divisor,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
END
), 0) / NULLIF(COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
ELSE 0
END
), 0), 0) AS average_price`,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?",
"recording_stocks", entity.StockAllocationStatusActive).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey).
Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey).
Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct).
Where("r.project_flock_kandangs_id IN ?", pfkIDs).
Where("r.deleted_at IS NULL").
Group("pw.product_id, p.name, COALESCE(f.name, tf.name)")
if err := recordingQuery.Scan(&rows).Error; err != nil {
return nil, err
}
// Part 2: Get usage from project_chickins (DOC, Pullet)
chickinQuery := db.
Table("project_chickins AS pc").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag_name,
COALESCE(SUM(pc.usage_qty), 0) AS total_qty,
COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price,
COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price
`).
Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("pc.project_flock_kandang_id IN ?", pfkIDs).
Where("pc.usage_qty > 0").
Group("pw.product_id, p.name, f.name")
var chickinRows []ActualUsageCostRow
if err := chickinQuery.Scan(&chickinRows).Error; err != nil {
return nil, err
}
// Merge results
rows = append(rows, chickinRows...)
return rows, nil
}
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
if len(productIDs) == 0 {
return []entity.Product{}, nil
}
var products []entity.Product
err := r.DB().WithContext(ctx).
Preload("Flags").
Where("id IN ?", productIDs).
Find(&products).Error
if err != nil {
return nil, err
}
return products, nil
}
@@ -6,6 +6,7 @@ import (
"math"
"strconv"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -332,18 +333,20 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID
}
var (
minStep uint16
statusProject string
completed int
minStep uint16
statusProject string
completed int
latestActionAt time.Time
)
for _, rec := range records {
if minStep == 0 || rec.StepNumber < minStep {
minStep = rec.StepNumber
statusProject = rec.StepName
}
if rec.StepNumber == uint16(utils.ProjectFlockStepAktif) {
completed++
if latestActionAt.IsZero() || rec.ActionAt.After(latestActionAt) {
latestActionAt = rec.ActionAt
statusProject = rec.StepName
}
}
@@ -426,11 +429,15 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
purchaseItems, err := s.PurchaseRepo.GetItemsByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch purchase items")
// Get actual usage cost instead of purchase items
actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost")
}
// Convert actual usage rows to pseudo purchase items
purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows)
realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations")
@@ -455,6 +462,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err)
}
totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err)
}
totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err)
@@ -468,6 +480,7 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
DeliveryProducts: deliveryProducts,
Chickins: chickins,
TotalWeightProduced: totalWeightProduced,
TotalEggWeightKg: totalEggWeightKg,
TotalDepletion: totalDepletion,
}
@@ -476,8 +489,6 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
return &report, nil
}
// GetExpeditionHPP menghitung HPP ekspedisi per vendor untuk sebuah project flock.
// Jika projectFlockKandangID tidak nil, maka hanya data untuk kandang tersebut yang dihitung.
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
@@ -778,5 +789,54 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl
}
return closest.Mortality, closest.FcrNumber
}
func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem {
if len(actualUsageRows) == 0 {
return []entity.PurchaseItem{}
}
// Collect all product IDs
productIDs := make([]uint, len(actualUsageRows))
for i, row := range actualUsageRows {
productIDs[i] = row.ProductID
}
// Fetch products with flags from repository
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs)
if err != nil {
s.Log.Warnf("Failed to fetch products for actual usage: %v", err)
products = []entity.Product{}
}
// Create product map
productMap := make(map[uint]*entity.Product)
for i := range products {
productMap[products[i].Id] = &products[i]
}
// Convert to pseudo purchase items
purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows))
for _, row := range actualUsageRows {
product := productMap[row.ProductID]
// Skip if product not found
if product == nil {
s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID)
continue
}
purchaseItem := entity.PurchaseItem{
Id: 0, // Pseudo item, no ID
ProductId: row.ProductID,
TotalQty: row.TotalQty,
TotalPrice: row.TotalPrice,
Price: row.AveragePrice,
Product: product,
}
purchaseItems = append(purchaseItems, purchaseItem)
}
return purchaseItems
}
@@ -90,6 +90,12 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
}
req.SupplierID = supplierID
locationID, err := strconv.ParseUint(c.FormValue("location_id"), 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format")
}
req.LocationID = locationID
form, err := c.MultipartForm()
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
@@ -106,17 +112,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err))
}
if singleExpenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required")
}
req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock}
} else {
for i, expenseNonstock := range req.ExpenseNonstocks {
if expenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
}
}
}
} else {
return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required")
@@ -171,6 +167,15 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
req.SupplierID = &supplierID
}
locationIDVal := c.FormValue("location_id")
if locationIDVal != "" {
locationID, err := strconv.ParseUint(locationIDVal, 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format")
}
req.LocationID = &locationID
}
expenseNonstocksJSON := c.FormValue("expense_nonstocks")
if expenseNonstocksJSON != "" {
var expenseNonstocks []validation.ExpenseNonstock
@@ -178,12 +183,6 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err))
}
for i, expenseNonstock := range expenseNonstocks {
if expenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
}
}
req.ExpenseNonstocks = &expenseNonstocks
}
+18 -5
View File
@@ -76,7 +76,6 @@ type ExpenseRealizationDTO struct {
type KandangGroupDTO struct {
Id uint64 `json:"id"`
KandangId uint64 `json:"kandang_id"`
Name string `json:"name,omitempty"`
Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"`
Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"`
@@ -178,7 +177,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
var pengajuans []ExpenseNonstockDTO
var realisasi []ExpenseRealizationDTO
// Map documents from Document service
for _, doc := range e.Documents {
documents = append(documents, DocumentDTO{
ID: uint64(doc.Id),
@@ -186,7 +184,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
})
}
// Map realization documents from Document service
for _, doc := range e.RealizationDocuments {
realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id),
@@ -271,6 +268,8 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO {
func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO {
kandangMap := make(map[uint64]*KandangGroupDTO)
var directPengajuans []ExpenseNonstockDTO
var directRealisasi []ExpenseRealizationDTO
for _, p := range pengajuans {
var kandangId uint64
@@ -287,16 +286,19 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
}
if kandangId > 0 {
if kandangMap[kandangId] == nil {
kandangMap[kandangId] = &KandangGroupDTO{
Id: kandangId,
KandangId: kandangId,
Name: kandangName,
Pengajuans: []ExpenseNonstockDTO{},
Realisasi: []ExpenseRealizationDTO{},
}
}
kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p)
} else {
directPengajuans = append(directPengajuans, p)
}
}
@@ -316,13 +318,24 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
if kandangMap[kandangId] == nil {
kandangMap[kandangId] = &KandangGroupDTO{
Id: kandangId,
KandangId: kandangId,
Name: kandangName,
Pengajuans: []ExpenseNonstockDTO{},
Realisasi: []ExpenseRealizationDTO{},
}
}
kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r)
} else {
}
}
// If there are direct expenses (without kandang), add them as a special entry with id=0
if len(directPengajuans) > 0 || len(directRealisasi) > 0 {
kandangMap[0] = &KandangGroupDTO{
Id: 0,
Name: "",
Pengajuans: directPengajuans,
Realisasi: directRealisasi,
}
}
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -144,11 +145,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
supplierID := uint(req.SupplierID)
supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) {
return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id)
}
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc},
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists},
); err != nil {
return nil, err
}
@@ -199,11 +197,47 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
createdBy := uint64(actorID)
hasKandang := false
for _, ens := range req.ExpenseNonstocks {
if ens.KandangID != nil {
hasKandang = true
break
}
}
var projectFlockIdJSON *string
if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) {
projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction)
activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location")
}
if len(activeProjectFlocks) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location")
}
projectFlockIDs := make([]uint64, len(activeProjectFlocks))
for i, pf := range activeProjectFlocks {
projectFlockIDs[i] = uint64(pf.Id)
}
projectFlockIdsJSON, err := json.Marshal(projectFlockIDs)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids")
}
jsonStr := string(projectFlockIdsJSON)
projectFlockIdJSON = &jsonStr
}
expense = &entity.Expense{
ReferenceNumber: referenceNumber,
PoNumber: req.PoNumber,
Category: req.Category,
SupplierId: req.SupplierID,
LocationId: req.LocationID,
ProjectFlockId: projectFlockIdJSON,
TransactionDate: expenseDate,
CreatedBy: createdBy,
}
@@ -216,35 +250,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
for _, expenseNonstock := range req.ExpenseNonstocks {
isAttachingToKandang := (expenseNonstock.KandangID != nil)
var projectFlockKandangId *uint64
var kandangId *uint64
if req.Category == string(utils.ExpenseCategoryBOP) {
if isAttachingToKandang {
kandangId = expenseNonstock.KandangID
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
if req.Category == string(utils.ExpenseCategoryBOP) {
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
}
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
} else {
kandangId = nil
projectFlockKandangId = nil
}
for _, costItem := range expenseNonstock.CostItems {
nonstockId := costItem.NonstockID
var kandangId *uint64
if req.Category == string(utils.ExpenseCategoryNonBOP) {
id := uint64(expenseNonstock.KandangID)
kandangId = &id
} else if req.Category == string(utils.ExpenseCategoryBOP) {
if projectFlockKandangId != nil {
kandangId = &expenseNonstock.KandangID
}
}
expenseNonstock := &entity.ExpenseNonstock{
newExpenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expense.Id,
ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId,
@@ -254,7 +289,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
Notes: costItem.Notes,
}
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil {
if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
}
}
@@ -361,6 +396,11 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
updateBody["supplier_id"] = *req.SupplierID
}
if req.LocationID != nil {
locationID := uint(*req.LocationID)
updateBody["location_id"] = locationID
}
if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 {
responseDTO, err := s.GetOne(c, id)
@@ -475,18 +515,26 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
for _, expenseNonstock := range *req.ExpenseNonstocks {
var projectFlockKandangId *uint64
var kandangId *uint64
if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
// Check if attaching to kandang
if expenseNonstock.KandangID != nil {
kandangId = expenseNonstock.KandangID
if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
// BOP with kandang: Get active project flock kandang
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
}
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
// NON-BOP: projectFlockKandangId stays nil
}
for _, costItem := range expenseNonstock.CostItems {
@@ -498,18 +546,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return err
}
var kandangId *uint64
if updatedExpense.Category == string(utils.ExpenseCategoryNonBOP) {
id := uint64(expenseNonstock.KandangID)
kandangId = &id
} else if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
if projectFlockKandangId != nil {
kandangId = &expenseNonstock.KandangID
}
}
expenseId := uint64(id)
expenseNonstock := &entity.ExpenseNonstock{
newExpenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expenseId,
ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId,
@@ -519,7 +557,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
Notes: costItem.Notes,
}
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil {
if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
}
}
@@ -9,12 +9,13 @@ type Create struct {
TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"`
Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"`
SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"`
LocationID uint64 `form:"location_id" json:"location_id" validate:"required,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"`
}
type ExpenseNonstock struct {
KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"`
KandangID *uint64 `form:"kandang_id" json:"kandang_id" validate:"omitempty"`
CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"`
}
@@ -22,13 +23,14 @@ type CostItem struct {
NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"`
Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"`
Price float64 `form:"price" json:"price" validate:"required,gt=0"`
Notes string `form:"notes" json:"notes" validate:"required,max=500"`
Notes string `form:"notes" json:"notes" validate:"omitempty,max=500"`
}
type Update struct {
TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"`
SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"`
LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"`
}
@@ -0,0 +1,92 @@
package controller
import (
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type InitialController struct {
InitialService service.InitialService
}
func NewInitialController(initialService service.InitialService) *InitialController {
return &InitialController{
InitialService: initialService,
}
}
func (u *InitialController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.InitialService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
func (u *InitialController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.InitialService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
func (u *InitialController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.InitialService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
@@ -0,0 +1,163 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type InitialRelationDTO struct {
Id uint `json:"id"`
ReferenceNumber string `json:"reference_number"`
TransactionType string `json:"transaction_type"`
InitialBalanceType string `json:"initial_balance_type"`
InitialBalanceTypeLabel string `json:"initial_balance_type_label"`
Party Party `json:"party"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
Direction string `json:"direction"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
}
type InitialListDTO struct {
InitialRelationDTO
CreatedBy uint `json:"created_by"`
CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type InitialDetailDTO struct {
InitialListDTO
}
type Party struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
}
// === Mapper Functions ===
func ToInitialRelationDTO(e entity.Payment) InitialRelationDTO {
reference := ""
if e.ReferenceNumber != nil {
reference = *e.ReferenceNumber
}
initialBalanceType := initialBalanceTypeFromPayment(e)
return InitialRelationDTO{
Id: e.Id,
ReferenceNumber: reference,
TransactionType: transactionTypeLabel(e.TransactionType),
InitialBalanceType: initialBalanceType,
InitialBalanceTypeLabel: initialBalanceLabel(initialBalanceType),
Party: partyFromInitial(e),
Bank: bankFromInitial(e),
Direction: e.Direction,
Nominal: e.Nominal,
Notes: e.Notes,
}
}
func ToInitialListDTO(e entity.Payment) InitialListDTO {
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return InitialListDTO{
InitialRelationDTO: ToInitialRelationDTO(e),
CreatedBy: e.CreatedBy,
CreatedByUser: userFromInitial(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToInitialListDTOs(e []entity.Payment) []InitialListDTO {
result := make([]InitialListDTO, len(e))
for i, r := range e {
result[i] = ToInitialListDTO(r)
}
return result
}
func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO {
return InitialDetailDTO{
InitialListDTO: ToInitialListDTO(e),
}
}
func partyFromInitial(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
}
return party
}
func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromInitial(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func transactionTypeLabel(transactionType string) string {
if strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) {
return "Saldo Awal"
}
return transactionType
}
func initialBalanceLabel(balanceType string) string {
switch strings.ToUpper(strings.TrimSpace(balanceType)) {
case "NEGATIVE":
return "Saldo Awal Negatif"
case "POSITIVE":
return "Saldo Awal Positif"
default:
return balanceType
}
}
func initialBalanceTypeFromPayment(e entity.Payment) string {
if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 {
return "NEGATIVE"
}
return "POSITIVE"
}
@@ -0,0 +1,36 @@
package initials
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
rInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories"
sInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type InitialModule struct{}
func (InitialModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
initialRepo := rInitial.NewInitialRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register initial approval workflow: %v", err))
}
initialService := sInitial.NewInitialService(initialRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
InitialRoutes(router, userService, initialService)
}
@@ -0,0 +1,51 @@
package repository
import (
"context"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type InitialRepository interface {
repository.BaseRepository[entity.Payment]
BankExists(ctx context.Context, bankId uint) (bool, error)
CustomerExists(ctx context.Context, customerId uint) (bool, error)
SupplierExists(ctx context.Context, supplierId uint) (bool, error)
NextPaymentSequence(ctx context.Context) (int64, error)
}
type InitialRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
db *gorm.DB
}
func NewInitialRepository(db *gorm.DB) InitialRepository {
return &InitialRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
db: db,
}
}
func (r *InitialRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) {
return repository.Exists[entity.Bank](ctx, r.db, bankId)
}
func (r *InitialRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) {
return repository.Exists[entity.Customer](ctx, r.db, customerId)
}
func (r *InitialRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) {
return repository.Exists[entity.Supplier](ctx, r.db, supplierId)
}
func (r *InitialRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) {
var next int64
if err := r.db.WithContext(ctx).
Raw("SELECT nextval('payments_code_seq')").
Scan(&next).Error; err != nil {
return 0, err
}
return next, nil
}
@@ -0,0 +1,21 @@
package initials
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/controllers"
initial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func InitialRoutes(v1 fiber.Router, u user.UserService, s initial.InitialService) {
ctrl := controller.NewInitialController(s)
route := v1.Group("/initial-balances")
route.Use(m.Auth(u))
route.Post("/",m.RequirePermissions(m.P_Finances_Initial_Balances_CreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_Finances_Initial_Balances_GetOne), ctrl.GetOne)
route.Patch("/:id",m.RequirePermissions(m.P_Finances_Initial_Balances_UpdateOne), ctrl.UpdateOne)
}
@@ -0,0 +1,336 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type InitialService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error)
}
type initialService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.InitialRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
func NewInitialService(
repo repository.InitialRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) InitialService {
return &initialService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowInitial,
}
}
func (s initialService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse").
Preload("Customer").
Preload("Supplier")
}
func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
initial, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if err != nil {
s.Log.Errorf("Failed get initial by id: %+v", err)
return nil, err
}
if !isInitialTransaction(initial.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approval for initial %d: %+v", id, err)
} else {
initial.LatestApproval = approval
}
}
return initial, nil
}
func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
party, err := normalizePartyType(req.PartyType)
if err != nil {
return nil, err
}
if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil {
return nil, err
}
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
balanceType, err := normalizeInitialBalanceType(req.InitialBalanceType)
if err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
code, err := s.generateInitialCode(c.Context())
if err != nil {
return nil, err
}
reference := req.ReferenceNumber
createBody := &entity.Payment{
PaymentCode: code,
ReferenceNumber: &reference,
TransactionType: string(utils.TransactionTypeSaldoAwal),
PartyType: party,
PartyId: req.PartyId,
PaymentDate: time.Now(),
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
Direction: directionForInitialType(balanceType),
Nominal: signedNominal(balanceType, req.Nominal),
Notes: req.Note,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
initialRepoTx := repository.NewInitialRepository(dbTransaction)
if err := initialRepoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
if s.ApprovalSvc != nil {
action := entity.ApprovalActionCreated
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
_, err := approvalSvcTx.CreateApproval(
c.Context(),
s.approvalWorkflow,
createBody.Id,
utils.InitialStepPengajuan,
&action,
actorID,
nil,
)
if err != nil {
return err
}
}
return nil
})
if err != nil {
s.Log.Errorf("Failed to create initial: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.ReferenceNumber != nil {
updateBody["reference_number"] = *req.ReferenceNumber
}
if req.Note != nil {
updateBody["notes"] = *req.Note
}
if req.BankId != nil {
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
updateBody["bank_id"] = *req.BankId
}
requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil
requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil
var existing *entity.Payment
if requiresVerification {
current, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if err != nil {
s.Log.Errorf("Failed get initial by id: %+v", err)
return nil, err
}
if !isInitialTransaction(current.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
existing = current
}
if req.PartyType != nil || req.PartyId != nil {
partyType := existing.PartyType
partyId := existing.PartyId
if req.PartyType != nil {
normalized, err := normalizePartyType(*req.PartyType)
if err != nil {
return nil, err
}
partyType = normalized
updateBody["party_type"] = partyType
}
if req.PartyId != nil {
partyId = *req.PartyId
updateBody["party_id"] = partyId
}
if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil {
return nil, err
}
}
if req.InitialBalanceType != nil || req.Nominal != nil {
balanceType := balanceTypeFromPayment(existing)
if req.InitialBalanceType != nil {
normalized, err := normalizeInitialBalanceType(*req.InitialBalanceType)
if err != nil {
return nil, err
}
balanceType = normalized
}
nominal := math.Abs(existing.Nominal)
if req.Nominal != nil {
nominal = *req.Nominal
}
updateBody["direction"] = directionForInitialType(balanceType)
updateBody["nominal"] = signedNominal(balanceType, nominal)
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
s.Log.Errorf("Failed to update initial: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func isInitialTransaction(transactionType string) bool {
return strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal))
}
func balanceTypeFromPayment(payment *entity.Payment) string {
if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 {
return "NEGATIVE"
}
return "POSITIVE"
}
func normalizePartyType(partyType string) (string, error) {
party := strings.ToUpper(strings.TrimSpace(partyType))
if !utils.IsValidPaymentParty(party) {
return "", utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
return party, nil
}
func normalizeInitialBalanceType(balanceType string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(balanceType))
switch normalized {
case "NEGATIVE", "POSITIVE":
return normalized, nil
default:
return "", utils.BadRequest("`initial_balance_type` must be `NEGATIVE` or `POSITIVE`")
}
}
func directionForInitialType(balanceType string) string {
if strings.EqualFold(balanceType, "NEGATIVE") {
return "OUT"
}
return "IN"
}
func signedNominal(balanceType string, nominal float64) float64 {
normalized := math.Abs(nominal)
if strings.EqualFold(balanceType, "NEGATIVE") {
return -normalized
}
return normalized
}
func (s initialService) generateInitialCode(ctx context.Context) (string, error) {
sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("INIT-%05d", sequence), nil
}
func (s initialService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
func (s initialService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error {
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists},
)
case utils.PaymentPartySupplier:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists},
)
default:
return utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
}
func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) error {
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
)
}
@@ -0,0 +1,27 @@
package validation
type Create struct {
PartyType string `json:"party_type" validate:"required_strict,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"`
InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
Note string `json:"note" validate:"required_strict,max=500"`
}
type Update struct {
PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"`
PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
ReferenceNumber *string `json:"reference_number,omitempty" validate:"omitempty,max=100"`
InitialBalanceType *string `json:"initial_balance_type,omitempty" validate:"omitempty,oneof=NEGATIVE POSITIVE"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
Note *string `json:"note,omitempty" validate:"omitempty,max=500"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -0,0 +1,92 @@
package controller
import (
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type InjectionController struct {
InjectionService service.InjectionService
}
func NewInjectionController(injectionService service.InjectionService) *InjectionController {
return &InjectionController{
InjectionService: injectionService,
}
}
func (u *InjectionController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.InjectionService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get injection successfully",
Data: dto.ToInjectionListDTO(*result),
})
}
func (u *InjectionController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.InjectionService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Balance injection created successfully",
Data: dto.ToInjectionListDTO(*result),
})
}
func (u *InjectionController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.InjectionService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update injection successfully",
Data: dto.ToInjectionListDTO(*result),
})
}
@@ -0,0 +1,102 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type InjectionRelationDTO struct {
Id uint `json:"id"`
TransactionType string `json:"transaction_type"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
AdjustmentDate string `json:"adjustment_date"`
Direction string `json:"direction"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
}
type InjectionListDTO struct {
InjectionRelationDTO
CreatedBy uint `json:"created_by"`
CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type InjectionDetailDTO struct {
InjectionListDTO
}
// === Mapper Functions ===
func ToInjectionRelationDTO(e entity.Payment) InjectionRelationDTO {
return InjectionRelationDTO{
Id: e.Id,
TransactionType: transactionTypeLabel(e.TransactionType),
Bank: bankFromInjection(e),
AdjustmentDate: utils.FormatDate(e.PaymentDate),
Direction: e.Direction,
Nominal: e.Nominal,
Notes: e.Notes,
}
}
func ToInjectionListDTO(e entity.Payment) InjectionListDTO {
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return InjectionListDTO{
InjectionRelationDTO: ToInjectionRelationDTO(e),
CreatedBy: e.CreatedBy,
CreatedByUser: userFromInjection(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToInjectionListDTOs(e []entity.Payment) []InjectionListDTO {
result := make([]InjectionListDTO, len(e))
for i, r := range e {
result[i] = ToInjectionListDTO(r)
}
return result
}
func ToInjectionDetailDTO(e entity.Payment) InjectionDetailDTO {
return InjectionDetailDTO{
InjectionListDTO: ToInjectionListDTO(e),
}
}
func bankFromInjection(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromInjection(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func transactionTypeLabel(transactionType string) string {
if strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) {
return "Injection"
}
return transactionType
}
@@ -0,0 +1,36 @@
package injections
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
rInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories"
sInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type InjectionModule struct{}
func (InjectionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
injectionRepo := rInjection.NewInjectionRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register injection approval workflow: %v", err))
}
injectionService := sInjection.NewInjectionService(injectionRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
InjectionRoutes(router, userService, injectionService)
}
@@ -0,0 +1,41 @@
package repository
import (
"context"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type InjectionRepository interface {
repository.BaseRepository[entity.Payment]
BankExists(ctx context.Context, bankId uint) (bool, error)
NextPaymentSequence(ctx context.Context) (int64, error)
}
type InjectionRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
db *gorm.DB
}
func NewInjectionRepository(db *gorm.DB) InjectionRepository {
return &InjectionRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
db: db,
}
}
func (r *InjectionRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) {
return repository.Exists[entity.Bank](ctx, r.db, bankId)
}
func (r *InjectionRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) {
var next int64
if err := r.db.WithContext(ctx).
Raw("SELECT nextval('payments_code_seq')").
Scan(&next).Error; err != nil {
return 0, err
}
return next, nil
}
@@ -0,0 +1,21 @@
package injections
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/controllers"
injection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func InjectionRoutes(v1 fiber.Router, u user.UserService, s injection.InjectionService) {
ctrl := controller.NewInjectionController(s)
route := v1.Group("/injections")
route.Use(m.Auth(u))
route.Post("/", m.RequirePermissions(m.P_Finances_Injections_CreateOne), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_Finances_Injections_GetOne), ctrl.GetOne)
route.Patch("/:id", m.RequirePermissions(m.P_Finances_Injections_UpdateOne), ctrl.UpdateOne)
}
@@ -0,0 +1,230 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type InjectionService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error)
}
type injectionService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.InjectionRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
func NewInjectionService(
repo repository.InjectionRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) InjectionService {
return &injectionService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowInjection,
}
}
func (s injectionService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse")
}
func (s injectionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
injection, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
if err != nil {
s.Log.Errorf("Failed get injection by id: %+v", err)
return nil, err
}
if !isInjectionTransaction(injection.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approval for injection %d: %+v", id, err)
} else {
injection.LatestApproval = approval
}
}
return injection, nil
}
func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
adjustmentDate, err := utils.ParseDateString(req.AdjustmentDate)
if err != nil {
return nil, utils.BadRequest(err.Error())
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
code, err := s.generateInjectionCode(c.Context())
if err != nil {
return nil, err
}
createBody := &entity.Payment{
PaymentCode: code,
TransactionType: string(utils.TransactionTypeInjection),
PartyType: string(utils.PaymentPartyCustomer),
PartyId: 0,
PaymentDate: adjustmentDate,
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
Direction: "IN",
Nominal: req.Nominal,
Notes: req.Notes,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
injectionRepoTx := repository.NewInjectionRepository(dbTransaction)
if err := injectionRepoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
if s.ApprovalSvc != nil {
action := entity.ApprovalActionCreated
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
_, err := approvalSvcTx.CreateApproval(
c.Context(),
s.approvalWorkflow,
createBody.Id,
utils.InjectionStepPengajuan,
&action,
actorID,
nil,
)
if err != nil {
return err
}
}
return nil
})
if err != nil {
s.Log.Errorf("Failed to create injection: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
requiresVerification := req.BankId != nil || req.AdjustmentDate != nil || req.Nominal != nil || req.Notes != nil
if requiresVerification {
current, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
if err != nil {
s.Log.Errorf("Failed get injection by id: %+v", err)
return nil, err
}
if !isInjectionTransaction(current.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
}
if req.BankId != nil {
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
updateBody["bank_id"] = *req.BankId
}
if req.AdjustmentDate != nil {
parsedDate, err := utils.ParseDateString(*req.AdjustmentDate)
if err != nil {
return nil, utils.BadRequest(err.Error())
}
updateBody["payment_date"] = parsedDate
}
if req.Nominal != nil {
updateBody["nominal"] = *req.Nominal
}
if req.Notes != nil {
updateBody["notes"] = *req.Notes
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
s.Log.Errorf("Failed to update injection: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func isInjectionTransaction(transactionType string) bool {
return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection))
}
func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) {
sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("INJ-%05d", sequence), nil
}
func (s injectionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
func (s injectionService) ensureBankExists(ctx context.Context, bankId *uint) error {
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
)
}
@@ -0,0 +1,21 @@
package validation
type Create struct {
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
AdjustmentDate string `json:"adjustment_date" validate:"required_strict"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
}
type Update struct {
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
}
+13
View File
@@ -0,0 +1,13 @@
package finance
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type FinanceModule struct{}
func (FinanceModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
RegisterRoutes(router, db, validate)
}
@@ -0,0 +1,92 @@
package controller
import (
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type PaymentController struct {
PaymentService service.PaymentService
}
func NewPaymentController(paymentService service.PaymentService) *PaymentController {
return &PaymentController{
PaymentService: paymentService,
}
}
func (u *PaymentController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.PaymentService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get payment successfully",
Data: dto.ToPaymentListDTO(*result),
})
}
func (u *PaymentController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.PaymentService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create payment successfully",
Data: dto.ToPaymentListDTO(*result),
})
}
func (u *PaymentController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.PaymentService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update payment successfully",
Data: dto.ToPaymentListDTO(*result),
})
}
@@ -0,0 +1,189 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type PaymentRelationDTO struct {
Id uint `json:"id"`
PaymentCode string `json:"payment_code"`
ReferenceNumber *string `json:"reference_number,omitempty"`
TransactionType string `json:"transaction_type"`
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"`
}
type PaymentListDTO struct {
Id uint `json:"id"`
PaymentCode string `json:"payment_code"`
ReferenceNumber *string `json:"reference_number"`
TransactionType string `json:"transaction_type"`
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type PaymentDetailDTO struct {
PaymentListDTO
}
type Party struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
}
// === Mapper Functions ===
func ToPaymentRelationDTO(e entity.Payment) PaymentRelationDTO {
expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal)
return PaymentRelationDTO{
Id: e.Id,
PaymentCode: paymentCodeFromPayment(e),
ReferenceNumber: e.ReferenceNumber,
TransactionType: transactionTypeFromPayment(e),
Party: partyFromPayment(e),
PaymentDate: e.PaymentDate,
PaymentMethod: e.PaymentMethod,
Bank: bankFromPayment(e),
ExpenseAmount: expenseAmount,
IncomeAmount: incomeAmount,
Nominal: e.Nominal,
Notes: e.Notes,
CreatedUser: userFromPayment(e),
}
}
func ToPaymentListDTO(e entity.Payment) PaymentListDTO {
expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal)
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return PaymentListDTO{
Id: e.Id,
PaymentCode: paymentCodeFromPayment(e),
ReferenceNumber: e.ReferenceNumber,
TransactionType: transactionTypeFromPayment(e),
Party: partyFromPayment(e),
PaymentDate: e.PaymentDate,
PaymentMethod: e.PaymentMethod,
Bank: bankFromPayment(e),
ExpenseAmount: expenseAmount,
IncomeAmount: incomeAmount,
Nominal: e.Nominal,
Notes: e.Notes,
CreatedUser: userFromPayment(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToPaymentListDTOs(e []entity.Payment) []PaymentListDTO {
result := make([]PaymentListDTO, len(e))
for i, r := range e {
result[i] = ToPaymentListDTO(r)
}
return result
}
func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO {
return PaymentDetailDTO{
PaymentListDTO: ToPaymentListDTO(e),
}
}
func partyFromPayment(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
}
return party
}
func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromPayment(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func paymentCodeFromPayment(e entity.Payment) string {
if e.PaymentCode != "" {
return e.PaymentCode
}
if e.ReferenceNumber != nil {
return *e.ReferenceNumber
}
return ""
}
func transactionTypeFromPayment(e entity.Payment) string {
if e.TransactionType != "" {
return e.TransactionType
}
return e.Direction
}
func paymentAmounts(direction string, nominal float64) (float64, float64) {
switch strings.ToUpper(direction) {
case "IN":
return 0, nominal
case "OUT":
return nominal, 0
default:
return 0, 0
}
}
@@ -0,0 +1,36 @@
package payments
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gorm.io/gorm"
rPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories"
sPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type PaymentModule struct{}
func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
paymentRepo := rPayment.NewPaymentRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register payment approval workflow: %v", err))
}
paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
PaymentRoutes(router, userService, paymentService)
}
@@ -0,0 +1,62 @@
package repository
import (
"context"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type PaymentRepository interface {
repository.BaseRepository[entity.Payment]
BankExists(ctx context.Context, bankId uint) (bool, error)
CustomerExists(ctx context.Context, customerId uint) (bool, error)
SupplierExists(ctx context.Context, supplierId uint) (bool, error)
SupplierCategory(ctx context.Context, supplierId uint) (string, error)
NextPaymentSequence(ctx context.Context) (int64, error)
}
type PaymentRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
db *gorm.DB
}
func NewPaymentRepository(db *gorm.DB) PaymentRepository {
return &PaymentRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
db: db,
}
}
func (r *PaymentRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) {
return repository.Exists[entity.Bank](ctx, r.db, bankId)
}
func (r *PaymentRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) {
return repository.Exists[entity.Customer](ctx, r.db, customerId)
}
func (r *PaymentRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) {
return repository.Exists[entity.Supplier](ctx, r.db, supplierId)
}
func (r *PaymentRepositoryImpl) SupplierCategory(ctx context.Context, supplierId uint) (string, error) {
var supplier entity.Supplier
if err := r.db.WithContext(ctx).
Select("id", "category").
First(&supplier, supplierId).Error; err != nil {
return "", err
}
return supplier.Category, nil
}
func (r *PaymentRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) {
var next int64
if err := r.db.WithContext(ctx).
Raw("SELECT nextval('payments_code_seq')").
Scan(&next).Error; err != nil {
return 0, err
}
return next, nil
}
@@ -0,0 +1,21 @@
package payments
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/controllers"
payment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService) {
ctrl := controller.NewPaymentController(s)
route := v1.Group("/payments")
route.Use(m.Auth(u))
route.Post("/",m.RequirePermissions(m.P_Finances_Payments_CreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_Finances_Payments_GetOne), ctrl.GetOne)
route.Patch("/:id",m.RequirePermissions(m.P_Finances_Payments_UpdateOne), ctrl.UpdateOne)
}
@@ -0,0 +1,362 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type PaymentService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error)
}
type paymentService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.PaymentRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
func NewPaymentService(
repo repository.PaymentRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) PaymentService {
return &paymentService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowPayment,
}
}
func (s paymentService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse").
Preload("Customer").
Preload("Supplier")
}
func (s paymentService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
payment, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found")
}
if err != nil {
s.Log.Errorf("Failed get payment by id: %+v", err)
return nil, err
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approval for payment %d: %+v", id, err)
} else {
payment.LatestApproval = approval
}
}
return payment, nil
}
func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
//! CHECK PARTY TYPE
party, err := normalizePartyType(req.PartyType)
if err != nil {
return nil, err
}
//! CHECK EXISTS
if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil {
return nil, err
}
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
//? NORMALIZE
paymentDate, err := utils.ParseDateString(req.PaymentDate)
if err != nil {
return nil, utils.BadRequest(err.Error())
}
method, err := normalizePaymentMethod(req.PaymentMethod)
if err != nil {
return nil, err
}
transactionType, err := s.resolveTransactionType(c.Context(), party, req.PartyId)
if err != nil {
return nil, err
}
//? GET CREATED BY
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
code, err := s.generatePaymentCode(c.Context(), party)
if err != nil {
return nil, err
}
createBody := &entity.Payment{
PaymentCode: code,
ReferenceNumber: req.ReferenceNumber,
TransactionType: transactionType,
PartyType: party,
PartyId: req.PartyId,
PaymentDate: paymentDate,
PaymentMethod: method,
BankId: req.BankId,
Direction: directionForParty(party),
Nominal: req.Nominal,
Notes: req.Notes,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
paymentRepoTx := repository.NewPaymentRepository(dbTransaction)
if err := paymentRepoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
if s.ApprovalSvc != nil {
action := entity.ApprovalActionCreated
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
_, err := approvalSvcTx.CreateApproval(
c.Context(),
s.approvalWorkflow,
createBody.Id,
utils.PaymentStepPengajuan,
&action,
actorID,
nil,
)
if err != nil {
return err
}
}
return nil
})
if err != nil {
s.Log.Errorf("Failed to create payment: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.PaymentDate != nil {
parsedDate, err := utils.ParseDateString(*req.PaymentDate)
if err != nil {
return nil, utils.BadRequest(err.Error())
}
updateBody["payment_date"] = parsedDate
}
if req.Nominal != nil {
updateBody["nominal"] = *req.Nominal
}
if req.ReferenceNumber != nil {
updateBody["reference_number"] = *req.ReferenceNumber
}
if req.PaymentMethod != nil {
method, err := normalizePaymentMethod(*req.PaymentMethod)
if err != nil {
return nil, err
}
updateBody["payment_method"] = method
}
if req.BankId != nil {
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
updateBody["bank_id"] = *req.BankId
}
if req.Notes != nil {
updateBody["notes"] = *req.Notes
}
if req.PartyType != nil || req.PartyId != nil {
existing, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found")
}
if err != nil {
s.Log.Errorf("Failed get payment by id: %+v", err)
return nil, err
}
partyType := existing.PartyType
partyId := existing.PartyId
if req.PartyType != nil {
normalized, err := normalizePartyType(*req.PartyType)
if err != nil {
return nil, err
}
partyType = normalized
updateBody["party_type"] = partyType
updateBody["direction"] = directionForParty(partyType)
}
if req.PartyId != nil {
partyId = *req.PartyId
updateBody["party_id"] = partyId
}
if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil {
return nil, err
}
transactionType, err := s.resolveTransactionType(c.Context(), partyType, partyId)
if err != nil {
return nil, err
}
updateBody["transaction_type"] = transactionType
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found")
}
s.Log.Errorf("Failed to update payment: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func normalizePartyType(partyType string) (string, error) {
party := strings.ToUpper(strings.TrimSpace(partyType))
if !utils.IsValidPaymentParty(party) {
return "", utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
return party, nil
}
func normalizePaymentMethod(method string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(method))
if !utils.IsValidPaymentMethod(normalized) {
return "", utils.BadRequest("Invalid payment_method")
}
return normalized, nil
}
func directionForParty(partyType string) string {
if utils.PaymentParty(partyType) == utils.PaymentPartyCustomer {
return "IN"
}
return "OUT"
}
func (s paymentService) resolveTransactionType(ctx context.Context, partyType string, partyId uint) (string, error) {
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
return string(utils.TransactionTypePenjualan), nil
case utils.PaymentPartySupplier:
category, err := s.getSupplierCategory(ctx, partyId)
if err != nil {
return "", err
}
if isSupplierCategoryBiaya(category) {
return string(utils.TransactionTypeBiaya), nil
}
return string(utils.TransactionTypePembelian), nil
default:
return "", utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
}
func (s paymentService) generatePaymentCode(ctx context.Context, partyType string) (string, error) {
prefix := "PAY"
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
prefix = "PAY-IN"
case utils.PaymentPartySupplier:
prefix = "PAY-OUT"
}
sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("%s-%05d", prefix, sequence), nil
}
func (s paymentService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
func (s paymentService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error {
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists},
)
case utils.PaymentPartySupplier:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists},
)
default:
return utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
}
func (s paymentService) ensureBankExists(ctx context.Context, bankId *uint) error {
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
)
}
func (s paymentService) getSupplierCategory(ctx context.Context, supplierId uint) (string, error) {
category, err := s.Repository.SupplierCategory(ctx, supplierId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", utils.NotFound("Supplier not found")
}
return "", err
}
return strings.ToUpper(strings.TrimSpace(category)), nil
}
func isSupplierCategoryBiaya(category string) bool {
switch strings.ToUpper(strings.TrimSpace(category)) {
case string(utils.SupplierCategoryBOP), "BIAYA":
return true
default:
return false
}
}
@@ -0,0 +1,29 @@
package validation
type Create struct {
PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"`
Nominal float64 `json:"nominal" validate:"required_strict"`
ReferenceNumber *string `json:"reference_number,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"`
BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
}
type Update struct {
PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"`
PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"`
PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
ReferenceNumber *string `json:"reference_number,omitempty"`
PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
}
+31
View File
@@ -0,0 +1,31 @@
package finance
import (
"gitlab.com/mbugroup/lti-api.git/internal/modules"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
payments "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments"
initials "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials"
injections "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections"
transactions "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions"
// MODULE IMPORTS
)
func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
group := router.Group("/finance")
allModules := []modules.Module{
payments.PaymentModule{},
initials.InitialModule{},
injections.InjectionModule{},
transactions.TransactionModule{},
// MODULE REGISTRY
}
for _, m := range allModules {
m.RegisterRoutes(group, db, validate)
}
}
@@ -0,0 +1,96 @@
package controller
import (
"math"
"strconv"
"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"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type TransactionController struct {
TransactionService service.TransactionService
}
func NewTransactionController(transactionService service.TransactionService) *TransactionController {
return &TransactionController{
TransactionService: transactionService,
}
}
func (u *TransactionController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.TransactionService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.TransactionListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all transactions successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToTransactionListDTOs(result),
})
}
func (u *TransactionController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.TransactionService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get transaction successfully",
Data: dto.ToTransactionListDTO(*result),
})
}
func (u *TransactionController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.TransactionService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete transaction successfully",
})
}
@@ -0,0 +1,189 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type TransactionRelationDTO struct {
Id uint `json:"id"`
PaymentCode string `json:"payment_code"`
ReferenceNumber *string `json:"reference_number,omitempty"`
TransactionType string `json:"transaction_type"`
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"`
}
type TransactionListDTO struct {
Id uint `json:"id"`
PaymentCode string `json:"payment_code"`
ReferenceNumber *string `json:"reference_number"`
TransactionType string `json:"transaction_type"`
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type TransactionDetailDTO struct {
TransactionListDTO
}
type Party struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
}
// === Mapper Functions ===
func ToTransactionRelationDTO(e entity.Payment) TransactionRelationDTO {
expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal)
return TransactionRelationDTO{
Id: e.Id,
PaymentCode: paymentCodeFromPayment(e),
ReferenceNumber: e.ReferenceNumber,
TransactionType: transactionTypeFromPayment(e),
Party: partyFromPayment(e),
PaymentDate: e.PaymentDate,
PaymentMethod: e.PaymentMethod,
Bank: bankFromPayment(e),
ExpenseAmount: expenseAmount,
IncomeAmount: incomeAmount,
Nominal: e.Nominal,
Notes: e.Notes,
CreatedUser: userFromPayment(e),
}
}
func ToTransactionListDTO(e entity.Payment) TransactionListDTO {
expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal)
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return TransactionListDTO{
Id: e.Id,
PaymentCode: paymentCodeFromPayment(e),
ReferenceNumber: e.ReferenceNumber,
TransactionType: transactionTypeFromPayment(e),
Party: partyFromPayment(e),
PaymentDate: e.PaymentDate,
PaymentMethod: e.PaymentMethod,
Bank: bankFromPayment(e),
ExpenseAmount: expenseAmount,
IncomeAmount: incomeAmount,
Nominal: e.Nominal,
Notes: e.Notes,
CreatedUser: userFromPayment(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToTransactionListDTOs(e []entity.Payment) []TransactionListDTO {
result := make([]TransactionListDTO, len(e))
for i, r := range e {
result[i] = ToTransactionListDTO(r)
}
return result
}
func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO {
return TransactionDetailDTO{
TransactionListDTO: ToTransactionListDTO(e),
}
}
func partyFromPayment(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
}
return party
}
func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromPayment(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func paymentCodeFromPayment(e entity.Payment) string {
if e.PaymentCode != "" {
return e.PaymentCode
}
if e.ReferenceNumber != nil {
return *e.ReferenceNumber
}
return ""
}
func transactionTypeFromPayment(e entity.Payment) string {
if e.TransactionType != "" {
return e.TransactionType
}
return e.Direction
}
func paymentAmounts(direction string, nominal float64) (float64, float64) {
switch strings.ToUpper(direction) {
case "IN":
return 0, nominal
case "OUT":
return nominal, 0
default:
return 0, 0
}
}
@@ -0,0 +1,42 @@
package transactions
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
rTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories"
sTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type TransactionModule struct{}
func (TransactionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
transactionRepo := rTransaction.NewTransactionRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register payment approval workflow: %v", err))
}
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register initial approval workflow: %v", err))
}
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register injection approval workflow: %v", err))
}
transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
TransactionRoutes(router, userService, transactionService)
}
@@ -0,0 +1,21 @@
package repository
import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type TransactionRepository interface {
repository.BaseRepository[entity.Payment]
}
type TransactionRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
}
func NewTransactionRepository(db *gorm.DB) TransactionRepository {
return &TransactionRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
}
}
@@ -0,0 +1,21 @@
package transactions
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/controllers"
transaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func TransactionRoutes(v1 fiber.Router, u user.UserService, s transaction.TransactionService) {
ctrl := controller.NewTransactionController(s)
route := v1.Group("/transactions")
route.Use(m.Auth(u))
route.Get("/",m.RequirePermissions(m.P_Finances_Transaction_GetAll), ctrl.GetAll)
route.Get("/:id",m.RequirePermissions(m.P_Finances_Transaction_GetOne), ctrl.GetOne)
route.Delete("/:id",m.RequirePermissions(m.P_Finances_Transaction_DeleteOne), ctrl.DeleteOne)
}
@@ -0,0 +1,175 @@
package service
import (
"context"
"errors"
"strings"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type TransactionService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
}
type transactionService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.TransactionRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey
}
func NewTransactionService(
repo repository.TransactionRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) TransactionService {
return &transactionService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{
string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial,
string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection,
},
}
}
func (s transactionService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse").
Preload("Customer").
Preload("Supplier")
}
func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
return db.Where(
`LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR
LOWER(COALESCE(notes, '')) LIKE ?`,
like, like, like, like,
)
}
return db.Order("payment_date DESC").Order("created_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get transactions: %+v", err)
return nil, 0, err
}
s.attachApprovals(c.Context(), transactions)
return transactions, total, nil
}
func (s transactionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
transaction, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Transaction not found")
}
if err != nil {
s.Log.Errorf("Failed get transaction by id: %+v", err)
return nil, err
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(
c.Context(),
s.workflowForTransaction(transaction),
id,
s.approvalQueryModifier(),
)
if err != nil {
s.Log.Warnf("Unable to load latest approval for transaction %d: %+v", id, err)
} else {
transaction.LatestApproval = approval
}
}
return transaction, nil
}
func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Transaction not found")
}
s.Log.Errorf("Failed to delete transaction: %+v", err)
return err
}
return nil
}
func (s transactionService) attachApprovals(ctx context.Context, transactions []entity.Payment) {
if s.ApprovalSvc == nil || len(transactions) == 0 {
return
}
workflowIDs := map[approvalutils.ApprovalWorkflowKey][]uint{}
for _, transaction := range transactions {
workflow := s.workflowForTransaction(&transaction)
workflowIDs[workflow] = append(workflowIDs[workflow], transaction.Id)
}
approvalByWorkflow := make(map[approvalutils.ApprovalWorkflowKey]map[uint]*entity.Approval, len(workflowIDs))
for workflow, ids := range workflowIDs {
if len(ids) == 0 {
continue
}
approvals, err := s.ApprovalSvc.LatestByTargets(ctx, workflow, ids, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approvals for transactions: %+v", err)
continue
}
approvalByWorkflow[workflow] = approvals
}
for i := range transactions {
workflow := s.workflowForTransaction(&transactions[i])
if approvals, ok := approvalByWorkflow[workflow]; ok {
transactions[i].LatestApproval = approvals[transactions[i].Id]
}
}
}
func (s transactionService) workflowForTransaction(transaction *entity.Payment) approvalutils.ApprovalWorkflowKey {
if transaction == nil {
return utils.ApprovalWorkflowPayment
}
transactionType := strings.TrimSpace(strings.ToUpper(transaction.TransactionType))
if transactionType == "" {
return utils.ApprovalWorkflowPayment
}
if workflow, ok := s.approvalWorkflows[transactionType]; ok {
return workflow
}
return utils.ApprovalWorkflowPayment
}
func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
@@ -0,0 +1,15 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -5,6 +5,9 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
@@ -13,19 +16,67 @@ import (
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
)
type AdjustmentModule struct{}
func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
// Repositories
stockLogsRepo := rStockLogs.NewStockLogRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
userRepo := rUser.NewUserRepository(db)
productRepo := rproduct.NewProductRepository(db)
adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo)
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKey("ADJUSTMENT_IN"),
Table: "adjustment_stocks",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error())
}
err = fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKey("ADJUSTMENT_OUT"),
Table: "adjustment_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error())
}
adjustmentService := sAdjustment.NewAdjustmentService(
productRepo,
stockLogsRepo,
warehouseRepo,
productWarehouseRepo,
adjustmentStockRepo,
fifoService,
validate,
projectFlockKandangRepo,
)
userService := sUser.NewUserService(userRepo, validate)
AdjustmentRoutes(router, userService, adjustmentService)
@@ -0,0 +1,50 @@
package repositories
import (
"context"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB
}
type adjustmentStockRepositoryImpl struct {
db *gorm.DB
}
func NewAdjustmentStockRepository(db *gorm.DB) AdjustmentStockRepository {
return &adjustmentStockRepositoryImpl{db: db}
}
func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error {
q := r.db.WithContext(ctx)
if modifier != nil {
q = modifier(q)
}
return q.Create(data).Error
}
func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock
err := r.db.WithContext(ctx).
Where("stock_log_id = ?", stockLogID).
First(&record).Error
if err != nil {
return nil, err
}
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository {
return &adjustmentStockRepositoryImpl{db: tx}
}
func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB {
return r.db
}
@@ -12,6 +12,7 @@ import (
common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations"
ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
@@ -29,24 +30,37 @@ type AdjustmentService interface {
}
type adjustmentService struct {
Log *logrus.Logger
Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
Log *logrus.Logger
Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoSvc common.FifoService
}
func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) AdjustmentService {
func NewAdjustmentService(
productRepo productRepo.ProductRepository,
stockLogsRepo stockLogsRepo.StockLogRepository,
warehouseRepo warehouseRepo.WarehouseRepository,
productWarehouseRepo ProductWarehouse.ProductWarehouseRepository,
adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository,
fifoSvc common.FifoService,
validate *validator.Validate,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
) AdjustmentService {
return &adjustmentService{
Log: utils.Log,
Validate: validate,
StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
Log: utils.Log,
Validate: validate,
StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
AdjustmentStockRepository: adjustmentStockRepo,
FifoSvc: fifoSvc,
}
}
@@ -103,39 +117,37 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
var createdLogId uint
isProductWarehouseExist, err := s.ProductWarehouseRepo.ProductWarehouseExistByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID))
if err != nil {
s.Log.Errorf("Failed to check product warehouse existence: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse")
var projectFlockKandangID *uint
pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(req.WarehouseID))
if err == nil && pfk != nil {
idCopy := uint(pfk.Id)
projectFlockKandangID = &idCopy
}
if !isProductWarehouseExist {
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID))
if err != nil {
return nil, err
pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(
ctx,
uint(req.ProductID),
uint(req.WarehouseID),
projectFlockKandangID,
)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to find product warehouse: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
}
newPW := &entity.ProductWarehouse{
ProductId: uint(req.ProductID),
WarehouseId: uint(req.WarehouseID),
Quantity: 0,
ProjectFlockKandangId: &projectFlockKandangID,
// CreatedBy: 1, // TODO: should Get from auth middleware
ProjectFlockKandangId: projectFlockKandangID,
}
if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil {
s.Log.Errorf("Failed to create product warehouse: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse")
}
s.Log.Infof("Product warehouse created: %+v", newPW.Id)
}
pw, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
ctx,
uint(req.ProductID),
uint(req.WarehouseID),
)
if err != nil {
s.Log.Errorf("Failed to get product warehouse for project flock check: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse")
pw = newPW
}
if err := common.EnsureProjectFlockNotClosedForProductWarehouses(
@@ -152,15 +164,16 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
}
// Create StockLog for history tracking
afterQuantity := productWarehouse.Quantity
newLog := &entity.StockLog{
LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: 0,
Notes: req.Note,
ProductWarehouseId: productWarehouse.Id,
CreatedBy: actorID, // TODO: should Get from auth middleware
CreatedBy: actorID,
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
afterQuantity += req.Quantity
newLog.Increase = afterQuantity
@@ -177,6 +190,57 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return err
}
// Create AdjustmentStock record for FIFO tracking
adjustmentStock := &entity.AdjustmentStock{
StockLogId: newLog.Id,
ProductWarehouseId: productWarehouse.Id,
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
// Adjustment INCREASE → Replenish stock (Stockable)
note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id)
replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{
StockableKey: "ADJUSTMENT_IN",
StockableID: newLog.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
Note: &note,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err))
}
// Update stockable tracking fields
adjustmentStock.TotalQty = replenishResult.AddedQuantity
adjustmentStock.TotalUsed = 0
} else {
// Adjustment DECREASE → Consume stock (Usable)
consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
UsableKey: "ADJUSTMENT_OUT",
UsableID: newLog.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
AllowPending: false, // Don't allow pending for adjustment
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err))
}
// Update usable tracking fields
adjustmentStock.UsageQty = consumeResult.UsageQuantity
adjustmentStock.PendingQty = consumeResult.PendingQuantity
}
// Save AdjustmentStock record
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
s.Log.Errorf("Failed to create adjustment stock: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
}
// Update ProductWarehouse quantity (for backward compatibility/reporting)
productWarehouse.Quantity = afterQuantity
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil {
s.Log.Errorf("Failed to update product warehouse quantity: %+v", err)
@@ -24,11 +24,12 @@ type ProductWarehousNestedDTO struct {
type ProductWarehouseListDTO struct {
ProductWarehouseRelationDTO
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserRelationDTO struct {
@@ -71,6 +72,19 @@ type AreaRelationDTO struct {
Name string `json:"name"`
}
type ProjectFlockKandangRelationDTO struct {
Id uint `json:"id"`
ProjectFlockId uint `json:"project_flock_id"`
KandangId uint `json:"kandang_id"`
Period int `json:"period"`
ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"`
}
type ProjectFlockRelationDTO struct {
Id uint `json:"id"`
FlockName string `json:"flock_name"`
}
// === Mapper Functions ===
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
@@ -105,6 +119,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
// Map Product relation jika ada
if e.Product.Id != 0 {
product := productDTO.ToProductRelationDTO(e.Product)
// Tambahkan flock name ke product name jika ada project flock
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")"
}
dto.Product = &product
}
@@ -139,6 +159,26 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
dto.Warehouse = &warehouse
}
// Map ProjectFlockKandang relation jika ada
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.Id != 0 {
pfkDTO := &ProjectFlockKandangRelationDTO{
Id: e.ProjectFlockKandang.Id,
ProjectFlockId: e.ProjectFlockKandang.ProjectFlockId,
KandangId: e.ProjectFlockKandang.KandangId,
Period: e.ProjectFlockKandang.Period,
}
// Map ProjectFlock jika ada
if e.ProjectFlockKandang.ProjectFlock.Id != 0 {
pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{
Id: e.ProjectFlockKandang.ProjectFlock.Id,
FlockName: e.ProjectFlockKandang.ProjectFlock.FlockName,
}
}
dto.ProjectFlockKandang = pfkDTO
}
// Map CreatedUser relation jika ada
// if e.CreatedUser.Id != 0 {
// user := UserRelationDTO{
@@ -18,6 +18,7 @@ type ProductWarehouseRepository interface {
ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error)
ExistsByID(ctx context.Context, id uint) (bool, error)
GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error)
FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error)
GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error)
GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error)
GetByFlagAndWarehouseID(ctx context.Context, flagName string, warehouseId uint) ([]entity.ProductWarehouse, error)
@@ -83,9 +84,43 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil {
err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId).
Order("id DESC").
Preload("ProjectFlockKandang").
First(&productWarehouse).Error
if err == nil {
if productWarehouse.ProjectFlockKandang.ClosedAt == nil {
return &productWarehouse, nil
}
}
err = r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId).
First(&productWarehouse).Error
if err != nil {
return nil, err
}
return &productWarehouse, nil
}
func (r *ProductWarehouseRepositoryImpl) FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT DISTINCT FROM ?", productID, warehouseID, projectFlockKandangID).
First(&productWarehouse).Error
if err != nil {
return nil, err
}
return &productWarehouse, nil
}
@@ -270,6 +305,8 @@ func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id u
Preload("Warehouse").
Preload("Warehouse.Area").
Preload("Warehouse.Location").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock").
First(&productWarehouse, id).Error
if err != nil {
return nil, err
@@ -44,7 +44,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Warehouse.Location").
Preload("Warehouse.Area").
Preload("Warehouse.Kandang").
Preload("ProjectFlockKandang")
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock")
}
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
@@ -159,7 +159,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
Id: d.Product.Id,
Name: d.Product.Name,
},
Quantity: d.Quantity,
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
})
}
@@ -229,7 +229,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
Id: d.Product.Id,
Name: d.Product.Name,
},
Quantity: d.Quantity,
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
})
}
+41 -1
View File
@@ -18,6 +18,8 @@ import (
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
)
type TransferModule struct{}
@@ -34,13 +36,51 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(err)
}
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc)
// Initialize FIFO Service
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
// Register Transfer as Stockable (adds stock to destination warehouse)
err = fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKey("STOCK_TRANSFER_IN"),
Table: "stock_transfer_details",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "dest_product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic(err)
}
// Register Transfer as Usable (consumes stock from source warehouse)
err = fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKey("STOCK_TRANSFER_OUT"),
Table: "stock_transfer_details",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic(err)
}
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService)
userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService)
@@ -44,9 +44,10 @@ type transferService struct {
WarehouseRepo warehouseRepo.WarehouseRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService
FifoSvc commonSvc.FifoService
}
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService) TransferService {
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService {
return &transferService{
Log: utils.Log,
Validate: validate,
@@ -60,6 +61,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
FifoSvc: fifoSvc,
}
}
@@ -104,28 +106,23 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
}
func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) {
s.Log.Infof("Attempting to get StockTransfer with ID: %d", id)
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db)
})
if err != nil {
s.Log.Errorf("Error getting StockTransfer ID %d: %+v", id, err)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer")
}
if transferPtr != nil {
s.Log.Infof("StockTransfer %d has %d documents", transferPtr.Id, len(transferPtr.Documents))
}
return transferPtr, nil
}
func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) {
// === VALIDASI SOURCE WAREHOUSE ===
pwIDs := make([]uint, 0, len(req.Products))
for _, product := range req.Products {
@@ -152,6 +149,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, err
}
destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID))
if err != nil {
return nil, err
}
if s.ProjectFlockKandangRepo != nil {
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing")
}
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
@@ -206,14 +218,62 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return err
}
var details []*entity.StockTransferDetail
// Prepare details and fetch product warehouses
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail)
for _, product := range req.Products {
details = append(details, &entity.StockTransferDetail{
// Get source product warehouse
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID))
}
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source")
}
// Get or create destination product warehouse
destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context()
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
if err != nil {
return err
}
destPW = &entity.ProductWarehouse{
ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0,
ProjectFlockKandangId: &projectFlockKandangID,
}
if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination")
}
}
detail := &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
Quantity: product.ProductQty,
})
SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(),
UsageQty: 0,
PendingQty: 0,
DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(),
TotalQty: 0,
TotalUsed: 0,
}
details = append(details, detail)
detailMap[uint64(product.ProductID)] = detail
}
if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil {
return err
}
@@ -233,23 +293,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return err
}
detailMap := make(map[uint64]uint64)
for _, d := range details {
detailMap[d.ProductId] = d.Id
}
var deliveryItems []*entity.StockTransferDeliveryItem
for i, delivery := range deliveries {
item := req.Deliveries[i]
for _, prod := range item.Products {
detailID, ok := detailMap[uint64(prod.ProductID)]
detail, ok := detailMap[uint64(prod.ProductID)]
if !ok {
return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID)
}
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
StockTransferDeliveryId: delivery.Id,
StockTransferDetailId: detailID,
StockTransferDetailId: detail.Id,
Quantity: prod.ProductQty,
})
}
@@ -275,74 +330,61 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d", idx+1))
s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)",
idx+1, deliveries[idx].Id, file.Filename)
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", idx+1, err))
}
}
}
// Execute FIFO operations for each product
for _, product := range req.Products {
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID))
detail := detailMap[uint64(product.ProductID)]
// Step 1: Consume stock from source warehouse (STOCK_TRANSFER_OUT)
consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: "STOCK_TRANSFER_OUT",
UsableID: uint(detail.Id),
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Quantity: product.ProductQty,
AllowPending: false, // Don't allow pending, must have actual stock
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse")
}
if sourcePW.Quantity < product.ProductQty {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID))
}
sourcePW.Quantity -= product.ProductQty
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil {
return err
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err))
}
decreaseLog := &entity.StockLog{
Decrease: product.ProductQty,
Notes: "",
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(entityTransfer.Id),
ProductWarehouseId: sourcePW.Id,
CreatedBy: actorID,
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil {
return err
// Update usage tracking fields for source warehouse
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": consumeResult.UsageQuantity,
"pending_qty": consumeResult.PendingQuantity,
}).Error; err != nil {
return fmt.Errorf("gagal update usage tracking: %w", err)
}
destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse")
}
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context()
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
if err != nil {
return err
}
destPW = &entity.ProductWarehouse{
ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0,
ProjectFlockKandangId: &projectFlockKandangID,
}
if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse")
}
// Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN)
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: "STOCK_TRANSFER_IN",
StockableID: uint(detail.Id),
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
Quantity: product.ProductQty,
Note: &note,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err))
}
destPW.Quantity += product.ProductQty
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil {
return err
}
increaseLog := &entity.StockLog{
Increase: product.ProductQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(entityTransfer.Id),
Notes: "",
ProductWarehouseId: destPW.Id,
CreatedBy: actorID,
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil {
return err
// Update total tracking fields for destination warehouse
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"total_qty": replenishResult.AddedQuantity,
}).Error; err != nil {
return fmt.Errorf("gagal update total tracking: %w", err)
}
}
@@ -350,7 +392,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
})
if err != nil {
s.Log.Errorf("Transaction failed in CreateOne: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err))
}
+1 -8
View File
@@ -33,18 +33,15 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
customerRepo := rCustomer.NewCustomerRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
// Initialize FIFO service
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
// Register marketing_delivery_products as FIFO Usable
// Note: ProductWarehouseID comes from marketing_products table via preload
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyMarketingDelivery,
Table: "marketing_delivery_products",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id", // Resolved from marketing_products via preload
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
@@ -55,11 +52,9 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
}
}
// Initialize approval service
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo)
// Register workflow steps for marketing approval
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err))
}
@@ -67,11 +62,9 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
// Initialize services
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate)
userService := sUser.NewUserService(userRepo, validate)
// Register routes
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
}
@@ -247,11 +247,15 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
itemDeliveryDate = &parsedDate
}
// Hitung total_weight dan total_price otomatis
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
deliveryProduct.TotalWeight = requestedProduct.TotalWeight
deliveryProduct.TotalPrice = requestedProduct.TotalPrice
deliveryProduct.TotalWeight = totalWeight
deliveryProduct.TotalPrice = totalPrice
deliveryProduct.DeliveryDate = itemDeliveryDate
deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber
@@ -357,11 +361,15 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
// Hitung total_weight dan total_price otomatis
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
deliveryProduct.TotalWeight = requestedProduct.TotalWeight
deliveryProduct.TotalPrice = requestedProduct.TotalPrice
deliveryProduct.TotalWeight = totalWeight
deliveryProduct.TotalPrice = totalPrice
deliveryProduct.DeliveryDate = itemDeliveryDate
deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber
@@ -75,7 +75,6 @@ func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, er
return nil, fiber.NewError(fiber.StatusNotFound, "SalesOrders not found")
}
if err != nil {
s.Log.Errorf("Failed get marketing by id: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order")
}
@@ -293,13 +292,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, rp := range req.MarketingProducts {
if old, ok := oldByPW[rp.ProductWarehouseId]; ok {
// Hitung total_weight dan total_price otomatis
totalWeight := rp.Qty * rp.AvgWeight
totalPrice := rp.UnitPrice * rp.Qty
updateBody := map[string]any{
"product_warehouse_id": rp.ProductWarehouseId,
"qty": rp.Qty,
"unit_price": rp.UnitPrice,
"avg_weight": rp.AvgWeight,
"total_weight": rp.TotalWeight,
"total_price": rp.TotalPrice,
"total_weight": totalWeight,
"total_price": totalPrice,
}
if err := marketingProductRepoTx.PatchOne(c.Context(), old.Id, updateBody, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product")
@@ -589,14 +592,18 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
// Hitung total_weight dan total_price otomatis
totalWeight := rp.Qty * rp.AvgWeight
totalPrice := rp.UnitPrice * rp.Qty
marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId,
ProductWarehouseId: rp.ProductWarehouseId,
Qty: rp.Qty,
UnitPrice: rp.UnitPrice,
AvgWeight: rp.AvgWeight,
TotalWeight: rp.TotalWeight,
TotalPrice: rp.TotalPrice,
TotalWeight: totalWeight,
TotalPrice: totalPrice,
}
if err := marketingProductRepo.CreateOne(ctx, marketingProduct, nil); err != nil {
return err
@@ -604,6 +611,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
marketingDeliveryProduct := &entity.MarketingDeliveryProduct{
MarketingProductId: marketingProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
UnitPrice: 0,
TotalWeight: 0,
AvgWeight: 0,
@@ -5,8 +5,6 @@ type DeliveryProduct struct {
Qty float64 `json:"qty" validate:"omitempty,gte=0"`
UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"`
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"`
TotalWeight float64 `json:"total_weight" validate:"omitempty,gte=0"`
TotalPrice float64 `json:"total_price" validate:"omitempty,gte=0"`
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"`
}
@@ -12,10 +12,8 @@ type CreateMarketingProduct struct {
VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"`
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"`
UnitPrice float64 `json:"unit_price" validate:"required,gt=0"`
TotalWeight float64 `json:"total_weight" validate:"required,gt=0"`
Qty float64 `json:"qty" validate:"required,gt=0"`
AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"`
TotalPrice float64 `json:"total_price" validate:"required,gt=0"`
}
type Update struct {
@@ -84,6 +84,7 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO {
Name: e.Name,
Status: e.Status,
Location: location,
Capacity: e.Capacity,
Pic: pic,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
@@ -1,11 +1,12 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"time"
)
// === DTO Structs ===
@@ -22,7 +23,7 @@ type NonstockListDTO struct {
Name string `json:"name"`
Flags []string `json:"flags"`
Uom *uomDTO.UomRelationDTO `json:"uom"`
Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers,omitempty"`
Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -100,7 +101,7 @@ func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO {
func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.SupplierRelationDTO {
if len(relations) == 0 {
return nil
return make([]supplierDTO.SupplierRelationDTO, 0)
}
result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations))
@@ -112,7 +113,7 @@ func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.S
}
if len(result) == 0 {
return nil
return make([]supplierDTO.SupplierRelationDTO, 0)
}
return result
@@ -3,8 +3,8 @@ package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3,max=50"`
UomID uint `json:"uom_id" validate:"required,gt=0"`
SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"`
Flags []string `json:"flags,omitempty" validate:"omitempty,dive,max=50"`
SupplierIDs []uint `json:"supplier_ids" validate:"dive,gt=0"`
Flags []string `json:"flags" validate:"dive,max=50"`
}
type Update struct {
@@ -40,7 +40,7 @@ func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error {
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.ProductionStandardListDTO]{
JSON(response.SuccessWithPaginate[dto.ProductionStandardRelationDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all productionStandards successfully",
@@ -7,7 +7,7 @@ import (
// === DTO Structs ===
type ProductionStandardListDTO struct {
type ProductionStandardRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
ProjectCategory string `json:"project_category"`
@@ -15,7 +15,7 @@ type ProductionStandardListDTO struct {
}
type ProductionStandardDetailDTO struct {
ProductionStandardListDTO
ProductionStandardRelationDTO
Details []WeeklyProductionStandardDTO `json:"details"`
}
@@ -33,6 +33,7 @@ type EggProductionStandardDetailDTO struct {
TargetHenHouseProduction *float64 `json:"target_hen_house_production"`
TargetEggWeight *float64 `json:"target_egg_weight"`
TargetEggMass *float64 `json:"target_egg_mass"`
StandardFCR *float64 `json:"standard_fcr"`
}
type WeeklyProductionStandardDTO struct {
@@ -43,14 +44,14 @@ type WeeklyProductionStandardDTO struct {
// === Mapper Functions ===
func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardListDTO {
func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardRelationDTO {
var createdUser *userDTO.UserRelationDTO
if e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
createdUser = &mapped
}
return ProductionStandardListDTO{
return ProductionStandardRelationDTO{
Id: e.Id,
Name: e.Name,
ProjectCategory: e.ProjectCategory,
@@ -58,8 +59,16 @@ func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandard
}
}
func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardListDTO {
result := make([]ProductionStandardListDTO, len(e))
func ToProductionStandardRelationDTO(e entity.ProductionStandard) ProductionStandardRelationDTO {
return ProductionStandardRelationDTO{
Id: e.Id,
Name: e.Name,
ProjectCategory: e.ProjectCategory,
}
}
func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardRelationDTO {
result := make([]ProductionStandardRelationDTO, len(e))
for i, r := range e {
result[i] = ToProductionStandardListDTO(r)
}
@@ -87,6 +96,7 @@ func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail
TargetHenHouseProduction: detail.TargetHenHouseProduction,
TargetEggWeight: detail.TargetEggWeight,
TargetEggMass: detail.TargetEggMass,
StandardFCR: detail.StandardFCR,
}
return WeeklyProductionStandardDTO{
@@ -140,6 +150,7 @@ func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProd
TargetHenHouseProduction: e.TargetHenHouseProduction,
TargetEggWeight: e.TargetEggWeight,
TargetEggMass: e.TargetEggMass,
StandardFCR: e.StandardFCR,
}
}
@@ -149,7 +160,7 @@ func ToProductionStandardDetailDTO(
productionStandardDetails []entity.ProductionStandardDetail,
) ProductionStandardDetailDTO {
return ProductionStandardDetailDTO{
ProductionStandardListDTO: ToProductionStandardListDTO(standard),
ProductionStandardRelationDTO: ToProductionStandardRelationDTO(standard),
Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails),
}
}
@@ -15,9 +15,9 @@ func ProductionStandardRoutes(v1 fiber.Router, u user.UserService, s productionS
route := v1.Group("/production-standards")
route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne)
route.Get("/", m.RequirePermissions(m.P_Production_Standart_GetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_Production_Standart_CreateOne), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_Production_Standart_GetOne), ctrl.GetOne)
route.Patch("/:id", m.RequirePermissions(m.P_Production_Standart_UpdateOne), ctrl.UpdateOne)
route.Delete("/:id", m.RequirePermissions(m.P_Production_Standart_DeleteOne), ctrl.DeleteOne)
}
@@ -84,7 +84,6 @@ func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.Produc
return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found")
}
if err != nil {
s.Log.Errorf("Failed get productionStandard by id: %+v", err)
return nil, err
}
return productionStandard, nil
@@ -111,6 +110,7 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea
var createdStandard *entity.ProductionStandard
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
standardRepoTx := repository.NewProductionStandardRepository(tx)
productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx)
standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx)
@@ -142,6 +142,7 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea
TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction,
TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight,
TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass,
StandardFCR: detailReq.ProductionStandardDetails.StandardFCR,
}
if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil {
@@ -206,7 +207,6 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat
nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id)
if err != nil {
s.Log.Errorf("Failed to check existing production standard: %+v", err)
return err
}
if nameExists {
@@ -255,6 +255,7 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat
TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction,
TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight,
TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass,
StandardFCR: detailReq.ProductionStandardDetails.StandardFCR,
}
if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil {
@@ -283,7 +284,6 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat
})
if err != nil {
s.Log.Errorf("Failed to update production standard: %+v", err)
return nil, err
}
@@ -295,7 +295,6 @@ func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found")
}
s.Log.Errorf("Failed to delete productionStandard: %+v", err)
return err
}
return nil
@@ -5,6 +5,7 @@ type ProductionStandardDetailItem struct {
TargetHenHouseProduction *float64 `json:"target_hen_house_production" validate:"omitempty,gte=0"`
TargetEggWeight *float64 `json:"target_egg_weight" validate:"omitempty,gte=0"`
TargetEggMass *float64 `json:"target_egg_mass" validate:"omitempty,gte=0"`
StandardFCR *float64 `json:"standard_fcr" validate:"omitempty,gte=0"`
}
type StandardGrowthDetailItem struct {
@@ -70,6 +70,7 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db = db.Where("is_visible = ?", true)
if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%")
}
@@ -12,6 +12,7 @@ type SupplierRepository interface {
repository.BaseRepository[entity.Supplier]
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error)
IdExists(ctx context.Context, id uint) (bool, error)
}
type SupplierRepositoryImpl struct {
@@ -33,3 +34,7 @@ func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, ex
func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) {
return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID)
}
func (r *SupplierRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.Supplier](ctx, r.db, id)
}
@@ -10,6 +10,7 @@ import (
fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/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"
productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
chickinDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto"
@@ -30,6 +31,7 @@ type ProjectFlockDTO struct {
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -82,6 +84,7 @@ func toProjectFlockDTO(pf *projectFlockDTO.ProjectFlockListDTO) *ProjectFlockDTO
Area: pf.Area,
Category: pf.Category,
Fcr: pf.Fcr,
ProductionStandard: pf.ProductionStandard,
Location: pf.Location,
CreatedUser: pf.CreatedUser,
CreatedAt: pf.CreatedAt,
@@ -10,6 +10,7 @@ import (
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"
productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto"
// pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
@@ -28,6 +29,7 @@ type ProjectFlockListDTO struct {
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"`
ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"`
@@ -103,6 +105,12 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
fcrSummary = &mapped
}
var productionStandardSummary *productionStandardDTO.ProductionStandardRelationDTO
if e.ProductionStandard.Id != 0 {
mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProductionStandard)
productionStandardSummary = &mapped
}
var locationSummary *locationDTO.LocationRelationDTO
if e.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.Location)
@@ -122,6 +130,7 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
ProjectBudgets: ToProjectBudgetDTOs(e.Budgets),
Category: e.Category,
Fcr: fcrSummary,
ProductionStandard: productionStandardSummary,
Location: locationSummary,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
@@ -6,6 +6,7 @@ import (
fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/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"
productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
@@ -19,6 +20,7 @@ type ProjectFlockWithPivotDTO struct {
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -62,6 +64,10 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
mapped := fcrDTO.ToFcrRelationDTO(e.ProjectFlock.Fcr)
pfLocal.Fcr = &mapped
}
if e.ProjectFlock.ProductionStandard.Id != 0 {
mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProjectFlock.ProductionStandard)
pfLocal.ProductionStandard = &mapped
}
if e.ProjectFlock.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.ProjectFlock.Location)
pfLocal.Location = &mapped
@@ -19,8 +19,10 @@ type ProjectflockRepository interface {
GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error)
GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error)
GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error)
GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error)
AreaExists(ctx context.Context, id uint) (bool, error)
FcrExists(ctx context.Context, id uint) (bool, error)
ProductionStandardExists(ctx context.Context, id uint) (bool, error)
LocationExists(ctx context.Context, id uint) (bool, error)
}
type KandangPeriodRow struct {
@@ -51,6 +53,7 @@ func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm
Preload("CreatedUser").
Preload("Area").
Preload("Fcr").
Preload("ProductionStandard").
Preload("Location").
Preload("Kandangs").
Preload("KandangHistory").
@@ -117,12 +120,14 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s
return db.
Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id").
Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id").
Joins("LEFT JOIN production_standards ON production_standards.id = project_flocks.production_standard_id").
Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id").
Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by").
Where(`
LOWER(areas.name) LIKE ?
OR LOWER(project_flocks.category) LIKE ?
OR LOWER(fcrs.name) LIKE ?
OR LOWER(production_standards.name) LIKE ?
OR LOWER(locations.name) LIKE ?
OR LOWER(locations.address) LIKE ?
OR LOWER(created_users.name) LIKE ?
@@ -152,6 +157,7 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s
likeQuery,
likeQuery,
likeQuery,
likeQuery,
)
}
@@ -163,6 +169,10 @@ func (r *ProjectflockRepositoryImpl) FcrExists(ctx context.Context, id uint) (bo
return repository.Exists[entity.Fcr](ctx, r.DB(), id)
}
func (r *ProjectflockRepositoryImpl) ProductionStandardExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProductionStandard](ctx, r.DB(), id)
}
func (r *ProjectflockRepositoryImpl) LocationExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.Location](ctx, r.DB(), id)
}
@@ -295,3 +305,17 @@ func (r *ProjectflockRepositoryImpl) ExistsByFlockName(ctx context.Context, floc
}
return count > 0, nil
}
func (r *ProjectflockRepositoryImpl) GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) {
var projectFlocks []entity.ProjectFlock
err := r.DB().WithContext(ctx).
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.project_flock_id = project_flocks.id").
Where("project_flocks.location_id = ?", locationID).
Where("project_flock_kandangs.closed_at IS NULL").
Group("project_flocks.id").
Find(&projectFlocks).Error
if err != nil {
return nil, err
}
return projectFlocks, nil
}
@@ -258,6 +258,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists},
commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: s.Repository.FcrExists},
commonSvc.RelationCheck{Name: "Production Standard", ID: &req.ProductionStandardId, Exists: s.Repository.ProductionStandardExists},
commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists},
); err != nil {
return nil, err
@@ -309,6 +310,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
AreaId: req.AreaId,
Category: cat,
FcrId: req.FcrId,
ProductionStandardId: req.ProductionStandardId,
LocationId: req.LocationId,
CreatedBy: actorID,
}
@@ -1,13 +1,14 @@
package validation
type Create struct {
FlockName string `json:"flock_name" validate:"required_strict"`
AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"`
Category string `json:"category" validate:"required_strict"`
FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"`
LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"`
KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"`
ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"`
FlockName string `json:"flock_name" validate:"required_strict"`
AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"`
Category string `json:"category" validate:"required_strict"`
FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"`
ProductionStandardId uint `json:"production_standard_id" validate:"required_strict,number,gt=0"`
LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"`
KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"`
ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"`
}
type Query struct {
+3
View File
@@ -12,6 +12,7 @@ import (
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
@@ -35,6 +36,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
supplierRepo := rSupplier.NewSupplierRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
expenseRepository := expenseRepo.NewExpenseRepository(db)
expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db)
projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
@@ -67,6 +69,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
db,
purchaseRepo,
projectFlockKandangRepository,
kandangRepo,
expenseServiceInstance,
)
@@ -17,6 +17,7 @@ import (
expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -53,6 +54,7 @@ type expenseBridge struct {
db *gorm.DB
purchaseRepo rPurchase.PurchaseRepository
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
kandangRepo kandangRepo.KandangRepository
expenseSvc expenseSvc.ExpenseService
}
@@ -60,12 +62,14 @@ func NewExpenseBridge(
db *gorm.DB,
purchaseRepo rPurchase.PurchaseRepository,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
kandangRepo kandangRepo.KandangRepository,
expenseSvc expenseSvc.ExpenseService,
) PurchaseExpenseBridge {
return &expenseBridge{
db: db,
purchaseRepo: purchaseRepo,
projectFlockKandangRepo: projectFlockKandangRepo,
kandangRepo: kandangRepo,
expenseSvc: expenseSvc,
}
}
@@ -550,6 +554,16 @@ func (b *expenseBridge) createExpenseViaService(
return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs")
}
kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB {
return db.Select("id, location_id")
})
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
if kandang == nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
costItems := make([]expenseValidation.CostItem, 0, len(items))
for _, gi := range items {
note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID)
@@ -570,8 +584,9 @@ func (b *expenseBridge) createExpenseViaService(
TransactionDate: utils.FormatDate(expenseDate),
Category: "BOP",
SupplierID: uint64(supplierID),
LocationID: uint64(kandang.LocationId),
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{
KandangID: uint64(*kandangID),
KandangID: func() *uint64 { id := uint64(*kandangID); return &id }(),
CostItems: costItems,
}},
}
@@ -164,3 +164,26 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error {
Data: result,
})
}
func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error {
data, meta, err := c.RepportService.GetHppPerKandang(ctx)
if err != nil {
return err
}
resp := struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Meta dto.HppPerKandangMetaDTO `json:"meta"`
Data dto.HppPerKandangResponseData `json:"data"`
}{
Code: fiber.StatusOK,
Status: "success",
Message: "Get HPP harian kandang layer successfully",
Meta: *meta,
Data: *data,
}
return ctx.Status(fiber.StatusOK).JSON(resp)
}
@@ -0,0 +1,123 @@
package dto
type HppPerKandangFiltersDTO struct {
AreaID string `json:"area_id"`
LocationID string `json:"location_id"`
KandangID string `json:"kandang_id"`
WeightMin string `json:"weight_min"`
WeightMax string `json:"weight_max"`
Period string `json:"period"`
ShowUnrecorded string `json:"show_unrecorded"`
}
type HppPerKandangMetaDTO struct {
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int64 `json:"total_pages"`
TotalResults int64 `json:"total_results"`
Filters HppPerKandangFiltersDTO `json:"filters"`
}
type HppPerKandangResponseData struct {
Period string `json:"period"`
Rows []HppPerKandangRowDTO `json:"rows"`
Summary HppPerKandangSummaryDTO `json:"summary"`
}
type HppPerKandangRowDTO struct {
ID int `json:"id"`
Kandang HppPerKandangRowKandangDTO `json:"kandang"`
WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"`
RemainingChickenBirds int64 `json:"remaining_chicken_birds"`
RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"`
AvgWeightKg float64 `json:"avg_weight_kg"`
EggProductionPieces int64 `json:"egg_production_pieces"`
EggProductionKg float64 `json:"egg_production_kg"`
// FeedCostRp float64 `json:"feed_cost_rp"`
// OvkCostRp float64 `json:"ovk_cost_rp"`
EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"`
EggValueRp int64 `json:"egg_value_rp"`
FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"`
DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"`
AverageDocPriceRp int64 `json:"average_doc_price_rp"`
HppRp float64 `json:"hpp_rp"`
RemainingValueRp int64 `json:"remaining_value_rp"`
}
type HppPerKandangRowKandangDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Location HppPerKandangLocationDTO `json:"location"`
Pic HppPerKandangPICDTO `json:"pic"`
}
type HppPerKandangLocationDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type HppPerKandangPICDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type HppPerKandangWeightRangeDTO struct {
WeightMin float64 `json:"weight_min"`
WeightMax float64 `json:"weight_max"`
}
type HppPerKandangSupplierDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Alias string `json:"alias"`
Category string `json:"category"`
}
type HppPerKandangSummaryDTO struct {
PerWeightRange []HppPerKandangSummaryWeightRangeDTO `json:"per_weight_range"`
Total HppPerKandangSummaryTotalDTO `json:"total"`
}
type HppPerKandangSummaryWeightRangeDTO struct {
ID int `json:"id"`
WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"`
Label string `json:"label"`
RemainingChickenBirds int64 `json:"remaining_chicken_birds"`
RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"`
AvgWeightKg float64 `json:"avg_weight_kg"`
EggProductionPieces int64 `json:"egg_production_pieces"`
EggProductionKg float64 `json:"egg_production_kg"`
EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"`
EggValueRp int64 `json:"egg_value_rp"`
FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"`
DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"`
AverageDocPriceRp float64 `json:"average_doc_price_rp"`
HppRp float64 `json:"hpp_rp"`
RemainingValueRp int64 `json:"remaining_value_rp"`
}
type HppPerKandangSummaryTotalDTO struct {
TotalRemainingChickenBirds int64 `json:"total_remaining_chicken_birds"`
TotalRemainingChickenWeightKg float64 `json:"total_remaining_chicken_weight_kg"`
AverageWeightKg float64 `json:"average_weight_kg"`
TotalRemainingValueRp int64 `json:"total_remaining_value_rp"`
TotalEggProductionPieces int64 `json:"total_egg_production_pieces"`
TotalEggProductionKg float64 `json:"total_egg_production_kg"`
AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"`
TotalEggValueRp int64 `json:"total_egg_value_rp"`
TotalHppRp float64 `json:"total_hpp_rp"`
TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"`
}
func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO {
return HppPerKandangFiltersDTO{
AreaID: area,
LocationID: location,
KandangID: kandang,
WeightMin: weightMin,
WeightMax: weightMax,
Period: period,
ShowUnrecorded: showUnrecorded,
}
}
+2 -1
View File
@@ -31,10 +31,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
recordingRepository := recordingRepo.NewRecordingRepository(db)
approvalRepository := commonRepo.NewApprovalRepository(db)
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
userRepository := rUser.NewUserRepository(db)
approvalSvc := approvalService.NewApprovalService(approvalRepository)
repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository)
repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, hppPerKandangRepository)
userService := sUser.NewUserService(userRepository, validate)
RepportRoutes(router, userService, repportService)
@@ -0,0 +1,361 @@
package repositories
import (
"context"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type HppPerKandangRow struct {
KandangID uint
KandangName string
KandangStatus string
LocationID uint
LocationName string
PicID uint
PicName string
RemainingChickenBirds float64
RemainingChickenWeight float64
EggProductionWeightKg float64
EggProductionPieces float64
}
type HppPerKandangCostRow struct {
KandangID uint
FeedCost float64
OvkCost float64
DocCost float64
DocQty float64
BudgetCost float64
ExpenseCost float64
}
type HppPerKandangSupplierRow struct {
KandangID uint
SupplierID uint
SupplierName string
SupplierAlias string
Category string
}
type HppPerKandangRepository interface {
GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error)
GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error)
}
type hppPerKandangRepository struct {
db *gorm.DB
}
func NewHppPerKandangRepository(db *gorm.DB) HppPerKandangRepository {
return &hppPerKandangRepository{db: db}
}
func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) {
var rows []HppPerKandangRow
query := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
k.id AS kandang_id,
k.name AS kandang_name,
k.status AS kandang_status,
loc.id AS location_id,
loc.name AS location_name,
pic.id AS pic_id,
pic.name AS pic_name,
COALESCE(MAX(r.total_chick_qty), 0) AS remaining_chicken_birds,
COALESCE(SUM(rbw.total_weight), 0) AS remaining_chicken_weight,
COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg,
COALESCE(SUM(re.qty), 0) AS egg_production_pieces`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("JOIN users AS pic ON pic.id = k.pic_id").
Joins("LEFT JOIN recording_bws AS rbw ON rbw.recording_id = r.id").
Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs)
query = query.Group("k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name").
Order("k.id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) {
var rows []HppPerKandangCostRow
recordingPfk := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT pfk.id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs)
purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS").String()
transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String()
query := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
k.id AS kandang_id,
COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
END), 0) AS feed_cost,
COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
END), 0) AS ovk_cost`,
utils.FlagPakan, transferStockableKey, utils.FlagPakan,
utils.FlagOVK, transferStockableKey, utils.FlagOVK).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey).
Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey).
Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct).
Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs)
query = query.Group("k.id").Order("k.id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, nil, err
}
docRows := make([]struct {
KandangID uint
DocCost float64
DocQty float64
SupplierID *uint
SupplierName *string
SupplierAlias *string
}, 0)
docQuery := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
pfk.kandang_id AS kandang_id,
COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost,
COALESCE(SUM(pc.usage_qty), 0) AS doc_qty,
s.id AS supplier_id,
s.name AS supplier_name,
s.alias AS supplier_alias`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id").
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
Where("pc.project_flock_kandang_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Group("pfk.kandang_id, s.id, s.name, s.alias")
docQuery = applyLocationFilters(docQuery, areaIDs, locationIDs, kandangIDs)
if err := docQuery.Scan(&docRows).Error; err != nil {
return nil, nil, err
}
costMap := make(map[uint]*HppPerKandangCostRow, len(rows))
for i := range rows {
row := rows[i]
costMap[row.KandangID] = &rows[i]
}
docSuppliers := make([]HppPerKandangSupplierRow, 0)
docSeen := make(map[uint]map[uint]bool)
for _, doc := range docRows {
entry, ok := costMap[doc.KandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
KandangID: doc.KandangID,
})
entry = &rows[len(rows)-1]
costMap[doc.KandangID] = entry
}
entry.DocCost += doc.DocCost
entry.DocQty += doc.DocQty
if doc.SupplierID != nil {
if docSeen[doc.KandangID] == nil {
docSeen[doc.KandangID] = make(map[uint]bool)
}
if !docSeen[doc.KandangID][*doc.SupplierID] {
docSeen[doc.KandangID][*doc.SupplierID] = true
supplierName := ""
if doc.SupplierName != nil {
supplierName = *doc.SupplierName
}
supplierAlias := ""
if doc.SupplierAlias != nil {
supplierAlias = *doc.SupplierAlias
}
docSuppliers = append(docSuppliers, HppPerKandangSupplierRow{
KandangID: doc.KandangID,
SupplierID: *doc.SupplierID,
SupplierName: supplierName,
SupplierAlias: supplierAlias,
Category: "DOC",
})
}
}
}
budgetRows := make([]struct {
KandangID uint
BudgetCost float64
}, 0)
pfkUsageSub := r.db.
Table("project_chickins AS pc").
Select(`
pc.project_flock_kandang_id,
SUM(pc.usage_qty) AS kandang_usage_qty`).
Group("pc.project_flock_kandang_id")
projectUsageSub := r.db.
Table("project_chickins AS pc").
Select(`
pfk.project_flock_id,
SUM(pc.usage_qty) AS project_usage_qty`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Group("pfk.project_flock_id")
budgetQuery := r.db.WithContext(ctx).
Table("project_flock_kandangs AS pfk").
Select(`
k.id AS kandang_id,
COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`).
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id").
Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub).
Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub).
Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Group("k.id")
budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs)
if err := budgetQuery.Scan(&budgetRows).Error; err != nil {
return nil, nil, err
}
for _, budget := range budgetRows {
entry, ok := costMap[budget.KandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
KandangID: budget.KandangID,
})
entry = &rows[len(rows)-1]
costMap[budget.KandangID] = entry
}
entry.BudgetCost += budget.BudgetCost
}
expenseRows := make([]struct {
KandangID uint
ExpenseCost float64
}, 0)
expenseQuery := r.db.WithContext(ctx).
Table("project_flock_kandangs AS pfk").
Select(`
k.id AS kandang_id,
COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`).
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id").
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Group("k.id")
expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs)
if err := expenseQuery.Scan(&expenseRows).Error; err != nil {
return nil, nil, err
}
for _, exp := range expenseRows {
entry, ok := costMap[exp.KandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
KandangID: exp.KandangID,
})
entry = &rows[len(rows)-1]
costMap[exp.KandangID] = entry
}
entry.ExpenseCost += exp.ExpenseCost
}
feedSuppliers := make([]HppPerKandangSupplierRow, 0)
feedQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT k.id AS kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey).
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}).
Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs)
if err := feedQuery.Scan(&feedSuppliers).Error; err != nil {
return nil, nil, err
}
for i := range feedSuppliers {
if _, exists := costMap[feedSuppliers[i].KandangID]; !exists {
rows = append(rows, HppPerKandangCostRow{
KandangID: feedSuppliers[i].KandangID,
})
costMap[feedSuppliers[i].KandangID] = &rows[len(rows)-1]
}
feedSuppliers[i].Category = "FEED"
}
supplierRows := append(docSuppliers, feedSuppliers...)
return rows, supplierRows, nil
}
func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int64) *gorm.DB {
if len(areaIDs) > 0 {
query = query.Where("loc.area_id IN ?", areaIDs)
}
if len(locationIDs) > 0 {
query = query.Where("k.location_id IN ?", locationIDs)
}
if len(kandangIDs) > 0 {
query = query.Where("k.id IN ?", kandangIDs)
}
return query
}
+2
View File
@@ -18,4 +18,6 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense)
route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing)
route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier)
route.Get("/hpp-per-kandang", ctrl.GetHppPerKandang)
}
@@ -2,6 +2,12 @@ package service
import (
"context"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
@@ -28,6 +34,7 @@ type RepportService interface {
GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error)
GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error)
GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error)
GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error)
}
type repportService struct {
@@ -40,6 +47,16 @@ type repportService struct {
RecordingRepo recordingRepo.RecordingRepository
ApprovalSvc approvalService.ApprovalService
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
HppPerKandangRepo repportRepo.HppPerKandangRepository
}
type HppCostAggregate struct {
FeedCost float64
OvkCost float64
DocCost float64
DocQty float64
BudgetCost float64
ExpenseCost float64
}
func NewRepportService(
@@ -51,6 +68,7 @@ func NewRepportService(
recordingRepo recordingRepo.RecordingRepository,
approvalSvc approvalService.ApprovalService,
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
hppPerKandangRepo repportRepo.HppPerKandangRepository,
) RepportService {
return &repportService{
Log: utils.Log,
@@ -62,6 +80,7 @@ func NewRepportService(
RecordingRepo: recordingRepo,
ApprovalSvc: approvalSvc,
PurchaseSupplierRepo: purchaseSupplierRepo,
HppPerKandangRepo: hppPerKandangRepo,
}
}
@@ -264,3 +283,438 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu
return result, totalSuppliers, nil
}
func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
params, filters, err := s.parseHppPerKandangQuery(ctx)
if err != nil {
return nil, nil, err
}
if err := s.Validate.Struct(params); err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
}
periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD")
}
startOfDay := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, location)
endOfDay := startOfDay.Add(24 * time.Hour)
repoRows, err := s.HppPerKandangRepo.GetRowsByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs)
if err != nil {
return nil, nil, err
}
costRows, supplierRows, err := s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs)
if err != nil {
return nil, nil, err
}
costMap := make(map[uint]HppCostAggregate, len(costRows))
for _, row := range costRows {
costMap[row.KandangID] = HppCostAggregate{
FeedCost: row.FeedCost,
OvkCost: row.OvkCost,
DocCost: row.DocCost,
DocQty: row.DocQty,
BudgetCost: row.BudgetCost,
ExpenseCost: row.ExpenseCost,
}
}
docSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO)
feedSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO)
docSeen := make(map[uint]map[uint]bool)
feedSeen := make(map[uint]map[uint]bool)
for _, sup := range supplierRows {
if sup.SupplierID == 0 {
continue
}
targetMap := feedSupplierMap
seen := feedSeen
category := "FEED"
if strings.EqualFold(sup.Category, "DOC") {
targetMap = docSupplierMap
seen = docSeen
category = "DOC"
}
if seen[sup.KandangID] == nil {
seen[sup.KandangID] = make(map[uint]bool)
}
if seen[sup.KandangID][sup.SupplierID] {
continue
}
seen[sup.KandangID][sup.SupplierID] = true
targetMap[sup.KandangID] = append(targetMap[sup.KandangID], dto.HppPerKandangSupplierDTO{
ID: int64(sup.SupplierID),
Name: sup.SupplierName,
Alias: sup.SupplierAlias,
Category: category,
})
}
type weightRangeKey struct {
Min float64
Max float64
}
type weightRangeAggregate struct {
Summary *dto.HppPerKandangSummaryWeightRangeDTO
EggHppSum float64
EggHppCount int
}
dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows))
perRangeMap := make(map[weightRangeKey]*weightRangeAggregate)
var totalBirds int64
var totalWeight float64
var totalEggPieces int64
var totalEggKg float64
var totalRemainingValueRp int64
var totalEggValueRp int64
var totalHppSum float64
var totalHppCount int
var totalDocPriceSum float64
var totalDocPriceCount int
var totalEggHppSum float64
var totalEggHppCount int
for _, row := range repoRows {
birdsFloat := row.RemainingChickenBirds
if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) {
birdsFloat = 0
}
weightFloat := row.RemainingChickenWeight
if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) {
weightFloat = 0
}
eggPiecesFloat := row.EggProductionPieces
if math.IsNaN(eggPiecesFloat) || math.IsInf(eggPiecesFloat, 0) {
eggPiecesFloat = 0
}
eggWeightFloat := row.EggProductionWeightKg
if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) {
eggWeightFloat = 0
}
avgWeight := 0.0
if birdsFloat > 0 {
avgWeight = weightFloat / birdsFloat
}
weightMin := math.Floor(avgWeight*10) / 10
if weightMin < 0 {
weightMin = 0
}
weightMax := weightMin + 0.09
rangeKey := weightRangeKey{Min: weightMin, Max: weightMax}
rowBirds := int64(math.Round(birdsFloat))
costEntry := costMap[row.KandangID]
totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost
hppRp := 0.0
if weightFloat > 0 {
hppRp = totalCost / weightFloat
}
eggHpp := 0.0
if eggWeightFloat > 0 {
eggHpp = totalCost / eggWeightFloat
}
rowEggPieces := int64(math.Round(eggPiecesFloat))
rowEggValue := int64(eggHpp * eggWeightFloat)
rowRemainingValue := int64(hppRp * weightFloat)
avgDocPrice := int64(0)
if costEntry.DocQty > 0 {
avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty))
}
dataRows = append(dataRows, dto.HppPerKandangRowDTO{
ID: int(row.KandangID),
Kandang: dto.HppPerKandangRowKandangDTO{
ID: int64(row.KandangID),
Name: row.KandangName,
Status: row.KandangStatus,
Location: dto.HppPerKandangLocationDTO{
ID: int64(row.LocationID),
Name: row.LocationName,
},
Pic: dto.HppPerKandangPICDTO{
ID: int64(row.PicID),
Name: row.PicName,
},
},
WeightRange: dto.HppPerKandangWeightRangeDTO{
WeightMin: weightMin,
WeightMax: weightMax,
},
RemainingChickenBirds: rowBirds,
RemainingChickenWeightKg: weightFloat,
AvgWeightKg: avgWeight,
// FeedCostRp: costEntry.FeedCost,
// OvkCostRp: costEntry.OvkCost,
DocSuppliers: docSupplierMap[row.KandangID],
FeedSuppliers: feedSupplierMap[row.KandangID],
EggProductionPieces: rowEggPieces,
EggProductionKg: eggWeightFloat,
AverageDocPriceRp: avgDocPrice,
HppRp: hppRp,
EggHppRpPerKg: eggHpp,
RemainingValueRp: rowRemainingValue,
EggValueRp: rowEggValue,
})
totalBirds += rowBirds
totalWeight += weightFloat
totalEggPieces += rowEggPieces
totalEggKg += eggWeightFloat
totalRemainingValueRp += rowRemainingValue
totalEggValueRp += rowEggValue
if weightFloat > 0 {
totalHppSum += hppRp
totalHppCount++
}
if avgDocPrice > 0 {
totalDocPriceSum += float64(avgDocPrice)
totalDocPriceCount++
}
if eggWeightFloat > 0 {
totalEggHppSum += eggHpp
totalEggHppCount++
}
rangeAgg, exists := perRangeMap[rangeKey]
if !exists {
rangeAgg = &weightRangeAggregate{
Summary: &dto.HppPerKandangSummaryWeightRangeDTO{
WeightRange: dto.HppPerKandangWeightRangeDTO{
WeightMin: weightMin,
WeightMax: weightMax,
},
Label: fmt.Sprintf("%.2f - %.2f", weightMin, weightMax),
},
}
perRangeMap[rangeKey] = rangeAgg
}
rangeSummary := rangeAgg.Summary
rangeSummary.RemainingChickenBirds += rowBirds
rangeSummary.RemainingChickenWeightKg += row.RemainingChickenWeight
rangeSummary.EggProductionPieces += rowEggPieces
rangeSummary.EggProductionKg += eggWeightFloat
rangeSummary.RemainingValueRp += rowRemainingValue
rangeSummary.EggValueRp += rowEggValue
if eggWeightFloat > 0 {
rangeAgg.EggHppSum += eggHpp
rangeAgg.EggHppCount++
}
}
rangeKeys := make([]weightRangeKey, 0, len(perRangeMap))
for key := range perRangeMap {
rangeKeys = append(rangeKeys, key)
}
sort.Slice(rangeKeys, func(i, j int) bool {
if rangeKeys[i].Min == rangeKeys[j].Min {
return rangeKeys[i].Max < rangeKeys[j].Max
}
return rangeKeys[i].Min < rangeKeys[j].Min
})
perRangeSummary := make([]dto.HppPerKandangSummaryWeightRangeDTO, 0, len(rangeKeys))
for idx, key := range rangeKeys {
agg := perRangeMap[key]
entry := agg.Summary
entry.ID = idx + 1
if entry.RemainingChickenBirds > 0 {
entry.AvgWeightKg = entry.RemainingChickenWeightKg / float64(entry.RemainingChickenBirds)
}
if agg.EggHppCount > 0 {
entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount)
}
perRangeSummary = append(perRangeSummary, *entry)
}
totalSummary := dto.HppPerKandangSummaryTotalDTO{
TotalRemainingChickenBirds: totalBirds,
TotalRemainingChickenWeightKg: totalWeight,
TotalEggProductionPieces: totalEggPieces,
TotalEggProductionKg: totalEggKg,
TotalRemainingValueRp: totalRemainingValueRp,
TotalEggValueRp: totalEggValueRp,
}
if totalBirds > 0 {
totalSummary.AverageWeightKg = totalWeight / float64(totalBirds)
}
if totalEggHppCount > 0 {
totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount)
}
if totalHppCount > 0 {
totalSummary.TotalHppRp = totalHppSum / float64(totalHppCount)
}
if totalDocPriceCount > 0 {
totalSummary.TotalAverageDocPriceRp = totalDocPriceSum / float64(totalDocPriceCount)
}
limit := params.Limit
if limit <= 0 {
limit = 10
}
totalCount := len(dataRows)
offset := (params.Page - 1) * limit
if offset < 0 {
offset = 0
}
if offset > totalCount {
offset = totalCount
}
end := offset + limit
if end > totalCount {
end = totalCount
}
pagedRows := dataRows[offset:end]
data := dto.HppPerKandangResponseData{
Period: params.Period,
Rows: pagedRows,
Summary: dto.HppPerKandangSummaryDTO{
PerWeightRange: perRangeSummary,
Total: totalSummary,
},
}
totalResults := int64(totalCount)
totalPages := int64(0)
if totalResults > 0 {
totalPages = int64(math.Ceil(float64(totalResults) / float64(limit)))
}
if totalPages == 0 {
totalPages = 1
}
meta := &dto.HppPerKandangMetaDTO{
Page: params.Page,
Limit: limit,
TotalPages: totalPages,
TotalResults: totalResults,
Filters: filters,
}
return &data, meta, nil
}
func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.HppPerKandangQuery, dto.HppPerKandangFiltersDTO, error) {
page := ctx.QueryInt("page", 1)
if page < 1 {
page = 1
}
limit := ctx.QueryInt("limit", 10)
if limit < 1 {
limit = 10
}
rawArea := ctx.Query("area_id", "")
rawLocation := ctx.Query("location_id", "")
rawKandang := ctx.Query("kandang_id", "")
rawWeightMin := ctx.Query("weight_min", "")
rawWeightMax := ctx.Query("weight_max", "")
period := ctx.Query("period", "")
showUnrecorded := ctx.QueryBool("show_unrecorded", false)
areaIDs, err := parseCommaSeparatedInt64s(rawArea)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
locationIDs, err := parseCommaSeparatedInt64s(rawLocation)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
kandangIDs, err := parseCommaSeparatedInt64s(rawKandang)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
weightMin, err := parseOptionalFloat64(rawWeightMin)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
weightMax, err := parseOptionalFloat64(rawWeightMax)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
params := &validation.HppPerKandangQuery{
Page: page,
Limit: limit,
Period: period,
ShowUnrecorded: showUnrecorded,
AreaIDs: areaIDs,
LocationIDs: locationIDs,
KandangIDs: kandangIDs,
WeightMin: weightMin,
WeightMax: weightMax,
}
showUnrecordedFilter := ""
if showUnrecorded {
showUnrecordedFilter = "true"
}
filters := dto.NewHppPerKandangFiltersDTO(
rawArea,
rawLocation,
rawKandang,
rawWeightMin,
rawWeightMax,
period,
showUnrecordedFilter,
)
return params, filters, nil
}
func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
result := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer value '%s'", part)
}
result = append(result, id)
}
return result, nil
}
func parseOptionalFloat64(raw string) (*float64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return nil, fmt.Errorf("invalid float value '%s'", raw)
}
return &value, nil
}
@@ -42,3 +42,15 @@ type PurchaseSupplierQuery struct {
SortBy string `query:"sort_by" validate:"omitempty"`
FilterBy string `query:"filter_by" validate:"omitempty"`
}
type HppPerKandangQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"`
Period string `query:"period" validate:"required"`
ShowUnrecorded bool `query:"show_unrecorded"`
AreaIDs []int64 `query:"-"`
LocationIDs []int64 `query:"-"`
KandangIDs []int64 `query:"-"`
WeightMin *float64 `query:"-"`
WeightMax *float64 `query:"-"`
}