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..a796d513 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1029,17 +1029,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(` @@ -1447,51 +1448,90 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C return map[uint][]SapronakDetailRow{}, nil } + pfpType := fifo.StockableKeyProjectFlockPopulation.String() + query := r.withCtx(ctx). Table("stock_allocations AS sa"). - Select(` - pw.product_id AS product_id, - p.name AS product_name, + Select(fmt.Sprintf(` + p_resolve.id AS product_id, + p_resolve.name AS product_name, f.name AS flag, - 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, + 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") return scanAndGroupDetails(query) } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index ba79db1d..7e7c69b2 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -470,6 +470,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)) @@ -775,6 +776,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 +798,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 == "" { 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/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, } } }