diff --git a/internal/config/config.go b/internal/config/config.go index af723b3b..0c09ee33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,7 @@ var ( SSOPortalURL string SSOClients map[string]SSOClientConfig SSOAccessCookieName string + SSOAccessCookieFallback []string SSORefreshCookieName string SSOCookieDomain string SSOCookieSecure bool @@ -141,6 +142,7 @@ func init() { SSOGetMeURL = viper.GetString("SSO_GETME_URL") SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL")) SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access") + SSOAccessCookieFallback = parseList("SSO_ACCESS_COOKIE_FALLBACK") SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index b7229382..e7640e7b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -19,11 +19,11 @@ const ( // AuthContext keeps authentication details captured by the middleware. type AuthContext struct { - Token string - Verification *sso.VerificationResult - User *entity.User - Roles []sso.Role - Permissions map[string]struct{} + Token string + Verification *sso.VerificationResult + User *entity.User + Roles []sso.Role + Permissions map[string]struct{} UserAreaIDs []uint UserLocationIDs []uint UserAllArea bool @@ -36,8 +36,30 @@ type AuthContext struct { func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { return func(c *fiber.Ctx) error { token := bearerToken(c) - if token == "" { - token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) + tokenSource := "" + if token != "" { + tokenSource = "header" + } else { + primaryName := strings.TrimSpace(config.SSOAccessCookieName) + if primaryName != "" { + token = strings.TrimSpace(c.Cookies(primaryName)) + if token != "" { + tokenSource = "cookie:" + primaryName + } + } + if token == "" { + for _, name := range config.SSOAccessCookieFallback { + name = strings.TrimSpace(name) + if name == "" || name == primaryName { + continue + } + token = strings.TrimSpace(c.Cookies(name)) + if token != "" { + tokenSource = "cookie:" + name + break + } + } + } } if token == "" { return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") @@ -45,7 +67,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl verification, err := sso.VerifyAccessToken(token) if err != nil { - utils.Log.WithError(err).Warn("auth: token verification failed") + if sso.IsSignatureError(err) { + logSignatureError("auth", tokenSource, token, err) + } else { + utils.Log.WithError(err).Warn("auth: token verification failed") + } return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } @@ -89,11 +115,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl } ctx := &AuthContext{ - Token: token, - Verification: verification, - User: user, - Roles: roles, - Permissions: permissions, + Token: token, + Verification: verification, + User: user, + Roles: roles, + Permissions: permissions, UserAreaIDs: nil, UserLocationIDs: nil, UserAllArea: false, @@ -216,6 +242,26 @@ func hasAllScopes(have, required []string) bool { return true } +func logSignatureError(ctxLabel, tokenSource, token string, err error) { + info := sso.ExtractTokenInfo(token) + aud := strings.Join(info.Aud, ",") + utils.Log.Errorf( + "access token verification failed: %v | ctx=%s source=%s iss=%s kid=%s aud=%s sub=%s exp=%d iat=%d nbf=%d expected_iss=%s expected_aud=%v", + err, + ctxLabel, + tokenSource, + info.Iss, + info.Kid, + aud, + info.Sub, + info.Exp, + info.Iat, + info.Nbf, + config.SSOIssuer, + config.SSOAllowedAudiences, + ) +} + // RequirePermissions ensures the authenticated user possesses all specified permissions. func RequirePermissions(perms ...string) fiber.Handler { required := canonicalPermissions(perms) diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go index 4730474a..f29d6ef5 100644 --- a/internal/modules/closings/dto/closingOverhead.dto.go +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -1,8 +1,6 @@ package dto import ( - "encoding/json" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) @@ -71,7 +69,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal 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) latestDateByNonstockID := make(map[uint]string) @@ -113,35 +111,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex qty := realizations[i].Qty 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].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) { if nonstock != nil && nonstock.Id != 0 { return nonstock.Name, nonstock.Uom.Name diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index d4cb0d0d..4c5db68d 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -65,7 +65,7 @@ type SapronakCategoryRowDTO struct { QtyOut float64 `json:"qty_out"` QtyUsed float64 `json:"qty_used"` Description string `json:"description"` - ProductCategory []string `json:"product_category"` + ProductCategory string `json:"product_category"` UnitPrice float64 `json:"unit_price"` TotalAmount float64 `json:"total_amount"` Notes string `json:"notes"` @@ -183,13 +183,13 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin "PULLET": 0, } - buildFlagList := func(productID uint, fallback string) []string { + buildFlagList := func(productID uint, fallback string) string { rawFlags := productFlags[productID] if len(rawFlags) == 0 { if fallback == "" { - return []string{} + return "" } - return []string{fallback} + return fallback } seen := make(map[string]struct{}, len(rawFlags)) ordered := make([]string, 0, len(rawFlags)) @@ -220,7 +220,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } return li < lj }) - return ordered + return strings.Join(ordered, " ") } for _, group := range report.Groups { @@ -317,6 +317,27 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } } + // For chicken categories, keep qty_used aligned with qty_in - qty_out. + // Sales are excluded; usage represents remaining after transfers. + adjustChicken := func(cat *SapronakCategoryDTO) { + if cat == nil { + return + } + for i := range cat.Rows { + row := &cat.Rows[i] + remaining := row.QtyIn - row.QtyOut + if remaining < 0 { + remaining = 0 + } + row.QtyUsed = remaining + if row.UnitPrice > 0 { + row.TotalAmount = row.QtyUsed * row.UnitPrice + } + } + } + adjustChicken(result.Doc) + adjustChicken(result.Pullet) + buildTotals := func(cat *SapronakCategoryDTO, label string) { if cat == nil { return @@ -345,5 +366,22 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin buildTotals(result.Doc, "TOTAL DOC") buildTotals(result.Ovk, "TOTAL OVK") buildTotals(result.Pakan, "TOTAL PAKAN") + + // For chicken categories, enforce total qty_used = qty_in - qty_out. + adjustChickenTotal := func(cat *SapronakCategoryDTO) { + if cat == nil { + return + } + remaining := cat.Total.QtyIn - cat.Total.QtyOut + if remaining < 0 { + remaining = 0 + } + cat.Total.QtyUsed = remaining + if cat.Total.AvgUnitPrice > 0 { + cat.Total.TotalAmount = cat.Total.AvgUnitPrice * remaining + } + } + adjustChickenTotal(result.Doc) + adjustChickenTotal(result.Pullet) return result } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index ecd96b0a..12aec564 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -25,17 +25,17 @@ type ClosingRepository interface { SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) - FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) - FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) - FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) - FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID 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) - FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) + FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) + FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) + FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (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, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (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) } @@ -86,6 +86,8 @@ type SapronakQueryParams struct { Limit int Offset int Search string + StartDate *time.Time + EndDate *time.Time } 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 - countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) - countArgs := append(append([]any{}, args...), searchArgs...) + 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 + } + } + 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 { 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) - 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 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) } + 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(` SELECT product_category AS category, @@ -222,8 +261,8 @@ SELECT FROM (%s) AS combined%s GROUP BY product_category, unit_id, unit ORDER BY product_category ASC, unit ASC -`, unionSQL, searchClause) - queryArgs := append(append([]any{}, args...), searchArgs...) +`, unionSQL, whereClause) + queryArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...) var rows []SapronakSummaryRow 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 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 { for _, j := range joins { if strings.TrimSpace(j) != "" { @@ -878,6 +927,14 @@ func (r *ClosingRepositoryImpl) fetchSapronakUsage( 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( ctx context.Context, table string, @@ -909,11 +966,11 @@ func (r *ClosingRepositoryImpl) fetchSapronakDetails( 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 { return nil, nil } - return r.fetchSapronakUsage( + db := r.usageQuery( ctx, "recording_stocks rs", "pw.id = rs.product_warehouse_id", @@ -922,13 +979,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui pfkID, 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 { return []SapronakUsageRow{}, nil } - return r.fetchSapronakUsage( + db := r.usageQuery( ctx, "project_chickins pc", "pw.id = pc.product_warehouse_id", @@ -937,10 +996,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, p pfkID, 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) { - return r.fetchSapronakDetails( +func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + db := r.detailQuery( ctx, "recording_stocks rs", "pw.id = rs.product_warehouse_id", @@ -959,10 +1020,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p pfkID, 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) { - return r.fetchSapronakDetails( +func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + db := r.detailQuery( ctx, "project_chickins pc", "pw.id = pc.product_warehouse_id", @@ -981,13 +1044,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con pfkID, 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 { 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). Table("stock_allocations AS sa"). Select(` @@ -1029,17 +1095,18 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). - Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)"). + Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id"). + Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw_pc.product_id, pw.product_id)"). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). Where(` - (sa.usable_type = ? AND r.project_flock_kandangs_id = ?) + (sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?) OR - (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?) + (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?) `, - fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, - fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, + fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, sapronakFlagsUsage, + fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, sapronakFlagsChickin, ) query = r.joinSapronakProductFlag(query, "p_resolve"). Group(` @@ -1048,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, pi.price, p_resolve.product_price `) + query = applyDateRange(query, dateExpr, start, end) 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). Table("purchase_items AS pi"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). @@ -1061,12 +1129,13 @@ func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandan Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). Where("pi.received_date IS NOT NULL") + db = applyDateRange(db, "pi.received_date", start, end) 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) - db := r.incomingPurchaseBase(ctx, kandangID).Select(` + db := r.incomingPurchaseBase(ctx, kandangID, start, end).Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, @@ -1080,9 +1149,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kanda 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( - r.incomingPurchaseBase(ctx, kandangID).Select(` + r.incomingPurchaseBase(ctx, kandangID, start, end).Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, @@ -1177,7 +1246,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) 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(). Table("purchase_items pi"). Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). @@ -1204,11 +1273,13 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Where("f.name IN ?", sapronakFlagsAll). Where("COALESCE(ast.total_qty, 0) > 0") incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") + incomingQuery = applyDateRange(incomingQuery, "ast.created_at", start, end) incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { 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). Table("stock_allocations AS sa"). Select(` @@ -1241,6 +1312,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka 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") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") + outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end) outgoing, err := scanAndGroupDetails(outgoingQuery) if err != nil { return nil, nil, err @@ -1249,7 +1321,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka 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). Table("stock_transfer_details AS std"). Select(` @@ -1271,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("f.name IN ?", sapronakFlagsAll) incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") + incomingQuery = applyDateRange(incomingQuery, "st.transfer_date", start, end) incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err @@ -1299,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("f.name IN ?", sapronakFlagsAll) incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p") + incomingLayingQuery = applyDateRange(incomingLayingQuery, "lt.transfer_date", start, end) incomingLaying, err := scanAndGroupDetails(incomingLayingQuery) if err != nil { return nil, nil, err @@ -1332,6 +1406,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Where("f.name IN ?", sapronakFlagsAll). 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 = applyDateRange(outgoingQuery, "st.transfer_date", start, end) outgoing, err := scanAndGroupDetails(outgoingQuery) if err != nil { return nil, nil, err @@ -1363,6 +1438,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Where("f.name IN ?", sapronakFlagsAll). 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 = applyDateRange(outgoingLayingQuery, "lt.transfer_date", start, end) outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) if err != nil { return nil, nil, err @@ -1374,7 +1450,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand 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). Table("stock_allocations AS sa"). Select(` @@ -1398,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") query = r.joinSapronakProductFlag(query, "p") + query = applyDateRange(query, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end) sales, err := scanAndGroupDetails(query) if err != nil { return nil, err @@ -1430,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") nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p") + nonFifoQuery = applyDateRange(nonFifoQuery, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end) nonFifoSales, err := scanAndGroupDetails(nonFifoQuery) if err != nil { return nil, err @@ -1442,56 +1520,113 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF 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 { return map[uint][]SapronakDetailRow{}, nil } - query := r.withCtx(ctx). - Table("stock_allocations AS sa"). - Select(` - pw.product_id AS product_id, - p.name AS product_name, - f.name AS flag, - COALESCE( + 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 - ) AS date, - COALESCE( - po.po_number, - st.movement_number, - lt.transfer_number, - CONCAT('ADJ-', ast.id), - '' - ) AS reference, + ) + END + `, pfpType) + + query := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(fmt.Sprintf(` + p_resolve.id AS product_id, + p_resolve.name AS product_name, + f.name AS flag, + 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 AS date, + CASE + WHEN sa.stockable_type = '%s' THEN COALESCE( + po_pc.po_number, + st_pc.movement_number, + lt_pc.transfer_number, + CASE WHEN ast_pc.id IS NOT NULL THEN CONCAT('ADJ-', ast_pc.id) END, + CONCAT('CHICKIN-', pc.id), + '' + ) + ELSE COALESCE( + po.po_number, + st.movement_number, + lt.transfer_number, + CASE WHEN ast.id IS NOT NULL THEN CONCAT('ADJ-', ast.id) END, + '' + ) + END AS reference, 0 AS qty_in, COALESCE(SUM(sa.qty), 0) AS qty_out, - COALESCE(pi.price, p.product_price, 0) AS price - `). - Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). + CASE + WHEN sa.stockable_type = '%s' THEN COALESCE(pi_pc.price, p_resolve.product_price, 0) + ELSE COALESCE(pi.price, p_resolve.product_price, 0) + END AS price + `, pfpType, pfpType, pfpType)). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()). + Joins("JOIN product_warehouses pw_sales ON pw_sales.id = mdp.product_warehouse_id"). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id"). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Joins("LEFT JOIN stock_allocations sa_pc ON sa_pc.usable_type = ? AND sa_pc.usable_id = pc.id", fifo.UsableKeyProjectChickin.String()). + Joins("LEFT JOIN purchase_items pi_pc ON pi_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN purchases po_pc ON po_pc.id = pi_pc.purchase_id"). + Joins("LEFT JOIN stock_transfer_details std_pc ON std_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN stock_transfers st_pc ON st_pc.id = std_pc.stock_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt_pc ON ltt_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN laying_transfers lt_pc ON lt_pc.id = ltt_pc.laying_transfer_id"). + Joins("LEFT JOIN adjustment_stocks ast_pc ON ast_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). + Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id"). + Joins(fmt.Sprintf("LEFT JOIN products p_resolve ON p_resolve.id = CASE WHEN sa.stockable_type = '%s' THEN pw_pc.product_id ELSE COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id) END", pfpType)). Where("sa.status = ?", entity.StockAllocationStatusActive). - Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where("sa.stockable_type <> ?", fifo.StockableKeyRecordingEgg.String()). + Where("pw_sales.project_flock_kandang_id = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group(` - pw.product_id, p.name, f.name, + p_resolve.id, p_resolve.name, f.name, + pi_pc.received_date, st_pc.transfer_date, lt_pc.transfer_date, ast_pc.created_at, pc.chick_in_date, pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, + po_pc.po_number, st_pc.movement_number, lt_pc.transfer_number, ast_pc.id, pc.id, po.po_number, st.movement_number, lt.transfer_number, ast.id, - pi.price, p.product_price + pi_pc.price, pi.price, p_resolve.product_price, sa.stockable_type `) - query = r.joinSapronakProductFlag(query, "p") + query = r.joinSapronakProductFlag(query, "p_resolve") + query = applyDateRange(query, dateExpr, start, end) return scanAndGroupDetails(query) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 71bfcdec..cd8ea5ac 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -2,7 +2,6 @@ package service import ( "context" - "encoding/json" "errors" "fmt" "math" @@ -33,6 +32,14 @@ import ( "gorm.io/gorm" ) +type activeKandangMetric struct { + ProjectFlockKandangID uint + ProjectFlockID uint + KandangID uint + Category string + Metric float64 +} + type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, 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 + 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{ Type: params.Type, WarehouseIDs: warehouseIDs, @@ -392,6 +404,8 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa Limit: params.Limit, Offset: offset, Search: params.Search, + StartDate: startDate, + EndDate: endDate, }) if err != nil { 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{ Type: params.Type, WarehouseIDs: warehouseIDs, ProjectFlockKandangIDs: projectFlockKandangIDs, Search: params.Search, + StartDate: startDate, + EndDate: endDate, }) if err != nil { 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 } +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 { qtyStr := strconv.FormatFloat(qty, 'f', -1, 64) if uom == "" { @@ -616,38 +722,17 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl 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) if err != nil { return nil, err } 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) if err != nil { return nil, err @@ -688,11 +773,197 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl 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 } +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) { if projectFlockKandangID != nil { if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index ba79db1d..460b139a 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -123,7 +124,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val 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) if err != nil { 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) { - // For sapronak closing report we intentionally ignore date range - // and aggregate all historical transactions for the kandang/project. - incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) + // Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any). + startDate, endDate := sapronakPeriodRange(pfk) + incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, startDate, endDate) if err != nil { 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 { 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 { 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 { 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 { 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 { 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 { return nil, nil, 0, 0, err } @@ -413,15 +414,15 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj usageDetailsRows = usageAllocatedDetails 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 { 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 { 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 { return nil, nil, 0, 0, err } @@ -470,6 +471,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj // should not be counted yet. Only when category is LAYING we allow // pullet usage to contribute to qty_used. isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) + hasChickin := len(pfk.Chickins) > 0 if !isLaying { filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows)) @@ -491,11 +493,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj 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 { if len(rows) == 0 { continue @@ -512,6 +509,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj transOutgoing := detailMaps.TransferOut 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) transOutgoing = dedupTransfers(transOutgoing) @@ -775,6 +777,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if !matchesFlag(flag) { continue } + if hasChickin && (strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER")) { + continue + } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { @@ -794,6 +799,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if !matchesFlag(flag) { continue } + // For chicken, we don't count sales as sapronak outflow. + if strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER") { + continue + } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { @@ -815,3 +824,20 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj 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 +} diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 68890c9a..d41c5dd7 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -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 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 kandangs ON kandangs.id = expense_nonstocks.kandang_id"). Where("expenses.realization_date IS NOT NULL"). Where("expenses.category = ?", "BOP") if projectFlockKandangID != nil { db = db.Where(`( 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) - )`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) + )`, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) } else { db = db.Where(`( 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) - )`, projectFlockID, projectFlockID, fmt.Sprintf("[%d]", projectFlockID)) + )`, projectFlockID, fmt.Sprintf("[%d]", projectFlockID)) } err := db.Find(&realizations).Error diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 677ef965..d3edf3b4 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -4,8 +4,8 @@ import ( "context" "errors" "fmt" + "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" @@ -117,18 +117,30 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO Preload("Products.DeliveryProduct") if params.Status != "" { + status := strings.TrimSpace(params.Status) latestApprovalSubQuery := s.MarketingRepo.DB(). WithContext(c.Context()). Table("approvals"). - Select("DISTINCT ON (approvable_id) approvable_id, step_name"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, action"). Where("approvable_type = ?", utils.ApprovalWorkflowMarketing.String()). Order("approvable_id, id DESC") - db = db.Where(`EXISTS ( - SELECT 1 - FROM (?) AS latest_approval - WHERE latest_approval.approvable_id = marketings.id - AND LOWER(latest_approval.step_name) = LOWER(?) - )`, latestApprovalSubQuery, params.Status) + + if strings.EqualFold(status, "DITOLAK") { + db = db.Where(`EXISTS ( + SELECT 1 + FROM (?) AS latest_approval + WHERE latest_approval.approvable_id = marketings.id + AND latest_approval.action = ? + )`, latestApprovalSubQuery, string(entity.ApprovalActionRejected)) + } else { + db = db.Where(`EXISTS ( + SELECT 1 + FROM (?) AS latest_approval + WHERE latest_approval.approvable_id = marketings.id + AND LOWER(latest_approval.step_name) = LOWER(?) + AND (latest_approval.action IS NULL OR latest_approval.action <> ?) + )`, latestApprovalSubQuery, status, string(entity.ApprovalActionRejected)) + } } if params.Search != "" { @@ -548,11 +560,17 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor if deliveryProduct == nil || deliveryProduct.Id == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") } + if deliveryProduct.ProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Delivery product warehouse not found") + } + if deliveryProduct.ProductWarehouseId != marketingProduct.ProductWarehouseId { + return fiber.NewError(fiber.StatusBadRequest, "Delivery product warehouse mismatch with marketing product") + } result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: fifo.UsableKeyMarketingDelivery, UsableID: deliveryProduct.Id, - ProductWarehouseID: marketingProduct.ProductWarehouseId, + ProductWarehouseID: deliveryProduct.ProductWarehouseId, Quantity: requestedQty, AllowPending: false, Tx: tx, @@ -573,12 +591,12 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor Decrease: result.UsageQuantity, LoggableType: string(utils.StockLogTypeMarketing), LoggableId: deliveryProduct.Id, - ProductWarehouseId: marketingProduct.ProductWarehouseId, + ProductWarehouseId: deliveryProduct.ProductWarehouseId, CreatedBy: actorID, Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, deliveryProduct.ProductWarehouseId, 1) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index eb2e4f5b..7d032c86 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -152,6 +152,31 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } } + requestedByWarehouse := make(map[uint]float64) + for _, item := range req.MarketingProducts { + if item.ProductWarehouseId == 0 { + continue + } + requestedByWarehouse[item.ProductWarehouseId] += item.Qty + } + + for pwID, requestedQty := range requestedByWarehouse { + productWarehouse, err := s.ProductWarehouseRepo.GetDetailByID(c.Context(), pwID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", pwID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock availability") + } + availableQty := productWarehouse.Quantity + if availableQty+1e-6 < requestedQty { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Stok tidak mencukupi untuk gudang %d: diminta %.3f, tersedia %.3f", pwID, requestedQty, availableQty), + ) + } + } + soDate, err := utils.ParseDateString(req.Date) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format") diff --git a/internal/modules/master/locations/controllers/location.controller.go b/internal/modules/master/locations/controllers/location.controller.go index f360a9c9..7a3bb5ff 100644 --- a/internal/modules/master/locations/controllers/location.controller.go +++ b/internal/modules/master/locations/controllers/location.controller.go @@ -24,10 +24,11 @@ func NewLocationController(locationService service.LocationService) *LocationCon func (u *LocationController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), - AreaId: c.QueryInt("area_id", 0), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + AreaId: c.QueryInt("area_id", 0), + HasLaying: c.QueryBool("has_laying", false), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 03f6cf45..8aa01dbf 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -60,6 +60,17 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) } + if params.HasLaying { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM project_flocks pf + WHERE pf.location_id = locations.id + AND pf.category = ? + AND pf.deleted_at IS NULL + ) + `, utils.ProjectFlockCategoryLaying) + } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go index a2ac6175..fbdc1572 100644 --- a/internal/modules/master/locations/validations/location.validation.go +++ b/internal/modules/master/locations/validations/location.validation.go @@ -13,8 +13,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` - Search string `query:"search" validate:"omitempty,max=50"` - AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` + Search string `query:"search" validate:"omitempty,max=50"` + AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + HasLaying bool `query:"has_laying"` } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 61a593d5..4374ba25 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -308,25 +308,23 @@ func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*Closin } for _, pw := range productWarehouses { - if pw.Quantity > 0 { - category := "" - if pw.Product.ProductCategory.Id != 0 { - category = pw.Product.ProductCategory.Name - } - uomName := "" - if pw.Product.Uom.Id != 0 { - uomName = pw.Product.Uom.Name - } - stockRemain = append(stockRemain, StockRemainingDetail{ - FlagName: string(flagName), - ProductWarehouseId: pw.Id, - ProductId: pw.ProductId, - ProductName: pw.Product.Name, - ProductCategory: category, - Uom: uomName, - Quantity: pw.Quantity, - }) + category := "" + if pw.Product.ProductCategory.Id != 0 { + category = pw.Product.ProductCategory.Name } + uomName := "" + if pw.Product.Uom.Id != 0 { + uomName = pw.Product.Uom.Name + } + stockRemain = append(stockRemain, StockRemainingDetail{ + FlagName: string(flagName), + ProductWarehouseId: pw.Id, + ProductId: pw.ProductId, + ProductName: pw.Product.Name, + ProductCategory: category, + Uom: uomName, + Quantity: pw.Quantity, + }) } } } @@ -585,7 +583,7 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati } } if s.ApprovalSvc != nil { - reopenAction := entity.ApprovalActionUpdated + reopenAction := entity.ApprovalActionApproved // Hindari duplikasi jika approval terakhir sudah Disetujui + Updated latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) if lerr != nil { @@ -611,6 +609,31 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati return nil, aerr } } + + // Pastikan approval project flock kembali ke Aktif + latestPF, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil) + if lerr != nil { + return nil, lerr + } + shouldCreatePF := true + if latestPF != nil && + latestPF.StepNumber == uint16(utils.ProjectFlockStepAktif) && + latestPF.Action != nil && *latestPF.Action == reopenAction { + shouldCreatePF = false + } + if shouldCreatePF { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + pfk.ProjectFlockId, + utils.ProjectFlockStepAktif, + &reopenAction, + actorID, + nil, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return nil, aerr + } + } } default: return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose") diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index e7240b49..2701134c 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -42,6 +42,7 @@ type KandangWithProjectFlockIdDTO struct { kandangDTO.KandangRelationDTO ProjectFlockKandangId uint `json:"project_flock_kandang_id"` Period int `json:"period"` + ClosedAt *time.Time `json:"closed_at,omitempty"` } type ProjectFlockDetailDTO struct { @@ -74,20 +75,28 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF for i, kandang := range e.Kandangs { var ( - pfkId uint - period int + pfkId uint + period int + closedAt *time.Time ) for _, kh := range e.KandangHistory { if kh.KandangId == kandang.Id { pfkId = kh.Id period = kh.Period + closedAt = kh.ClosedAt break } } + mapped := kandangDTO.ToKandangRelationDTO(kandang) + if closedAt != nil { + // Jangan ubah tabel kandang, hanya override status di response. + mapped.Status = string(utils.KandangStatusNonActive) + } kandangSummaries[i] = KandangWithProjectFlockIdDTO{ - KandangRelationDTO: kandangDTO.ToKandangRelationDTO(kandang), + KandangRelationDTO: mapped, ProjectFlockKandangId: pfkId, Period: period, + ClosedAt: closedAt, } } } diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 5e75d4a9..d35bf78e 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -196,7 +196,11 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) if err != nil { - utils.Log.Errorf("access token verification failed: %v", err) + if sso.IsSignatureError(err) { + logSignatureError("sso refresh", "sso_token", tokenResp.AccessToken, err) + } else { + utils.Log.Errorf("access token verification failed: %v", err) + } return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") } @@ -304,7 +308,11 @@ func (h *Controller) Callback(c *fiber.Ctx) error { verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) if err != nil { - utils.Log.Errorf("access token verification failed: %v", err) + if sso.IsSignatureError(err) { + logSignatureError("sso callback", "sso_token", tokenResp.AccessToken, err) + } else { + utils.Log.Errorf("access token verification failed: %v", err) + } return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") } @@ -337,6 +345,22 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { token := strings.TrimSpace(c.Cookies(accessName)) tokenFromCookie := token != "" + usedCookieName := accessName + + if !tokenFromCookie { + for _, name := range config.SSOAccessCookieFallback { + name = strings.TrimSpace(name) + if name == "" || name == accessName { + continue + } + token = strings.TrimSpace(c.Cookies(name)) + if token != "" { + tokenFromCookie = true + usedCookieName = name + break + } + } + } if !tokenFromCookie { authHeader := strings.TrimSpace(c.Get("Authorization")) @@ -363,7 +387,11 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { } if _, err := sso.VerifyAccessToken(token); err != nil { - utils.Log.WithError(err).Warn("access token verification failed for userinfo") + if sso.IsSignatureError(err) { + logSignatureError("sso userinfo", "request", token, err) + } else { + utils.Log.WithError(err).Warn("access token verification failed for userinfo") + } return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") } @@ -382,7 +410,7 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { // SSO /auth/get-me expects the access cookie; add Authorization as well for compatibility. req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) if tokenFromCookie { - req.Header.Set("Cookie", fmt.Sprintf("%s=%s", accessName, token)) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", usedCookieName, token)) } resp, err := h.httpClient.Do(req) @@ -400,13 +428,6 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { 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 != "" { c.Set("Content-Type", ct) } else { @@ -418,17 +439,9 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { // Logout clears SSO cookies and removes any leftover PKCE session state. func (h *Controller) Logout(c *fiber.Ctx) error { - requestedAlias := normalizeClientParam(c.Query("client")) - if requestedAlias == "" { - requestedAlias = normalizeClientParam(c.Query("client_id")) - } - var ( - alias string - cfg config.SSOClientConfig - hasClientInfo bool - ) - if requestedAlias != "" { - alias, cfg, hasClientInfo = findSSOClientConfig(requestedAlias) + alias := "" + if singleAlias, _, ok := singleSSOClient(); ok { + alias = singleAlias } accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") @@ -445,14 +458,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error { hadAccessCookie := accessToken != "" hadRefreshCookie := refreshToken != "" - state := strings.TrimSpace(c.Query("state")) - 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 == "" { + if !hadAccessCookie && !hadRefreshCookie { return fiber.NewError(fiber.StatusUnauthorized, "not authenticated") } @@ -477,52 +483,20 @@ func (h *Controller) Logout(c *fiber.Ctx) error { clearSSOCookie(c, refreshName) redirectTarget := "" - rawReturn := strings.TrimSpace(c.Query("return_to")) - if hasClientInfo { - 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 - } + if config.SSOPortalURL != "" { + redirectTarget = config.SSOPortalURL } utils.Log.WithFields(logrus.Fields{ "client": alias, - "state": state, "redirect": redirectTarget, }).Info("sso logout completed") 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"}) @@ -541,145 +515,6 @@ func singleSSOClient() (string, config.SSOClientConfig, bool) { 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 { for alias := range config.SSOClients { @@ -836,6 +671,27 @@ func resolveSSOCookieName(configuredName, fallback string) string { return strings.TrimSpace(fallback) } +func logSignatureError(ctxLabel, tokenSource, token string, err error) { + info := sso.ExtractTokenInfo(token) + aud := strings.Join(info.Aud, ",") + utils.Log.Errorf( + "access token verification failed: %v | ctx=%s source=%s iss=%s kid=%s aud=%s sub=%s exp=%d iat=%d nbf=%d expected_iss=%s expected_aud=%v jwks=%s", + err, + ctxLabel, + tokenSource, + info.Iss, + info.Kid, + aud, + info.Sub, + info.Exp, + info.Iat, + info.Nbf, + config.SSOIssuer, + config.SSOAllowedAudiences, + config.SSOJWKSURL, + ) +} + func normalizeClientParam(raw string) string { value := strings.TrimSpace(raw) if value == "" { @@ -848,98 +704,6 @@ func normalizeClientParam(raw string) string { 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) { if requestedAlias == "" { diff --git a/internal/modules/sso/verifier/verifier.go b/internal/modules/sso/verifier/verifier.go index 0c8d97e8..7d7cefbb 100644 --- a/internal/modules/sso/verifier/verifier.go +++ b/internal/modules/sso/verifier/verifier.go @@ -2,9 +2,11 @@ package sso import ( "context" + "encoding/json" "errors" "fmt" "net/http" + "os" "strconv" "strings" "sync" @@ -41,6 +43,16 @@ type VerificationResult struct { Claims *AccessTokenClaims } +type TokenInfo struct { + Kid string + Iss string + Aud []string + Sub string + Exp int64 + Iat int64 + Nbf int64 +} + var ( globalMu sync.RWMutex globalV *verifier @@ -106,10 +118,19 @@ func VerifyAccessToken(token string) (*VerificationResult, error) { jwt.WithIssuedAt(), jwt.WithExpirationRequired(), ) - + tok, err := parser.ParseWithClaims(token, claims, v.jwks.Keyfunc) if err != nil { - return nil, fmt.Errorf("parse token: %w", err) + if shouldRefreshOnVerifyError(err) { + if refreshErr := v.jwks.Refresh(context.Background(), keyfunc.RefreshOptions{IgnoreRateLimit: true}); refreshErr != nil { + utils.Log.WithError(refreshErr).Warn("sso jwks refresh after signature error failed") + } else { + tok, err = parser.ParseWithClaims(token, claims, v.jwks.Keyfunc) + } + } + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } } if !tok.Valid { return nil, errors.New("invalid token") @@ -158,3 +179,106 @@ func VerifyAccessToken(token string) (*VerificationResult, error) { return result, nil } + +func shouldRefreshOnVerifyError(err error) bool { + if !IsSignatureError(err) { + return false + } + return !disableRefreshOnSignatureError() +} + +func IsSignatureError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "verification error") || strings.Contains(msg, "token signature is invalid") +} + +func disableRefreshOnSignatureError() bool { + val := strings.TrimSpace(os.Getenv("SSO_DISABLE_JWKS_REFRESH_ON_SIG_ERROR")) + if val == "" { + return false + } + return val == "1" || strings.EqualFold(val, "true") || strings.EqualFold(val, "yes") +} + +func ExtractTokenInfo(token string) TokenInfo { + token = strings.TrimSpace(token) + if token == "" { + return TokenInfo{} + } + + claims := jwt.MapClaims{} + parser := jwt.NewParser() + tok, _, err := parser.ParseUnverified(token, claims) + if err != nil { + return TokenInfo{} + } + + info := TokenInfo{} + if kid, ok := tok.Header["kid"].(string); ok { + info.Kid = kid + } + if iss, ok := claims["iss"].(string); ok { + info.Iss = iss + } + if sub, ok := claims["sub"].(string); ok { + info.Sub = sub + } + if aud, ok := claims["aud"]; ok { + info.Aud = toStringSlice(aud) + } + info.Exp = toInt64(claims["exp"]) + info.Iat = toInt64(claims["iat"]) + info.Nbf = toInt64(claims["nbf"]) + return info +} + +func toStringSlice(v any) []string { + switch t := v.(type) { + case string: + if t == "" { + return nil + } + return []string{t} + case []string: + out := make([]string, 0, len(t)) + for _, s := range t { + if s != "" { + out = append(out, s) + } + } + return out + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func toInt64(v any) int64 { + switch t := v.(type) { + case int64: + return t + case int: + return int64(t) + case float64: + return int64(t) + case json.Number: + if n, err := t.Int64(); err == nil { + return n + } + case string: + if n, err := strconv.ParseInt(t, 10, 64); err == nil { + return n + } + } + return 0 +}