Files
lti-api/internal/modules/marketing/services/deliveryorder.service.go
T
2026-03-30 13:40:29 +07:00

1111 lines
39 KiB
Go

package service
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"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type DeliveryOrdersService interface {
GetAll(ctx *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error)
CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error)
UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error)
}
type deliveryOrdersService struct {
Validate *validator.Validate
MarketingRepo marketingRepo.MarketingRepository
MarketingProductRepo marketingRepo.MarketingProductRepository
MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository
StockLogRepo rShared.StockLogRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
ApprovalSvc commonSvc.ApprovalService
FifoStockV2Svc commonSvc.FifoStockV2Service
}
func NewDeliveryOrdersService(
marketingRepo marketingRepo.MarketingRepository,
marketingProductRepo marketingRepo.MarketingProductRepository,
marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository,
stockLogRepo rShared.StockLogRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
approvalSvc commonSvc.ApprovalService,
fifoStockV2Svc commonSvc.FifoStockV2Service,
validate *validator.Validate,
) DeliveryOrdersService {
return &deliveryOrdersService{
Validate: validate,
MarketingRepo: marketingRepo,
MarketingProductRepo: marketingProductRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
StockLogRepo: stockLogRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
ApprovalSvc: approvalSvc,
FifoStockV2Svc: fifoStockV2Svc,
}
}
func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("Customer").
Preload("SalesPerson").
Preload("Products.ProductWarehouse.Product").
Preload("Products.ProductWarehouse.Product.Uom").
Preload("Products.ProductWarehouse.Warehouse").
Preload("Products.DeliveryProduct")
}
func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil {
return nil, err
}
marketing, err := s.MarketingRepo.GetByID(c.Context(), marketingId, s.withRelations)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, marketingId, nil)
if err != nil {
}
marketing.LatestApproval = latestApproval
allDeliveryProducts, err := s.MarketingDeliveryProductRepo.GetByMarketingId(c.Context(), marketingId)
if err != nil {
allDeliveryProducts = []entity.MarketingDeliveryProduct{}
}
responseDTO := dto.ToMarketingDetailDTO(marketing, allDeliveryProducts)
return &responseDTO, nil
}
func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
scope, err := m.ResolveLocationScope(c, s.MarketingRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
marketings, total, err := s.MarketingRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = db.
Preload("CreatedUser").
Preload("Customer").
Preload("SalesPerson").
Preload("Products.ProductWarehouse.Product").
Preload("Products.ProductWarehouse.Product.Uom").
Preload("Products.ProductWarehouse.Warehouse").
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, action").
Where("approvable_type = ?", utils.ApprovalWorkflowMarketing.String()).
Order("approvable_id, id DESC")
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 != "" {
searchPattern := "%" + params.Search + "%"
db = db.Where(`(
marketings.so_number ILIKE ? OR
EXISTS (
SELECT 1
FROM customers c
WHERE c.id = marketings.customer_id
AND c.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM users su
WHERE su.id = marketings.sales_person_id
AND su.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE mp.marketing_id = marketings.id
AND w.name ILIKE ?
)
)`, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern)
}
if len(params.ProductIDs) > 0 {
db = db.Where(`EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.id IN ?
)`, params.ProductIDs)
}
if params.CustomerId != 0 {
db = db.Where("marketings.customer_id = ?", params.CustomerId)
}
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where(
`EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE mp.marketing_id = marketings.id
AND w.location_id IN ?
)`,
scope.IDs,
)
}
if params.MarketingId != 0 {
return db.Where("id = ?", params.MarketingId)
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
return nil, 0, err
}
for i := range marketings {
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, marketings[i].Id, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
continue
}
marketings[i].LatestApproval = latestApproval
}
result := make([]dto.MarketingListDTO, len(marketings))
for i, marketing := range marketings {
result[i] = dto.ToMarketingListDTO(&marketing, []entity.MarketingDeliveryProduct{})
}
return result, total, nil
}
func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
}
marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Marketing not found")
}
if err != nil {
return nil, err
}
allDeliveryProducts, err := s.MarketingDeliveryProductRepo.GetByMarketingId(c.Context(), id)
if err != nil {
allDeliveryProducts = []entity.MarketingDeliveryProduct{}
}
if s.ApprovalSvc != nil {
approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), utils.ApprovalWorkflowMarketing, marketing.Id, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
} else if len(approvals) > 0 {
if marketing.LatestApproval == nil {
latest := approvals[len(approvals)-1]
marketing.LatestApproval = &latest
}
} else {
marketing.LatestApproval = nil
}
}
responseDTO := dto.ToMarketingDetailDTO(marketing, allDeliveryProducts)
return &responseDTO, nil
}
func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), req.MarketingId); err != nil {
return nil, err
}
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Marketing", ID: &req.MarketingId, Exists: s.MarketingRepo.IdExists},
); err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.MarketingRepo.DB()))
latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, req.MarketingId, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status")
}
if latestApproval == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Marketing has not been submitted for approval")
}
if latestApproval.StepNumber < uint16(utils.MarketingStepSalesOrder) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Marketing must be approved to Sales Order step before creating delivery order")
}
if latestApproval.StepNumber >= uint16(utils.MarketingDeliveryOrder) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery order already exists for this marketing")
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
marketing, err := marketingRepoTx.GetByID(c.Context(), req.MarketingId, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
}
allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("No marketing products found for marketing %d", req.MarketingId))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing products")
}
for _, requestedProduct := range req.DeliveryProducts {
var foundMarketingProduct *entity.MarketingProduct
for i := range allMarketingProducts {
if allMarketingProducts[i].Id == requestedProduct.MarketingProductId {
foundMarketingProduct = &allMarketingProducts[i]
break
}
}
if foundMarketingProduct == nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId))
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(
c.Context(),
dbTransaction,
[]uint{foundMarketingProduct.ProductWarehouseId},
); err != nil {
return err
}
deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Delivery product for marketing product %d not found", requestedProduct.MarketingProductId))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product")
}
var itemDeliveryDate *time.Time
if requestedProduct.DeliveryDate != "" {
parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid delivery date format for product %d: %v", requestedProduct.MarketingProductId, err))
}
itemDeliveryDate = &parsedDate
}
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week)
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
deliveryProduct.TotalWeight = totalWeight
deliveryProduct.TotalPrice = totalPrice
deliveryProduct.DeliveryDate = itemDeliveryDate
deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber
if requestedProduct.Qty > 0 {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil {
return err
}
}
if err := marketingDeliveryProductRepositoryTx.UpdateOne(c.Context(), deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
}
}
approvalAction := entity.ApprovalActionApproved
if _, err := approvalSvcTx.CreateApproval(
c.Context(),
utils.ApprovalWorkflowMarketing,
req.MarketingId,
utils.MarketingDeliveryOrder,
&approvalAction,
actorID,
nil); err != nil {
if !errors.Is(err, gorm.ErrDuplicatedKey) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create delivery order approval")
}
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create delivery order")
}
return s.getMarketingWithDeliveries(c, req.MarketingId)
}
func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
}
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists},
); err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
}
allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing products")
}
if len(req.DeliveryProducts) > 0 {
for _, requestedProduct := range req.DeliveryProducts {
var foundMarketingProduct *entity.MarketingProduct
for i := range allMarketingProducts {
if allMarketingProducts[i].Id == requestedProduct.MarketingProductId {
foundMarketingProduct = &allMarketingProducts[i]
break
}
}
if foundMarketingProduct == nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId))
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(
c.Context(),
dbTransaction,
[]uint{foundMarketingProduct.ProductWarehouseId},
); err != nil {
return err
}
deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Delivery product for marketing product %d not found", requestedProduct.MarketingProductId))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product")
}
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
var itemDeliveryDate *time.Time
if requestedProduct.DeliveryDate != "" {
parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid delivery date format for product %d: %v", requestedProduct.MarketingProductId, err))
}
itemDeliveryDate = &parsedDate
} else if deliveryProduct.DeliveryDate != nil {
itemDeliveryDate = deliveryProduct.DeliveryDate
}
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week)
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
deliveryProduct.TotalWeight = totalWeight
deliveryProduct.TotalPrice = totalPrice
deliveryProduct.DeliveryDate = itemDeliveryDate
deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber
if requestedProduct.Qty != oldRequestedQty {
if oldRequestedQty > 0 {
if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, actorID); err != nil {
return err
}
}
if requestedProduct.Qty > 0 {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil {
return err
}
}
}
if err := marketingDeliveryProductRepositoryTx.UpdateOne(c.Context(), deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
}
}
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery order")
}
return s.getMarketingWithDeliveries(c, id)
}
func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) {
if marketingType == string(utils.MarketingTypeTrading) {
totalWeight = 0
totalPrice = qty * unitPrice
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
totalWeight = qty * avgWeight
totalPrice = unitPrice * float64(*week) * qty
} else {
totalWeight = qty * avgWeight
totalPrice = totalWeight * unitPrice
}
return totalWeight, totalPrice
}
func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error {
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
}
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")
}
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
previousUsage := deliveryProduct.UsageQty
deliveryProduct.UsageQty = requestedQty
deliveryProduct.PendingQty = 0
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
}
if err := reflowMarketingScope(
ctx,
s.FifoStockV2Svc,
tx,
marketingProduct.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err))
}
refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh delivery product")
}
deliveryProduct.UsageQty = refreshed.UsageQty
deliveryProduct.PendingQty = refreshed.PendingQty
deliveryProduct.CreatedAt = refreshed.CreatedAt
if err := s.allocatePopulationForMarketingDelivery(ctx, tx, deliveryProduct, marketingProduct.ProductWarehouseId); err != nil {
return err
}
allocatedDelta := deliveryProduct.UsageQty - previousUsage
if actorID > 0 && allocatedDelta > 0 {
decreaseLog := &entity.StockLog{
Decrease: allocatedDelta,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: deliveryProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: fmt.Sprintf("FIFO v2 reflow consume (%.2f)", allocatedDelta),
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, deliveryProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock - decreaseLog.Decrease
} else {
decreaseLog.Stock -= decreaseLog.Decrease
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
return nil
}
func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, actorID uint) error {
if deliveryProduct == nil || deliveryProduct.Id == 0 {
return nil
}
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
}
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
currentUsage := deliveryProduct.UsageQty
currentPending := deliveryProduct.PendingQty
if currentUsage <= 0 && currentPending <= 0 {
return nil
}
affectedKandangIDs, err := s.marketingPopulationKandangIDsFromActiveAllocations(ctx, tx, deliveryProduct.Id)
if err != nil {
return err
}
deliveryProduct.UsageQty = 0
deliveryProduct.PendingQty = 0
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset delivery product")
}
if err := reflowMarketingScope(
ctx,
s.FifoStockV2Svc,
tx,
marketingProduct.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err))
}
refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh delivery product")
}
deliveryProduct.UsageQty = refreshed.UsageQty
deliveryProduct.PendingQty = refreshed.PendingQty
deliveryProduct.CreatedAt = refreshed.CreatedAt
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
if err := s.resyncPopulationUsageByKandangIDs(ctx, tx, affectedKandangIDs); err != nil {
return err
}
releasedUsage := currentUsage - deliveryProduct.UsageQty
if actorID > 0 && releasedUsage > 0 {
increaseLog := &entity.StockLog{
Increase: releasedUsage,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: fmt.Sprintf("FIFO v2 reflow release (%.2f)", releasedUsage),
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
increaseLog.Stock = latestStockLog.Stock + increaseLog.Increase
} else {
increaseLog.Stock += increaseLog.Increase
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil)
}
return nil
}
func (s deliveryOrdersService) allocatePopulationForMarketingDelivery(
ctx context.Context,
tx *gorm.DB,
deliveryProduct *entity.MarketingDeliveryProduct,
productWarehouseID uint,
) error {
if deliveryProduct == nil || deliveryProduct.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Delivery product tidak valid")
}
if tx == nil {
return errors.New("transaction is required")
}
if deliveryProduct.UsageQty <= 0 {
return nil
}
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak ditemukan")
}
flagGroupCode, err := resolveMarketingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if !strings.EqualFold(flagGroupCode, "AYAM") {
return nil
}
exactAllocations, err := s.findDirectPopulationAllocationsForMarketing(ctx, tx, deliveryProduct.Id)
if err != nil {
return err
}
if len(exactAllocations) > 0 {
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
if err := s.applyDirectPopulationAllocationsForMarketing(ctx, tx, productWarehouseID, deliveryProduct.Id, exactAllocations); err != nil {
return err
}
return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingAllocationKandangIDs(exactAllocations))
}
sourceGroups, err := s.findPopulationSourceGroupsForMarketing(ctx, tx, deliveryProduct.Id, productWarehouseID)
if err != nil {
return err
}
if len(sourceGroups) == 0 {
return nil
}
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
for _, group := range sourceGroups {
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(
ctx,
group.ProjectFlockKandangID,
group.ProductWarehouseID,
)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery")
}
if err := s.allocatePopulationConsumptionWithoutRelease(
ctx,
tx,
populations,
productWarehouseID,
deliveryProduct.Id,
group.Qty,
); err != nil {
return err
}
}
return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingSourceGroupKandangIDs(sourceGroups))
}
type marketingPopulationAllocation struct {
ProjectFlockPopulationID uint `gorm:"column:project_flock_population_id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
Qty float64 `gorm:"column:qty"`
}
type marketingPopulationSourceGroup struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
Qty float64 `gorm:"column:qty"`
}
func (s deliveryOrdersService) findDirectPopulationAllocationsForMarketing(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
) ([]marketingPopulationAllocation, error) {
var rows []marketingPopulationAllocation
err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
pfp.id AS project_flock_population_id,
pc.project_flock_kandang_id AS project_flock_kandang_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Group("pfp.id, pc.project_flock_kandang_id").
Order("pfp.id ASC").
Scan(&rows).Error
return rows, err
}
func (s deliveryOrdersService) findPopulationSourceGroupsForMarketing(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
productWarehouseID uint,
) ([]marketingPopulationSourceGroup, error) {
groups := make(map[string]marketingPopulationSourceGroup)
appendGroup := func(projectFlockKandangID uint, sourceProductWarehouseID uint, qty float64) {
if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || qty <= 0 {
return
}
key := fmt.Sprintf("%d:%d", projectFlockKandangID, sourceProductWarehouseID)
current := groups[key]
current.ProjectFlockKandangID = projectFlockKandangID
current.ProductWarehouseID = sourceProductWarehouseID
current.Qty += qty
groups[key] = current
}
var transferRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
source_pw.project_flock_kandang_id AS project_flock_kandang_id,
std.source_product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("source_pw.project_flock_kandang_id IS NOT NULL").
Group("source_pw.project_flock_kandang_id, std.source_product_warehouse_id").
Scan(&transferRows).Error; err != nil {
return nil, err
}
for _, row := range transferRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
var purchaseRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
pi.project_flock_kandang_id AS project_flock_kandang_id,
pi.product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("pi.project_flock_kandang_id IS NOT NULL").
Where("pi.product_warehouse_id IS NOT NULL").
Group("pi.project_flock_kandang_id, pi.product_warehouse_id").
Scan(&purchaseRows).Error; err != nil {
return nil, err
}
for _, row := range purchaseRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
var layingRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
ltt.product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("ltt.product_warehouse_id IS NOT NULL").
Group("ltt.target_project_flock_kandang_id, ltt.product_warehouse_id").
Scan(&layingRows).Error; err != nil {
return nil, err
}
for _, row := range layingRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
if len(groups) == 0 {
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil)
if err != nil {
return nil, err
}
if pw.ProjectFlockKandangId != nil && *pw.ProjectFlockKandangId != 0 {
appendGroup(*pw.ProjectFlockKandangId, productWarehouseID, 0)
}
}
result := make([]marketingPopulationSourceGroup, 0, len(groups))
for _, group := range groups {
if group.Qty == 0 {
group.Qty = s.resolveMarketingRequestedUsageQty(ctx, tx, deliveryProductID)
}
if group.Qty > 0 {
result = append(result, group)
}
}
return result, nil
}
func (s deliveryOrdersService) applyDirectPopulationAllocationsForMarketing(
ctx context.Context,
tx *gorm.DB,
productWarehouseID uint,
deliveryProductID uint,
allocations []marketingPopulationAllocation,
) error {
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, allocation := range allocations {
if allocation.ProjectFlockPopulationID == 0 || allocation.Qty <= 0 {
continue
}
record := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: allocation.ProjectFlockPopulationID,
UsableType: fifo.UsableKeyMarketingDelivery.String(),
UsableId: deliveryProductID,
Qty: allocation.Qty,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, record, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", allocation.ProjectFlockPopulationID).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", allocation.Qty)).Error; err != nil {
return err
}
}
return nil
}
func (s deliveryOrdersService) allocatePopulationConsumptionWithoutRelease(
ctx context.Context,
tx *gorm.DB,
populations []entity.ProjectFlockPopulation,
productWarehouseID uint,
deliveryProductID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
remaining := consumeQty
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, population := range populations {
available := population.TotalQty - population.TotalUsedQty
if available <= 0 {
continue
}
portion := available
if remaining < portion {
portion = remaining
}
if portion <= 0 {
continue
}
record := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: population.Id,
UsableType: fifo.UsableKeyMarketingDelivery.String(),
UsableId: deliveryProductID,
Qty: portion,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, record, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", population.Id).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
return err
}
remaining -= portion
if remaining <= 0.000001 {
break
}
}
if remaining > 0.000001 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
}
return nil
}
func (s deliveryOrdersService) marketingPopulationKandangIDsFromActiveAllocations(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
) ([]uint, error) {
var ids []uint
err := tx.WithContext(ctx).
Table("stock_allocations sa").
Distinct("pc.project_flock_kandang_id").
Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Pluck("pc.project_flock_kandang_id", &ids).Error
return ids, err
}
func (s deliveryOrdersService) resyncPopulationUsageByKandangIDs(ctx context.Context, tx *gorm.DB, kandangIDs []uint) error {
for _, kandangID := range uniqueUintIDs(kandangIDs) {
if kandangID == 0 {
continue
}
if err := s.ProjectFlockPopulationRepo.WithTx(tx).ResyncUsageByProjectFlockKandangID(ctx, tx, kandangID); err != nil {
return err
}
}
return nil
}
func (s deliveryOrdersService) resolveMarketingRequestedUsageQty(ctx context.Context, tx *gorm.DB, deliveryProductID uint) float64 {
var usageQty float64
if err := tx.WithContext(ctx).
Table("marketing_delivery_products").
Select("usage_qty").
Where("id = ?", deliveryProductID).
Scan(&usageQty).Error; err != nil {
return 0
}
return usageQty
}
func marketingAllocationKandangIDs(rows []marketingPopulationAllocation) []uint {
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ProjectFlockKandangID)
}
return ids
}
func marketingSourceGroupKandangIDs(rows []marketingPopulationSourceGroup) []uint {
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ProjectFlockKandangID)
}
return ids
}
func uniqueUintIDs(ids []uint) []uint {
seen := make(map[uint]struct{}, len(ids))
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}