Merge branch 'fix/be/closing-unclosing' into 'development'

[FEAT/BE] fixing overhead,sapronak,perhitungan sapronak

See merge request mbugroup/lti-api!337
This commit is contained in:
Adnan Zahir
2026-02-26 11:30:01 +07:00
6 changed files with 478 additions and 435 deletions
@@ -1,8 +1,6 @@
package dto package dto
import ( import (
"encoding/json"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
) )
@@ -71,7 +69,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal
return dto return dto
} }
func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int, projectFlockKandangCountMap map[uint]int) OverheadListDTO { func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int) OverheadListDTO {
overheadsByNonstockID := make(map[uint]*OverheadDTO) overheadsByNonstockID := make(map[uint]*OverheadDTO)
latestDateByNonstockID := make(map[uint]string) latestDateByNonstockID := make(map[uint]string)
@@ -113,35 +111,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
qty := realizations[i].Qty qty := realizations[i].Qty
totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price) totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price)
// Farm-level expense division
if realizations[i].ExpenseNonstock.Expense != nil &&
realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil {
projectFlockIDs := parseProjectFlockIDsFromJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId)
if len(projectFlockIDs) > 0 {
totalKandangInAllProjects := 0
for _, pfID := range projectFlockIDs {
if count, exists := projectFlockKandangCountMap[pfID]; exists {
totalKandangInAllProjects += count
}
}
if totalKandangInAllProjects > 0 {
if isPerKandang {
qty = qty / float64(totalKandangInAllProjects)
totalAmount = totalAmount / float64(totalKandangInAllProjects)
} else {
// Overhead ALL: divide by total kandang then multiply by this project's kandang count
perKandangAmount := totalAmount / float64(totalKandangInAllProjects)
perKandangQty := qty / float64(totalKandangInAllProjects)
qty = perKandangQty * float64(totalKandangCount)
totalAmount = perKandangAmount * float64(totalKandangCount)
}
}
}
}
overheadsByNonstockID[nonstockID].ActualQuantity += qty overheadsByNonstockID[nonstockID].ActualQuantity += qty
overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount
@@ -191,27 +160,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
} }
} }
func parseProjectFlockIDsFromJSON(projectFlockJSON string) []uint {
if projectFlockJSON == "" {
return []uint{}
}
var projectFlocks []uint
if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil {
return []uint{}
}
return projectFlocks
}
func countProjectFlocksInJSON(projectFlockJSON string) int {
projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON)
if len(projectFlocks) == 0 {
return 1
}
return len(projectFlocks)
}
func getItemInfo(nonstock *entity.Nonstock) (string, string) { func getItemInfo(nonstock *entity.Nonstock) (string, string) {
if nonstock != nil && nonstock.Id != 0 { if nonstock != nil && nonstock.Id != 0 {
return nonstock.Name, nonstock.Uom.Name return nonstock.Name, nonstock.Uom.Name
@@ -25,17 +25,17 @@ type ClosingRepository interface {
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
} }
@@ -86,6 +86,8 @@ type SapronakQueryParams struct {
Limit int Limit int
Offset int Offset int
Search string Search string
StartDate *time.Time
EndDate *time.Time
} }
func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) {
@@ -142,15 +144,33 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
} }
var totalResults int64 var totalResults int64
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) dateClause := ""
countArgs := append(append([]any{}, args...), searchArgs...) var dateArgs []any
if params.StartDate != nil {
dateClause += " AND sort_date::date >= ?"
dateArgs = append(dateArgs, params.StartDate)
}
if params.EndDate != nil {
dateClause += " AND sort_date::date <= ?"
dateArgs = append(dateArgs, params.EndDate)
}
whereClause := searchClause
if dateClause != "" {
if whereClause == "" {
whereClause = " WHERE " + strings.TrimPrefix(dateClause, " AND ")
} else {
whereClause += dateClause
}
}
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, whereClause)
countArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil { if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
dataArgs := append(append([]any{}, args...), searchArgs...) dataArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
dataArgs = append(dataArgs, params.Limit, params.Offset) dataArgs = append(dataArgs, params.Limit, params.Offset)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause) dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, whereClause)
var rows []SapronakRow var rows []SapronakRow
if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil {
@@ -213,6 +233,25 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S
searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like)
} }
dateClause := ""
var dateArgs []any
if params.StartDate != nil {
dateClause += " AND sort_date::date >= ?"
dateArgs = append(dateArgs, params.StartDate)
}
if params.EndDate != nil {
dateClause += " AND sort_date::date <= ?"
dateArgs = append(dateArgs, params.EndDate)
}
whereClause := searchClause
if dateClause != "" {
if whereClause == "" {
whereClause = " WHERE " + strings.TrimPrefix(dateClause, " AND ")
} else {
whereClause += dateClause
}
}
querySQL := fmt.Sprintf(` querySQL := fmt.Sprintf(`
SELECT SELECT
product_category AS category, product_category AS category,
@@ -222,8 +261,8 @@ SELECT
FROM (%s) AS combined%s FROM (%s) AS combined%s
GROUP BY product_category, unit_id, unit GROUP BY product_category, unit_id, unit
ORDER BY product_category ASC, unit ASC ORDER BY product_category ASC, unit ASC
`, unionSQL, searchClause) `, unionSQL, whereClause)
queryArgs := append(append([]any{}, args...), searchArgs...) queryArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
var rows []SapronakSummaryRow var rows []SapronakSummaryRow
if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil { if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil {
@@ -778,6 +817,16 @@ type SapronakDetailRow struct {
func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) } func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) }
func applyDateRange(db *gorm.DB, column string, start, end *time.Time) *gorm.DB {
if start != nil {
db = db.Where(column+"::date >= ?", start)
}
if end != nil {
db = db.Where(column+"::date <= ?", end)
}
return db
}
func applyJoins(db *gorm.DB, joins ...string) *gorm.DB { func applyJoins(db *gorm.DB, joins ...string) *gorm.DB {
for _, j := range joins { for _, j := range joins {
if strings.TrimSpace(j) != "" { if strings.TrimSpace(j) != "" {
@@ -878,6 +927,14 @@ func (r *ClosingRepositoryImpl) fetchSapronakUsage(
return rows, nil return rows, nil
} }
func scanUsage(db *gorm.DB) ([]SapronakUsageRow, error) {
rows := make([]SapronakUsageRow, 0)
if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *ClosingRepositoryImpl) detailQuery( func (r *ClosingRepositoryImpl) detailQuery(
ctx context.Context, ctx context.Context,
table string, table string,
@@ -909,11 +966,11 @@ func (r *ClosingRepositoryImpl) fetchSapronakDetails(
return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...)) return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...))
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) {
if pfkID == 0 { if pfkID == 0 {
return nil, nil return nil, nil
} }
return r.fetchSapronakUsage( db := r.usageQuery(
ctx, ctx,
"recording_stocks rs", "recording_stocks rs",
"pw.id = rs.product_warehouse_id", "pw.id = rs.product_warehouse_id",
@@ -922,13 +979,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui
pfkID, pfkID,
sapronakFlagsUsage, sapronakFlagsUsage,
) )
db = applyDateRange(db, "r.record_datetime", start, end)
return scanUsage(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) {
if pfkID == 0 { if pfkID == 0 {
return []SapronakUsageRow{}, nil return []SapronakUsageRow{}, nil
} }
return r.fetchSapronakUsage( db := r.usageQuery(
ctx, ctx,
"project_chickins pc", "project_chickins pc",
"pw.id = pc.product_warehouse_id", "pw.id = pc.product_warehouse_id",
@@ -937,10 +996,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, p
pfkID, pfkID,
sapronakFlagsChickin, sapronakFlagsChickin,
) )
db = applyDateRange(db, "pc.chick_in_date", start, end)
return scanUsage(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return r.fetchSapronakDetails( db := r.detailQuery(
ctx, ctx,
"recording_stocks rs", "recording_stocks rs",
"pw.id = rs.product_warehouse_id", "pw.id = rs.product_warehouse_id",
@@ -959,10 +1020,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p
pfkID, pfkID,
sapronakFlagsUsage, sapronakFlagsUsage,
) )
db = applyDateRange(db, "r.record_datetime", start, end)
return scanAndGroupDetails(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return r.fetchSapronakDetails( db := r.detailQuery(
ctx, ctx,
"project_chickins pc", "project_chickins pc",
"pw.id = pc.product_warehouse_id", "pw.id = pc.product_warehouse_id",
@@ -981,13 +1044,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con
pfkID, pfkID,
sapronakFlagsChickin, sapronakFlagsChickin,
) )
db = applyDateRange(db, "pc.chick_in_date", start, end)
return scanAndGroupDetails(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 { if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil return map[uint][]SapronakDetailRow{}, nil
} }
dateExpr := "COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime)"
query := r.withCtx(ctx). query := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -1049,11 +1115,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p_resolve.product_price pi.price, p_resolve.product_price
`) `)
query = applyDateRange(query, dateExpr, start, end)
return scanAndGroupDetails(query) return scanAndGroupDetails(query)
} }
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint, start, end *time.Time) *gorm.DB {
db := r.withCtx(ctx). db := r.withCtx(ctx).
Table("purchase_items AS pi"). Table("purchase_items AS pi").
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
@@ -1062,12 +1129,13 @@ func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandan
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL") Where("pi.received_date IS NOT NULL")
db = applyDateRange(db, "pi.received_date", start, end)
return r.joinSapronakProductFlag(db, "p") return r.joinSapronakProductFlag(db, "p")
} }
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) {
rows := make([]SapronakIncomingRow, 0) rows := make([]SapronakIncomingRow, 0)
db := r.incomingPurchaseBase(ctx, kandangID).Select(` db := r.incomingPurchaseBase(ctx, kandangID, start, end).Select(`
pi.product_id AS product_id, pi.product_id AS product_id,
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
@@ -1081,9 +1149,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kanda
return rows, nil return rows, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return scanAndGroupDetails( return scanAndGroupDetails(
r.incomingPurchaseBase(ctx, kandangID).Select(` r.incomingPurchaseBase(ctx, kandangID, start, end).Select(`
pi.product_id AS product_id, pi.product_id AS product_id,
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
@@ -1178,7 +1246,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
return in, out return in, out
} }
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
poByWarehouse := r.DB(). poByWarehouse := r.DB().
Table("purchase_items pi"). Table("purchase_items pi").
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
@@ -1205,11 +1273,13 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("COALESCE(ast.total_qty, 0) > 0") Where("COALESCE(ast.total_qty, 0) > 0")
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incomingQuery = applyDateRange(incomingQuery, "ast.created_at", start, end)
incoming, err := scanAndGroupDetails(incomingQuery) incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
dateExpr := "COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at)"
outgoingQuery := r.withCtx(ctx). outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -1242,6 +1312,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery) outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1250,7 +1321,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
return incoming, outgoing, nil return incoming, outgoing, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
incomingQuery := r.withCtx(ctx). incomingQuery := r.withCtx(ctx).
Table("stock_transfer_details AS std"). Table("stock_transfer_details AS std").
Select(` Select(`
@@ -1272,6 +1343,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)"). Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll) Where("f.name IN ?", sapronakFlagsAll)
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incomingQuery = applyDateRange(incomingQuery, "st.transfer_date", start, end)
incoming, err := scanAndGroupDetails(incomingQuery) incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1300,6 +1372,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)"). Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll) Where("f.name IN ?", sapronakFlagsAll)
incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p") incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p")
incomingLayingQuery = applyDateRange(incomingLayingQuery, "lt.transfer_date", start, end)
incomingLaying, err := scanAndGroupDetails(incomingLayingQuery) incomingLaying, err := scanAndGroupDetails(incomingLayingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1333,6 +1406,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, "st.transfer_date", start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery) outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1364,6 +1438,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p") outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p")
outgoingLayingQuery = applyDateRange(outgoingLayingQuery, "lt.transfer_date", start, end)
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1375,7 +1450,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
return incoming, outgoing, nil return incoming, outgoing, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
query := r.withCtx(ctx). query := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -1399,6 +1474,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
query = r.joinSapronakProductFlag(query, "p") query = r.joinSapronakProductFlag(query, "p")
query = applyDateRange(query, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end)
sales, err := scanAndGroupDetails(query) sales, err := scanAndGroupDetails(query)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1431,6 +1507,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p") nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p")
nonFifoQuery = applyDateRange(nonFifoQuery, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end)
nonFifoSales, err := scanAndGroupDetails(nonFifoQuery) nonFifoSales, err := scanAndGroupDetails(nonFifoQuery)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1443,12 +1520,29 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
return sales, nil return sales, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 { if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil return map[uint][]SapronakDetailRow{}, nil
} }
pfpType := fifo.StockableKeyProjectFlockPopulation.String() pfpType := fifo.StockableKeyProjectFlockPopulation.String()
dateExpr := fmt.Sprintf(`
CASE
WHEN sa.stockable_type = '%s' THEN COALESCE(
pi_pc.received_date,
st_pc.transfer_date,
lt_pc.transfer_date,
ast_pc.created_at,
pc.chick_in_date
)
ELSE COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at
)
END
`, pfpType)
query := r.withCtx(ctx). query := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
@@ -1532,6 +1626,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C
`) `)
query = r.joinSapronakProductFlag(query, "p_resolve") query = r.joinSapronakProductFlag(query, "p_resolve")
query = applyDateRange(query, dateExpr, start, end)
return scanAndGroupDetails(query) return scanAndGroupDetails(query)
} }
@@ -2,7 +2,6 @@ package service
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@@ -33,6 +32,14 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type activeKandangMetric struct {
ProjectFlockKandangID uint
ProjectFlockID uint
KandangID uint
Category string
Metric float64
}
type ClosingService interface { type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error)
GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
@@ -385,6 +392,11 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
} }
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID)
if err != nil {
s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range")
}
rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{ rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{
Type: params.Type, Type: params.Type,
WarehouseIDs: warehouseIDs, WarehouseIDs: warehouseIDs,
@@ -392,6 +404,8 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
Limit: params.Limit, Limit: params.Limit,
Offset: offset, Offset: offset,
Search: params.Search, Search: params.Search,
StartDate: startDate,
EndDate: endDate,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -468,11 +482,19 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
} }
} }
startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID)
if err != nil {
s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range")
}
rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{ rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{
Type: params.Type, Type: params.Type,
WarehouseIDs: warehouseIDs, WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs, ProjectFlockKandangIDs: projectFlockKandangIDs,
Search: params.Search, Search: params.Search,
StartDate: startDate,
EndDate: endDate,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err) s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -542,6 +564,90 @@ func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFl
return ids, nil return ids, nil
} }
func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID uint, kandangID *uint) (*time.Time, *time.Time, error) {
db := s.Repository.DB().WithContext(ctx)
if kandangID != nil && *kandangID > 0 {
var pfk entity.ProjectFlockKandang
if err := db.Select("id, created_at, closed_at").First(&pfk, *kandangID).Error; err != nil {
return nil, nil, err
}
var minChickin *time.Time
if err := db.Table("project_chickins").
Select("MIN(chick_in_date)").
Where("project_flock_kandang_id = ?", pfk.Id).
Scan(&minChickin).Error; err != nil {
return nil, nil, err
}
start := pfk.CreatedAt
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
}
startDate := dateOnlyUTC(start)
var endDate *time.Time
if pfk.ClosedAt != nil {
d := dateOnlyUTC(*pfk.ClosedAt)
endDate = &d
}
return &startDate, endDate, nil
}
var minCreated time.Time
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MIN(created_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&minCreated).Error; err != nil {
return nil, nil, err
}
var minChickin *time.Time
if err := db.Table("project_chickins pc").
Select("MIN(pc.chick_in_date)").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Scan(&minChickin).Error; err != nil {
return nil, nil, err
}
start := minCreated
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
}
startDate := dateOnlyUTC(start)
var endDate *time.Time
var openCount int64
if err := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ? AND closed_at IS NULL", projectFlockID).
Count(&openCount).Error; err != nil {
return nil, nil, err
}
if openCount == 0 {
var maxClosed *time.Time
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MAX(closed_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&maxClosed).Error; err != nil {
return nil, nil, err
}
if maxClosed != nil && !maxClosed.IsZero() {
d := dateOnlyUTC(*maxClosed)
endDate = &d
}
}
return &startDate, endDate, nil
}
func dateOnlyUTC(t time.Time) time.Time {
u := t.UTC()
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
}
func formatQuantity(qty float64, uom string) string { func formatQuantity(qty float64, uom string) string {
qtyStr := strconv.FormatFloat(qty, 'f', -1, 64) qtyStr := strconv.FormatFloat(qty, 'f', -1, 64)
if uom == "" { if uom == "" {
@@ -616,38 +722,17 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
return nil, err return nil, err
} }
realizations, err = s.allocateFarmOverheadRealizations(c.Context(), projectFlockID, projectFlockKandangID, realizations)
if err != nil {
return nil, err
}
projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalKandangCount := len(projectFlockKandangs) totalKandangCount := len(projectFlockKandangs)
// Build kandang count map for farm expense division
projectFlockKandangCountMap := make(map[uint]int)
projectFlockKandangCountMap[projectFlockID] = totalKandangCount
involvedProjectFlocks := make(map[uint]bool)
for _, realization := range realizations {
if realization.ExpenseNonstock != nil &&
realization.ExpenseNonstock.Expense != nil &&
realization.ExpenseNonstock.Expense.ProjectFlockId != nil {
var projectFlockIDs []uint
if err := json.Unmarshal([]byte(*realization.ExpenseNonstock.Expense.ProjectFlockId), &projectFlockIDs); err == nil {
for _, pfID := range projectFlockIDs {
if pfID != projectFlockID {
involvedProjectFlocks[pfID] = true
}
}
}
}
}
for pfID := range involvedProjectFlocks {
if pfKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), pfID); err == nil {
projectFlockKandangCountMap[pfID] = len(pfKandangs)
}
}
chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -688,11 +773,197 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
totalActualPopulation := totalChickinQty - totalDepletion totalActualPopulation := totalChickinQty - totalDepletion
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount, projectFlockKandangCountMap) result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount)
return &result, nil return &result, nil
} }
type activeKandangMetricRow struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"`
KandangID uint `gorm:"column:kandang_id"`
Category string `gorm:"column:category"`
ChickinQty float64 `gorm:"column:chickin_qty"`
DepletionQty float64 `gorm:"column:depletion_qty"`
EggQty float64 `gorm:"column:egg_qty"`
}
func (s closingService) getActiveKandangMetrics(ctx context.Context, locationID uint, transactionDate time.Time) ([]activeKandangMetric, error) {
db := s.Repository.DB().WithContext(ctx)
rows := []activeKandangMetricRow{}
rawSQL := `
SELECT
pfk.id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pfk.kandang_id AS kandang_id,
pf.category AS category,
COALESCE((
SELECT SUM(pc.usage_qty)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = pfk.id
AND pc.chick_in_date::date <= ?
), 0) AS chickin_qty,
COALESCE((
SELECT SUM(rd.qty)
FROM recording_depletions rd
JOIN recordings r ON r.id = rd.recording_id
WHERE r.project_flock_kandangs_id = pfk.id
AND r.record_datetime::date <= ?
), 0) AS depletion_qty,
COALESCE((
SELECT SUM(re.qty)
FROM recording_eggs re
JOIN recordings r2 ON r2.id = re.recording_id
WHERE r2.project_flock_kandangs_id = pfk.id
AND r2.record_datetime::date <= ?
), 0) AS egg_qty
FROM project_flock_kandangs pfk
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE pf.location_id = ?
AND (pfk.closed_at IS NULL OR pfk.closed_at::date > ?)
AND EXISTS (
SELECT 1
FROM project_chickins pc2
WHERE pc2.project_flock_kandang_id = pfk.id
AND pc2.chick_in_date::date <= ?
)
`
if err := db.Raw(rawSQL, transactionDate, transactionDate, transactionDate, locationID, transactionDate, transactionDate).Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]activeKandangMetric, 0, len(rows))
for _, row := range rows {
metric := 0.0
switch strings.ToLower(strings.TrimSpace(row.Category)) {
case "growing":
metric = row.ChickinQty
case "laying":
metric = row.EggQty
default:
s.Log.Warnf("Unknown project flock category for overhead allocation: %s (pfk=%d)", row.Category, row.ProjectFlockKandangID)
}
result = append(result, activeKandangMetric{
ProjectFlockKandangID: row.ProjectFlockKandangID,
ProjectFlockID: row.ProjectFlockID,
KandangID: row.KandangID,
Category: row.Category,
Metric: metric,
})
}
return result, nil
}
func round2(value float64) float64 {
return math.Round(value*100) / 100
}
func allocateFarmLevelQty(totalQty float64, metrics []activeKandangMetric) map[uint]float64 {
allocations := make(map[uint]float64, len(metrics))
if totalQty == 0 || len(metrics) == 0 {
return allocations
}
totalMetric := 0.0
var maxMetric float64
var maxMetricID uint
for _, m := range metrics {
if m.Metric <= 0 {
continue
}
totalMetric += m.Metric
if m.Metric > maxMetric || maxMetricID == 0 {
maxMetric = m.Metric
maxMetricID = m.ProjectFlockKandangID
}
}
if totalMetric == 0 {
return allocations
}
sumRounded := 0.0
for _, m := range metrics {
if m.Metric <= 0 {
continue
}
portion := totalQty * (m.Metric / totalMetric)
rounded := round2(portion)
allocations[m.ProjectFlockKandangID] = rounded
sumRounded += rounded
}
diff := totalQty - sumRounded
if maxMetricID != 0 && diff != 0 {
allocations[maxMetricID] = round2(allocations[maxMetricID] + diff)
}
return allocations
}
func (s closingService) allocateFarmOverheadRealizations(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, realizations []entity.ExpenseRealization) ([]entity.ExpenseRealization, error) {
if len(realizations) == 0 {
return realizations, nil
}
cache := make(map[string][]activeKandangMetric)
allocated := make([]entity.ExpenseRealization, 0, len(realizations))
for _, realization := range realizations {
expenseNonstock := realization.ExpenseNonstock
if expenseNonstock == nil || expenseNonstock.Expense == nil {
allocated = append(allocated, realization)
continue
}
// If already bound to a specific project flock kandang, don't re-allocate.
if expenseNonstock.ProjectFlockKandangId != nil {
allocated = append(allocated, realization)
continue
}
expense := expenseNonstock.Expense
locationID := uint(expense.LocationId)
txDate := expense.RealizationDate
cacheKey := fmt.Sprintf("%d|%s", locationID, txDate.Format("2006-01-02"))
metrics, exists := cache[cacheKey]
if !exists {
var err error
metrics, err = s.getActiveKandangMetrics(ctx, locationID, txDate)
if err != nil {
return nil, err
}
cache[cacheKey] = metrics
}
allocations := allocateFarmLevelQty(realization.Qty, metrics)
allocatedQty := 0.0
if projectFlockKandangID != nil {
allocatedQty = allocations[*projectFlockKandangID]
} else {
for _, m := range metrics {
if m.ProjectFlockID == projectFlockID {
allocatedQty += allocations[m.ProjectFlockKandangID]
}
}
allocatedQty = round2(allocatedQty)
}
adj := realization
adj.Qty = allocatedQty
if adj.Qty == 0 {
adj.Price = realization.Price
}
allocated = append(allocated, adj)
}
return allocated, nil
}
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -123,7 +124,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
continue continue
} }
// We no longer filter by date for closing sapronak report; pass nil pointers. // Filter sapronak data by project flock period range.
items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag)
if err != nil { if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
@@ -379,33 +380,33 @@ func buildSapronakDetails(
} }
func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) {
// For sapronak closing report we intentionally ignore date range // Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any).
// and aggregate all historical transactions for the kandang/project. startDate, endDate := sapronakPeriodRange(pfk)
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId) incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id) usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id) chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id) usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id) chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id) usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
@@ -413,15 +414,15 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
usageDetailsRows = usageAllocatedDetails usageDetailsRows = usageAllocatedDetails
chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{} chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{}
} }
adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId) adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId) transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id) salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
@@ -492,11 +493,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
chickinUsageDetailsRows = filteredDetail chickinUsageDetailsRows = filteredDetail
} }
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
for pid, rows := range chickinUsageDetailsRows { for pid, rows := range chickinUsageDetailsRows {
if len(rows) == 0 { if len(rows) == 0 {
continue continue
@@ -513,6 +509,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
transOutgoing := detailMaps.TransferOut transOutgoing := detailMaps.TransferOut
salesOutgoing := detailMaps.SalesOut salesOutgoing := detailMaps.SalesOut
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
transIncoming = dedupTransfers(transIncoming) transIncoming = dedupTransfers(transIncoming)
transOutgoing = dedupTransfers(transOutgoing) transOutgoing = dedupTransfers(transOutgoing)
@@ -823,3 +824,20 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
return items, groups, totalIncoming, totalUsage, nil return items, groups, totalIncoming, totalUsage, nil
} }
func sapronakPeriodRange(pfk entity.ProjectFlockKandang) (*time.Time, *time.Time) {
if len(pfk.Chickins) == 0 {
start := dateOnlyUTC(pfk.CreatedAt)
return &start, pfk.ClosedAt
}
minDate := pfk.Chickins[0].ChickInDate
for _, c := range pfk.Chickins[1:] {
if c.ChickInDate.Before(minDate) {
minDate = c.ChickInDate
}
}
start := dateOnlyUTC(minDate)
return &start, pfk.ClosedAt
}
@@ -69,23 +69,19 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL"). Where("expenses.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP") Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
db = db.Where(`( db = db.Where(`(
expense_nonstocks.project_flock_kandang_id = ? OR expense_nonstocks.project_flock_kandang_id = ? OR
(expense_nonstocks.kandang_id = (SELECT kandang_id FROM project_flock_kandangs WHERE id = ?) AND
expense_nonstocks.project_flock_kandang_id IS NULL) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) )`, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID))
} else { } else {
db = db.Where(`( db = db.Where(`(
project_flock_kandangs.project_flock_id = ? OR project_flock_kandangs.project_flock_id = ? OR
kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, projectFlockID, projectFlockID, fmt.Sprintf("[%d]", projectFlockID)) )`, projectFlockID, fmt.Sprintf("[%d]", projectFlockID))
} }
err := db.Find(&realizations).Error err := db.Find(&realizations).Error
@@ -428,13 +428,6 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response") return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response")
} }
// if sanitized, perms, ok := sanitizeUserInfoPayload(body); ok {
// if caps := capabilities.FromPermissions(perms); len(caps) > 0 {
// injectCapabilities(sanitized, caps)
// }
// return c.Status(resp.StatusCode).JSON(sanitized)
// }
if ct := resp.Header.Get("Content-Type"); ct != "" { if ct := resp.Header.Get("Content-Type"); ct != "" {
c.Set("Content-Type", ct) c.Set("Content-Type", ct)
} else { } else {
@@ -446,17 +439,9 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
// Logout clears SSO cookies and removes any leftover PKCE session state. // Logout clears SSO cookies and removes any leftover PKCE session state.
func (h *Controller) Logout(c *fiber.Ctx) error { func (h *Controller) Logout(c *fiber.Ctx) error {
requestedAlias := normalizeClientParam(c.Query("client")) alias := ""
if requestedAlias == "" { if singleAlias, _, ok := singleSSOClient(); ok {
requestedAlias = normalizeClientParam(c.Query("client_id")) alias = singleAlias
}
var (
alias string
cfg config.SSOClientConfig
hasClientInfo bool
)
if requestedAlias != "" {
alias, cfg, hasClientInfo = findSSOClientConfig(requestedAlias)
} }
accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access")
@@ -473,14 +458,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
hadAccessCookie := accessToken != "" hadAccessCookie := accessToken != ""
hadRefreshCookie := refreshToken != "" hadRefreshCookie := refreshToken != ""
state := strings.TrimSpace(c.Query("state")) if !hadAccessCookie && !hadRefreshCookie {
if state != "" {
if err := h.store.Delete(c.Context(), state); err != nil {
utils.Log.Warnf("failed to delete pkce session during logout: %v", err)
}
}
if !hadAccessCookie && !hadRefreshCookie && state == "" {
return fiber.NewError(fiber.StatusUnauthorized, "not authenticated") return fiber.NewError(fiber.StatusUnauthorized, "not authenticated")
} }
@@ -505,52 +483,20 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
clearSSOCookie(c, refreshName) clearSSOCookie(c, refreshName)
redirectTarget := "" redirectTarget := ""
rawReturn := strings.TrimSpace(c.Query("return_to")) if config.SSOPortalURL != "" {
if hasClientInfo { redirectTarget = config.SSOPortalURL
if rawReturn == "" {
rawReturn = cfg.DefaultReturnURI
}
if normalized, err := normalizeReturnTarget(rawReturn, cfg); err == nil {
redirectTarget = normalized
} else if rawReturn != "" {
utils.Log.WithError(err).Warn("invalid return_to during logout")
}
} else if rawReturn == "" && config.SSOPortalURL != "" {
if alias, singleCfg, ok := singleClientFromToken(verification); ok {
if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" {
redirectTarget = normalized
alias, cfg, hasClientInfo = alias, singleCfg, true
} else {
redirectTarget = config.SSOPortalURL
}
} else if accessToken != "" {
if alias, singleCfg, ok := h.singleClientFromSSO(c.Context(), accessToken); ok {
if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" {
redirectTarget = normalized
alias, cfg, hasClientInfo = alias, singleCfg, true
} else {
redirectTarget = config.SSOPortalURL
}
} else {
redirectTarget = config.SSOPortalURL
}
} else {
redirectTarget = config.SSOPortalURL
}
} else if rawReturn != "" {
if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") {
redirectTarget = rawReturn
}
} }
utils.Log.WithFields(logrus.Fields{ utils.Log.WithFields(logrus.Fields{
"client": alias, "client": alias,
"state": state,
"redirect": redirectTarget, "redirect": redirectTarget,
}).Info("sso logout completed") }).Info("sso logout completed")
if redirectTarget != "" { if redirectTarget != "" {
return c.Redirect(redirectTarget, fiber.StatusFound) return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "signed out",
"redirect": redirectTarget,
})
} }
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"}) return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"})
@@ -569,145 +515,6 @@ func singleSSOClient() (string, config.SSOClientConfig, bool) {
return "", config.SSOClientConfig{}, false return "", config.SSOClientConfig{}, false
} }
func singleClientFromToken(verification *sso.VerificationResult) (string, config.SSOClientConfig, bool) {
if verification == nil || verification.Claims == nil {
return "", config.SSOClientConfig{}, false
}
return singleClientFromScopes(verification.Claims.Scopes())
}
func (h *Controller) singleClientFromSSO(ctx context.Context, accessToken string) (string, config.SSOClientConfig, bool) {
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return "", config.SSOClientConfig{}, false
}
meURL := strings.TrimSpace(config.SSOGetMeURL)
if meURL == "" {
return "", config.SSOClientConfig{}, false
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
if err != nil {
utils.Log.WithError(err).Warn("failed to build SSO getme request")
return "", config.SSOClientConfig{}, false
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := h.httpClient.Do(req)
if err != nil {
utils.Log.WithError(err).Warn("SSO getme request failed")
return "", config.SSOClientConfig{}, false
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
utils.Log.WithField("status", resp.StatusCode).Warn("SSO getme responded with error")
return "", config.SSOClientConfig{}, false
}
var payload struct {
Data struct {
Roles []struct {
Client *struct {
Alias string `json:"alias"`
} `json:"client"`
} `json:"roles"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
utils.Log.WithError(err).Warn("failed to decode SSO getme response")
return "", config.SSOClientConfig{}, false
}
aliases := make(map[string]struct{})
for _, role := range payload.Data.Roles {
if role.Client == nil {
continue
}
alias := strings.ToLower(strings.TrimSpace(role.Client.Alias))
if alias != "" {
aliases[alias] = struct{}{}
}
}
if len(aliases) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias := range aliases {
if normalized, cfg, ok := findClientAlias(alias); ok {
return normalized, cfg, true
}
return "", config.SSOClientConfig{}, false
}
return "", config.SSOClientConfig{}, false
}
func singleClientFromScopes(scopes []string) (string, config.SSOClientConfig, bool) {
if len(scopes) == 0 {
return "", config.SSOClientConfig{}, false
}
seen := make(map[string]struct{})
for _, scope := range scopes {
if alias, ok := matchClientAliasFromScope(scope); ok {
seen[alias] = struct{}{}
}
if len(seen) > 1 {
return "", config.SSOClientConfig{}, false
}
}
if len(seen) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias := range seen {
if normalized, cfg, ok := findClientAlias(alias); ok {
return normalized, cfg, true
}
}
return "", config.SSOClientConfig{}, false
}
func matchClientAliasFromScope(scope string) (string, bool) {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
return "", false
}
prefix := scope
if idx := strings.IndexAny(prefix, ".:"); idx > 0 {
prefix = prefix[:idx]
}
if prefix == "" {
return "", false
}
if alias, _, ok := findClientAlias(prefix); ok {
return alias, true
}
if prefix == "user-management" {
if alias, _, ok := findClientAlias("umgmt"); ok {
return alias, true
}
}
if prefix == "umgmt" {
if alias, _, ok := findClientAlias("user-management"); ok {
return alias, true
}
}
return "", false
}
func findClientAlias(alias string) (string, config.SSOClientConfig, bool) {
alias = strings.TrimSpace(alias)
if alias == "" {
return "", config.SSOClientConfig{}, false
}
if cfg, ok := config.SSOClients[alias]; ok && strings.TrimSpace(cfg.PublicID) != "" {
return alias, cfg, true
}
for key, cfg := range config.SSOClients {
if strings.EqualFold(key, alias) && strings.TrimSpace(cfg.PublicID) != "" {
return key, cfg, true
}
}
return "", config.SSOClientConfig{}, false
}
func defaultSSOClientAlias() string { func defaultSSOClientAlias() string {
for alias := range config.SSOClients { for alias := range config.SSOClients {
@@ -897,98 +704,6 @@ func normalizeClientParam(raw string) string {
return strings.ToLower(value) return strings.ToLower(value)
} }
func sanitizeUserInfoPayload(body []byte) (map[string]any, []string, bool) {
if len(body) == 0 {
return map[string]any{}, nil, true
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, nil, false
}
perms := collectPermissionNames(payload)
sensitive := map[string]struct{}{
"roles": {},
"permissions": {},
}
payload = scrubSensitiveKeys(payload, sensitive)
sanitized, ok := payload.(map[string]any)
if !ok {
sanitized = map[string]any{"data": payload}
}
return sanitized, perms, true
}
func scrubSensitiveKeys(value any, sensitive map[string]struct{}) any {
switch v := value.(type) {
case map[string]any:
for key, val := range v {
if _, ok := sensitive[strings.ToLower(key)]; ok {
delete(v, key)
continue
}
v[key] = scrubSensitiveKeys(val, sensitive)
}
return v
case []any:
for i, item := range v {
v[i] = scrubSensitiveKeys(item, sensitive)
}
return v
default:
return value
}
}
func collectPermissionNames(value any) []string {
names := make(map[string]struct{})
collectPermissionRec(value, names)
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
return out
}
func collectPermissionRec(value any, acc map[string]struct{}) {
switch v := value.(type) {
case map[string]any:
for key, val := range v {
if strings.EqualFold(key, "permissions") {
if arr, ok := val.([]any); ok {
for _, item := range arr {
if perm, ok := item.(map[string]any); ok {
if name, ok := perm["name"].(string); ok && strings.TrimSpace(name) != "" {
acc[strings.ToLower(strings.TrimSpace(name))] = struct{}{}
}
}
}
}
} else {
collectPermissionRec(val, acc)
}
}
case []any:
for _, item := range v {
collectPermissionRec(item, acc)
}
}
}
func injectCapabilities(payload map[string]any, caps map[string]bool) {
if len(caps) == 0 {
return
}
if data, ok := payload["data"].(map[string]any); ok {
data["capabilities"] = caps
return
}
payload["capabilities"] = caps
}
func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) { func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) {
if requestedAlias == "" { if requestedAlias == "" {