diff --git a/internal/common/repository/common.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go new file mode 100644 index 00000000..38b1a93b --- /dev/null +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -0,0 +1,75 @@ +package repository + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockAllocationRepository interface { + BaseRepository[entity.StockAllocation] + FindActiveByUsable(ctx context.Context, usableType string, usableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.StockAllocation, error) + ReleaseByUsable(ctx context.Context, usableType string, usableID uint, note *string, modifier func(*gorm.DB) *gorm.DB) error +} + +type StockAllocationRepositoryImpl struct { + *BaseRepositoryImpl[entity.StockAllocation] +} + +func NewStockAllocationRepository(db *gorm.DB) StockAllocationRepository { + return &StockAllocationRepositoryImpl{ + BaseRepositoryImpl: NewBaseRepository[entity.StockAllocation](db), + } +} + +func (r *StockAllocationRepositoryImpl) FindActiveByUsable( + ctx context.Context, + usableType string, + usableID uint, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.StockAllocation, error) { + var allocations []entity.StockAllocation + + q := r.DB().WithContext(ctx). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + + if modifier != nil { + q = modifier(q) + } + + if err := q.Order("created_at ASC").Find(&allocations).Error; err != nil { + return nil, err + } + + return allocations, nil +} + +func (r *StockAllocationRepositoryImpl) ReleaseByUsable( + ctx context.Context, + usableType string, + usableID uint, + note *string, + modifier func(*gorm.DB) *gorm.DB, +) error { + now := time.Now() + + updates := map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + } + if note != nil { + updates["note"] = *note + } + + q := r.DB().WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + + if modifier != nil { + q = modifier(q) + } + + return q.Updates(updates).Error +} diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go new file mode 100644 index 00000000..e3b80268 --- /dev/null +++ b/internal/common/service/common.fifo.service.go @@ -0,0 +1,820 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/sirupsen/logrus" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/entities" + productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type FifoService interface { + RegisterStockable(cfg fifo.StockableConfig) error + RegisterUsable(cfg fifo.UsableConfig) error + + Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) + Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) + ReleaseUsage(ctx context.Context, req StockReleaseRequest) error +} + +type fifoService struct { + db *gorm.DB + logger *logrus.Logger + allocations commonRepo.StockAllocationRepository + productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository + defaultOrderBy []string + pendingBatchPerUsable int + maxLotsPerStockable int + defaultAllocationNotes string +} + +func NewFifoService( + db *gorm.DB, + allocations commonRepo.StockAllocationRepository, + productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, + logger *logrus.Logger, +) FifoService { + if logger == nil { + logger = logrus.StandardLogger() + } + return &fifoService{ + db: db, + logger: logger, + allocations: allocations, + productWarehouseRepo: productWarehouseRepo, + defaultOrderBy: []string{"created_at ASC", "id ASC"}, + pendingBatchPerUsable: 25, + maxLotsPerStockable: 50, + } +} + +func (s *fifoService) withTransaction( + ctx context.Context, + tx *gorm.DB, + fn func(*gorm.DB) error, +) error { + if tx != nil { + return fn(tx.WithContext(ctx)) + } + return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error { + return fn(inner) + }) +} + +func (s *fifoService) txOrDB(tx, db *gorm.DB) *gorm.DB { + if tx != nil { + return tx + } + return db +} + +func (s *fifoService) RegisterStockable(cfg fifo.StockableConfig) error { + return fifo.RegisterStockable(cfg) +} + +func (s *fifoService) RegisterUsable(cfg fifo.UsableConfig) error { + return fifo.RegisterUsable(cfg) +} + +type StockReplenishRequest struct { + StockableKey fifo.StockableKey + StockableID uint + ProductWarehouseID uint + Quantity float64 + Note *string + Tx *gorm.DB +} + +type PendingResolution struct { + UsableKey fifo.UsableKey + UsableID uint + Quantity float64 +} + +type StockReplenishResult struct { + AddedQuantity float64 + PendingResolved []PendingResolution + RemainingPending float64 +} + +type StockConsumeRequest struct { + UsableKey fifo.UsableKey + UsableID uint + ProductWarehouseID uint + Quantity float64 + AllowPending bool + Note *string + Tx *gorm.DB +} + +type AllocationDetail struct { + StockableKey fifo.StockableKey + StockableID uint + Quantity float64 +} + +type StockConsumeResult struct { + RequestedQuantity float64 + UsageQuantity float64 + PendingQuantity float64 + AddedAllocations []AllocationDetail + ReleasedQuantity float64 +} + +type StockReleaseRequest struct { + UsableKey fifo.UsableKey + UsableID uint + Reason *string + Tx *gorm.DB +} + +func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) { + if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { + return nil, errors.New("stockable key and id are required") + } + if req.ProductWarehouseID == 0 { + return nil, errors.New("product warehouse id is required") + } + if req.Quantity <= 0 { + return nil, errors.New("quantity must be greater than zero") + } + + cfg, ok := fifo.Stockable(req.StockableKey) + if !ok { + return nil, fmt.Errorf("stockable %q is not registered", req.StockableKey) + } + + result := &StockReplenishResult{ + AddedQuantity: req.Quantity, + } + + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil { + return err + } + + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + req.ProductWarehouseID: req.Quantity, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return err + } + + resolved, err := s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID) + if err != nil { + return err + } + result.PendingResolved = resolved + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) { + if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { + return nil, errors.New("usable key and id are required") + } + if req.Quantity < 0 { + return nil, errors.New("quantity must be zero or greater") + } + + cfg, ok := fifo.Usable(req.UsableKey) + if !ok { + return nil, fmt.Errorf("usable %q is not registered", req.UsableKey) + } + + result := &StockConsumeResult{ + RequestedQuantity: req.Quantity, + } + + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID) + if err != nil { + return err + } + + productWarehouseID := ctxRow.ProductWarehouseID + if productWarehouseID == 0 { + return fmt.Errorf("usable %q (id: %d) has no product warehouse reference", req.UsableKey, req.UsableID) + } + if req.ProductWarehouseID != 0 && req.ProductWarehouseID != productWarehouseID { + return fmt.Errorf("usable %q (id: %d) references product warehouse %d but %d was provided", req.UsableKey, req.UsableID, productWarehouseID, req.ProductWarehouseID) + } + + currentUsage := ctxRow.UsageQty + currentPending := ctxRow.PendingQty + currentTotal := currentUsage + currentPending + delta := req.Quantity - currentTotal + + var ( + usageDelta float64 + pendingDelta float64 + addedAlloc []AllocationDetail + releasedAmount float64 + ) + + switch { + case delta > 0: + allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta) + if err != nil { + return err + } + if allocationRes.pending > 0 && !req.AllowPending { + return fmt.Errorf("insufficient stock: requested %.3f, allocated %.3f", req.Quantity, currentUsage+allocationRes.allocated) + } + + usageDelta += allocationRes.allocated + pendingDelta += allocationRes.pending + addedAlloc = allocationRes.allocations + + if allocationRes.allocated > 0 { + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + productWarehouseID: -allocationRes.allocated, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return err + } + } + case delta < 0: + reductionTarget := -delta + + if currentPending > 0 { + pendingReduction := math.Min(currentPending, reductionTarget) + if pendingReduction > 0 { + pendingDelta -= pendingReduction + reductionTarget -= pendingReduction + } + } + + if reductionTarget > 0 { + released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget) + if err != nil { + return err + } + if released+1e-6 < reductionTarget { + return fmt.Errorf("unable to release %.3f from usable %d, only %.3f available", reductionTarget, req.UsableID, released) + } + usageDelta -= released + releasedAmount = released + } + default: + // no change + } + + if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil { + return err + } + + result.AddedAllocations = addedAlloc + result.ReleasedQuantity = releasedAmount + result.UsageQuantity = currentUsage + usageDelta + result.PendingQuantity = currentPending + pendingDelta + + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error { + if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { + return errors.New("usable key and id are required") + } + + return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + cfg, ok := fifo.Usable(req.UsableKey) + if !ok { + return fmt.Errorf("usable %q is not registered", req.UsableKey) + } + + ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID) + if err != nil { + return err + } + + var usageDelta, pendingDelta float64 + if ctxRow.UsageQty > 0 { + if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil { + return err + } + usageDelta -= ctxRow.UsageQty + } + if ctxRow.PendingQty > 0 { + pendingDelta -= ctxRow.PendingQty + } + + if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil { + return err + } + + return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }) + }) +} + +// --- helpers --- + +type usableContextRow struct { + ProductWarehouseID uint + UsageQty float64 + PendingQty float64 +} + +func (s *fifoService) loadUsableContext(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint) (*usableContextRow, error) { + var row usableContextRow + + query := tx.Table(cfg.Table). + Select(fmt.Sprintf("%s AS product_warehouse_id, COALESCE(%s,0) AS usage_qty, COALESCE(%s,0) AS pending_qty", cfg.Columns.ProductWarehouseID, cfg.Columns.UsageQuantity, cfg.Columns.PendingQuantity)). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id). + Clauses(clause.Locking{Strength: "UPDATE"}) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + if err := query.Take(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("usable record %d not found", id) + } + return nil, err + } + + return &row, nil +} + +func (s *fifoService) incrementStockableQty(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error { + column := cfg.Columns.TotalQuantity + + query := tx.Table(cfg.Table). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + updates := map[string]any{ + column: gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty), + } + if cfg.Columns.TotalUsedQuantity != "" { + updates[cfg.Columns.TotalUsedQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0)", cfg.Columns.TotalUsedQuantity)) + } + + return query.Updates(updates).Error +} + +func (s *fifoService) incrementStockableUsage(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error { + if qty == 0 { + return nil + } + column := cfg.Columns.TotalUsedQuantity + query := tx.Table(cfg.Table). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + return query.Update(column, gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty)).Error +} + +type allocationOutcome struct { + allocated float64 + pending float64 + allocations []AllocationDetail +} + +type stockLot struct { + StockableKey fifo.StockableKey + RecordID uint + AvailableQty float64 + CreatedAt time.Time +} + +func (s *fifoService) allocateFromStock( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + usableKey fifo.UsableKey, + usableID uint, + requestQty float64, +) (*allocationOutcome, error) { + lots, err := s.fetchStockLots(ctx, tx, productWarehouseID) + if err != nil { + return nil, err + } + if len(lots) == 0 { + return &allocationOutcome{pending: requestQty}, nil + } + + var ( + remaining = requestQty + applied float64 + allocations []*entities.StockAllocation + allocationSummaries []AllocationDetail + usageAdjustments = make(map[fifo.StockableKey]map[uint]float64) + ) + + for _, lot := range lots { + if remaining <= 0 { + break + } + if lot.AvailableQty <= 0 { + continue + } + + portion := lot.AvailableQty + if portion > remaining { + portion = remaining + } + + applied += portion + remaining -= portion + + allocationSummaries = append(allocationSummaries, AllocationDetail{ + StockableKey: lot.StockableKey, + StockableID: lot.RecordID, + Quantity: portion, + }) + + allocations = append(allocations, &entities.StockAllocation{ + ProductWarehouseId: productWarehouseID, + StockableType: lot.StockableKey.String(), + StockableId: lot.RecordID, + UsableType: usableKey.String(), + UsableId: usableID, + Qty: portion, + Status: entities.StockAllocationStatusActive, + }) + + if _, ok := usageAdjustments[lot.StockableKey]; !ok { + usageAdjustments[lot.StockableKey] = make(map[uint]float64) + } + usageAdjustments[lot.StockableKey][lot.RecordID] += portion + } + + if len(allocations) > 0 { + if err := s.allocations.CreateMany(ctx, allocations, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return nil, err + } + + for key, deltas := range usageAdjustments { + cfg, ok := fifo.Stockable(key) + if !ok { + continue + } + for id, qty := range deltas { + if err := s.incrementStockableUsage(ctx, tx, cfg, id, qty); err != nil { + return nil, err + } + } + } + } + + return &allocationOutcome{ + allocated: applied, + pending: remaining, + allocations: allocationSummaries, + }, nil +} + +func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]stockLot, error) { + configs := fifo.Stockables() + if len(configs) == 0 { + return nil, nil + } + + var lots []stockLot + for key, cfg := range configs { + selectStmt := fmt.Sprintf( + "%s AS id, %s AS available_qty, %s AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + cfg.Columns.CreatedAt, + ) + + var rows []struct { + ID uint + AvailableQty float64 + CreatedAt time.Time + } + + query := tx.Table(cfg.Table). + Select(selectStmt). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). + Where(fmt.Sprintf("%s > %s", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity)) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + for _, order := range s.orderClauses(cfg.OrderBy) { + query = query.Order(order) + } + query = query.Limit(s.maxLotsPerStockable) + + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.AvailableQty <= 0 { + continue + } + lots = append(lots, stockLot{ + StockableKey: key, + RecordID: row.ID, + AvailableQty: row.AvailableQty, + CreatedAt: row.CreatedAt, + }) + } + } + + if len(lots) == 0 { + return nil, nil + } + + sort.SliceStable(lots, func(i, j int) bool { + if lots[i].CreatedAt.Equal(lots[j].CreatedAt) { + return lots[i].RecordID < lots[j].RecordID + } + return lots[i].CreatedAt.Before(lots[j].CreatedAt) + }) + + return lots, nil +} + +func (s *fifoService) applyUsableDeltas(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint, usageDelta, pendingDelta float64) error { + if usageDelta == 0 && pendingDelta == 0 { + return nil + } + + updates := map[string]any{} + if usageDelta != 0 { + updates[cfg.Columns.UsageQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.UsageQuantity), usageDelta) + } + if pendingDelta != 0 { + updates[cfg.Columns.PendingQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.PendingQuantity), pendingDelta) + } + + query := tx.Table(cfg.Table).Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + return query.Updates(updates).Error +} + +type pendingCandidate struct { + UsableKey fifo.UsableKey + Config fifo.UsableConfig + UsableID uint + Pending float64 + CreatedAt time.Time +} + +func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]PendingResolution, error) { + candidates, err := s.fetchPendingCandidates(ctx, tx, productWarehouseID) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + return nil, nil + } + + var resolutions []PendingResolution + + for _, candidate := range candidates { + if candidate.Pending <= 0 { + continue + } + + outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending) + if err != nil { + return nil, err + } + if outcome.allocated <= 0 { + break + } + + if err := s.applyUsableDeltas(ctx, tx, candidate.Config, candidate.UsableID, outcome.allocated, -outcome.allocated); err != nil { + return nil, err + } + + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + productWarehouseID: -outcome.allocated, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return nil, err + } + + resolutions = append(resolutions, PendingResolution{ + UsableKey: candidate.UsableKey, + UsableID: candidate.UsableID, + Quantity: outcome.allocated, + }) + + if outcome.pending > 0 { + // No more stock available for this warehouse at the moment. + break + } + } + + return resolutions, nil +} + +func (s *fifoService) releaseUsagePortion( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, + target float64, +) (float64, error) { + if target <= 0 { + return 0, nil + } + + allocations, err := s.allocations.FindActiveByUsable(ctx, usableKey.String(), usableID, func(db *gorm.DB) *gorm.DB { + target := s.txOrDB(tx, db) + return target.Clauses(clause.Locking{Strength: "UPDATE"}) + }) + if err != nil { + return 0, err + } + if len(allocations) == 0 { + return 0, nil + } + + var ( + remaining = target + totalReleased float64 + warehouseAdjustments = make(map[uint]float64) + stockableAdjustments = make(map[fifo.StockableKey]map[uint]float64) + ) + + now := time.Now() + + for i := len(allocations) - 1; i >= 0 && remaining > 0; i-- { + allocation := allocations[i] + releaseAmt := allocation.Qty + if releaseAmt > remaining { + releaseAmt = remaining + } + + remaining -= releaseAmt + totalReleased += releaseAmt + warehouseAdjustments[allocation.ProductWarehouseId] += releaseAmt + + key := fifo.StockableKey(allocation.StockableType) + if _, ok := stockableAdjustments[key]; !ok { + stockableAdjustments[key] = make(map[uint]float64) + } + stockableAdjustments[key][allocation.StockableId] += releaseAmt + + if releaseAmt == allocation.Qty { + if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{ + "status": entities.StockAllocationStatusReleased, + "released_at": now, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + } else { + if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{ + "quantity": allocation.Qty - releaseAmt, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + } + } + + if totalReleased == 0 { + return 0, nil + } + + for key, deltas := range stockableAdjustments { + cfg, ok := fifo.Stockable(key) + if !ok { + continue + } + for id, qty := range deltas { + if err := s.incrementStockableUsage(ctx, tx, cfg, id, -qty); err != nil { + return 0, err + } + } + } + + if len(warehouseAdjustments) > 0 { + if err := s.productWarehouseRepo.AdjustQuantities(ctx, warehouseAdjustments, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + + for warehouseID := range warehouseAdjustments { + if _, err := s.resolvePendingForWarehouse(ctx, tx, warehouseID); err != nil { + return 0, err + } + } + } + + return totalReleased, nil +} + +func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]pendingCandidate, error) { + configs := fifo.Usables() + if len(configs) == 0 { + return nil, nil + } + + var candidates []pendingCandidate + + for key, cfg := range configs { + selectStmt := fmt.Sprintf( + "%s AS id, %s AS pending_qty, %s AS created_at", + cfg.Columns.ID, + cfg.Columns.PendingQuantity, + cfg.Columns.CreatedAt, + ) + + var rows []struct { + ID uint + Pending float64 + CreatedAt time.Time + } + + query := tx.Table(cfg.Table). + Select(selectStmt). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). + Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). + Limit(s.pendingBatchPerUsable) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + for _, order := range s.orderClauses(cfg.OrderBy) { + query = query.Order(order) + } + + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.Pending <= 0 { + continue + } + candidates = append(candidates, pendingCandidate{ + UsableKey: key, + Config: cfg, + UsableID: row.ID, + Pending: row.Pending, + CreatedAt: row.CreatedAt, + }) + } + } + + if len(candidates) == 0 { + return nil, nil + } + + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) { + return candidates[i].UsableID < candidates[j].UsableID + } + return candidates[i].CreatedAt.Before(candidates[j].CreatedAt) + }) + + return candidates, nil +} + +func (s *fifoService) orderClauses(custom []string) []string { + if len(custom) > 0 { + return custom + } + return s.defaultOrderBy +} diff --git a/internal/database/migrations/20250925040409_create_master_tables.up.sql b/internal/database/migrations/20250925040409_create_master_tables.up.sql index eabc78b5..7a1a6bf1 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.up.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.up.sql @@ -2,42 +2,42 @@ CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, id_user BIGINT NOT NULL, - name VARCHAR NOT NULL, - email VARCHAR NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + name VARCHAR(50) NOT NULL, + email VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ ); -CREATE UNIQUE INDEX users_id_user_unique ON users (id_user) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX users_id_user_unique ON users (id_user) +WHERE + deleted_at IS NULL; -CREATE UNIQUE INDEX users_email_unique ON users (email) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX users_email_unique ON users (email) +WHERE + deleted_at IS NULL; -- FLAGS CREATE TABLE flags ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, flagable_id BIGINT NOT NULL, flagable_type VARCHAR(50) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW () ); -CREATE UNIQUE INDEX flags_unique_flagable ON flags ( - name, - flagable_id, - flagable_type -); +CREATE UNIQUE INDEX flags_unique_flagable ON flags (name, flagable_id, flagable_type); CREATE INDEX flags_flagable_lookup ON flags (flagable_type, flagable_id); -- PRODUCT CATEGORIES CREATE TABLE product_categories ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, code VARCHAR(10) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -53,9 +53,9 @@ WHERE -- UOM CREATE TABLE uoms ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + name VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -67,12 +67,12 @@ WHERE -- BANKS CREATE TABLE banks ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, alias VARCHAR(5) NOT NULL, - owner VARCHAR, + owner VARCHAR(50), account_number VARCHAR(50) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -84,9 +84,9 @@ WHERE -- AREAS CREATE TABLE areas ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + name VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -98,11 +98,11 @@ WHERE -- LOCATIONS CREATE TABLE locations ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, address TEXT NOT NULL, area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -114,11 +114,11 @@ WHERE -- KANDANG CREATE TABLE kandangs ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE, pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -130,13 +130,13 @@ WHERE -- WAREHOUSES CREATE TABLE warehouses ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, type VARCHAR(50) NOT NULL, area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE, location_id BIGINT REFERENCES locations (id) ON DELETE SET NULL ON UPDATE CASCADE, kandang_id BIGINT REFERENCES kandangs (id) ON DELETE SET NULL ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -148,16 +148,16 @@ WHERE -- CUSTOMERS CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, type VARCHAR(50) NOT NULL, address TEXT NOT NULL, phone VARCHAR(20) NOT NULL, - email VARCHAR NOT NULL, + email VARCHAR(50) NOT NULL, account_number VARCHAR(50) NOT NULL, balance NUMERIC(15, 3) DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -169,10 +169,10 @@ WHERE -- NONSTOCK CREATE TABLE nonstocks ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -184,9 +184,9 @@ WHERE -- FCR CREATE TABLE fcrs ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + name VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -201,29 +201,29 @@ CREATE TABLE fcr_standards ( weight NUMERIC(15, 3) NOT NULL, fcr_number NUMERIC(15, 3) NOT NULL, mortality NUMERIC(15, 3) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ ); -- SUPPLIERS CREATE TABLE suppliers ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, alias VARCHAR(5) NOT NULL, - pic VARCHAR NOT NULL, + pic VARCHAR(50) NOT NULL, type VARCHAR(50) NOT NULL, category VARCHAR(20) NOT NULL, - hatchery VARCHAR, + hatchery VARCHAR(50), phone VARCHAR(20) NOT NULL, - email VARCHAR NOT NULL, + email VARCHAR(50) NOT NULL, address TEXT NOT NULL, npwp VARCHAR(50), account_number VARCHAR(50), balance NUMERIC(15, 3) DEFAULT 0, due_date INT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -235,15 +235,15 @@ WHERE CREATE TABLE nonstock_suppliers ( nonstock_id BIGINT NOT NULL REFERENCES nonstocks (id) ON DELETE CASCADE ON UPDATE CASCADE, supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), PRIMARY KEY (nonstock_id, supplier_id) ); -- PRODUCTS CREATE TABLE products ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - brand VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, + brand VARCHAR(50) NOT NULL, sku VARCHAR(100), uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE, product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE, @@ -251,8 +251,8 @@ CREATE TABLE products ( selling_price NUMERIC(15, 3), tax NUMERIC(15, 3), expiry_period INT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -268,15 +268,15 @@ WHERE CREATE TABLE product_suppliers ( product_id BIGINT NOT NULL REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE, supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), PRIMARY KEY (product_id, supplier_id) ); -- PROJECTS CREATE TABLE projects ( id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -288,8 +288,8 @@ CREATE TABLE product_warehouses ( warehouse_id BIGINT NOT NULL REFERENCES warehouses (id), quantity INTEGER NOT NULL DEFAULT 0, created_by BIGINT NOT NULL REFERENCES users (id), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ ); @@ -316,8 +316,8 @@ CREATE TABLE stock_logs ( note TEXT, product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE, created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ ); @@ -330,4 +330,4 @@ CREATE INDEX stock_logs_created_by_idx ON stock_logs (created_by); CREATE INDEX stock_logs_created_at_idx ON stock_logs (created_at); -CREATE INDEX stock_logs_deleted_at_idx ON stock_logs (deleted_at); +CREATE INDEX stock_logs_deleted_at_idx ON stock_logs (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql b/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql new file mode 100644 index 00000000..955610e9 --- /dev/null +++ b/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS stock_allocations_released_at_idx; +DROP INDEX IF EXISTS stock_allocations_status_idx; +DROP INDEX IF EXISTS stock_allocations_usage_lookup; +DROP INDEX IF EXISTS stock_allocations_lookup; +DROP INDEX IF EXISTS stock_allocations_product_warehouse_id_idx; + +DROP TABLE IF EXISTS stock_allocations; diff --git a/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql b/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql new file mode 100644 index 00000000..b2a8b053 --- /dev/null +++ b/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS stock_allocations ( + id BIGSERIAL PRIMARY KEY, + product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses(id), + stockable_type VARCHAR(100) NOT NULL, + stockable_id BIGINT NOT NULL, + usable_type VARCHAR(100) NOT NULL, + usable_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + released_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS stock_allocations_product_warehouse_id_idx + ON stock_allocations (product_warehouse_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_lookup + ON stock_allocations (stockable_type, stockable_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_usage_lookup + ON stock_allocations (usable_type, usable_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_status_idx + ON stock_allocations (status); + +CREATE INDEX IF NOT EXISTS stock_allocations_released_at_idx + ON stock_allocations (released_at); diff --git a/internal/database/migrations/20251117034511_create_expenses_table.up.sql b/internal/database/migrations/20251117034511_create_expenses_table.up.sql index 8949d931..f5f20f2c 100644 --- a/internal/database/migrations/20251117034511_create_expenses_table.up.sql +++ b/internal/database/migrations/20251117034511_create_expenses_table.up.sql @@ -1,7 +1,7 @@ CREATE TABLE expenses ( id BIGSERIAL PRIMARY KEY, reference_number VARCHAR(50) UNIQUE NOT NULL, - supplier_id BIGINT NULL, + supplier_id BIGINT NOT NULL, category VARCHAR(50) NOT NULL CHECK ( category IN ('BOP', 'NON-BOP') ), diff --git a/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.down.sql b/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.down.sql new file mode 100644 index 00000000..e69de29b diff --git a/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.up.sql b/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.up.sql new file mode 100644 index 00000000..ce71256b --- /dev/null +++ b/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.up.sql @@ -0,0 +1,44 @@ +-- ============================ +-- EXPENSES +-- ============================ +ALTER TABLE expenses DROP COLUMN IF EXISTS grand_total; + +ALTER TABLE expenses RENAME COLUMN note TO notes; + +ALTER TABLE expenses RENAME COLUMN expense_date TO transaction_date; + +-- ============================ +-- EXPENSE_REALIZATIONS +-- ============================ +ALTER TABLE expense_realizations +RENAME COLUMN realization_qty TO qty; + +ALTER TABLE expense_realizations +RENAME COLUMN realization_unit_price TO price; + +ALTER TABLE expense_realizations RENAME COLUMN note TO notes; + +ALTER TABLE expense_realizations +DROP COLUMN IF EXISTS realization_total_price; + +ALTER TABLE expense_realizations +DROP COLUMN IF EXISTS realization_date; + +ALTER TABLE expense_realizations DROP COLUMN IF EXISTS created_by; + +ALTER TABLE expense_realizations +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + +-- ============================ +-- EXPENSE_NONSTOCKS +-- ============================ +ALTER TABLE expense_nonstocks RENAME COLUMN note TO notes; + +ALTER TABLE expense_nonstocks DROP COLUMN IF EXISTS total_price; + +ALTER TABLE expense_nonstocks RENAME COLUMN unit_price TO price; + +ALTER TABLE expense_nonstocks DROP COLUMN IF EXISTS created_by; + +ALTER TABLE expense_nonstocks +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); \ No newline at end of file diff --git a/internal/database/migrations/20251127070744_create_project_budgets.down.sql b/internal/database/migrations/20251127070744_create_project_budgets.down.sql new file mode 100644 index 00000000..55bfdb3d --- /dev/null +++ b/internal/database/migrations/20251127070744_create_project_budgets.down.sql @@ -0,0 +1,2 @@ +DROP Table IF EXISTS project_budgets; + diff --git a/internal/database/migrations/20251127070744_create_project_budgets.up.sql b/internal/database/migrations/20251127070744_create_project_budgets.up.sql new file mode 100644 index 00000000..db4a713b --- /dev/null +++ b/internal/database/migrations/20251127070744_create_project_budgets.up.sql @@ -0,0 +1,31 @@ +CREATE TABLE project_budgets ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL, + nonstock_id BIGINT NOT NULL, + qty NUMERIC(15, 3) NOT NULL, + price NUMERIC(15, 3) NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Tambahkan Foreign Key ke project_flocks +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flocks') THEN + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_project_flock_id + FOREIGN KEY (project_flock_id) REFERENCES project_flocks(id); + END IF; +END $$; +-- Tambahkan Foreign Key ke nonstocks +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'nonstocks') THEN + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_nonstock_id + FOREIGN KEY (nonstock_id) REFERENCES nonstocks(id); + END IF; +END $$; +-- Index +CREATE INDEX idx_project_budgets_project_flock_id ON project_budgets (project_flock_id); + +CREATE INDEX idx_project_budgets_nonstock_id ON project_budgets (nonstock_id); \ No newline at end of file diff --git a/internal/entities/area.go b/internal/entities/area.go index 0af4d1f0..cda0a8ed 100644 --- a/internal/entities/area.go +++ b/internal/entities/area.go @@ -8,7 +8,7 @@ import ( type Area struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:areas_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:areas_name_unique,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/bank.go b/internal/entities/bank.go index 3c2a93da..0275a517 100644 --- a/internal/entities/bank.go +++ b/internal/entities/bank.go @@ -8,9 +8,9 @@ import ( type Bank struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:banks_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:banks_name_unique,where:deleted_at IS NULL"` Alias string `gorm:"not null;size:5"` - Owner *string `gorm:""` + Owner *string `gorm:"type:varchar(50)"` AccountNumber string `gorm:"not null;size:50"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/customer.go b/internal/entities/customer.go index 98d0c861..f171f0ff 100644 --- a/internal/entities/customer.go +++ b/internal/entities/customer.go @@ -8,12 +8,12 @@ import ( type Customer struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:customers_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:customers_name_unique,where:deleted_at IS NULL"` PicId uint `gorm:"not null"` Type string `gorm:"not null;size:50"` Address string `gorm:"not null"` Phone string `gorm:"not null;size:20"` - Email string `gorm:"not null"` + Email string `gorm:"type:varchar(50);not null"` AccountNumber string `gorm:"not null;size:50"` Balance float64 `gorm:"default:0"` CreatedBy uint `gorm:"not null"` diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 74998e6a..e6ab1d77 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -13,18 +13,16 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` - DocumentPath sql.NullString `gorm:"type:json"` // Dokumen pengajuan - RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` // Dokumen realisasi - RealizationDate time.Time `gorm:"type:date;column:realization_date"` // Tanggal realisasi - ExpenseDate time.Time `gorm:"type:date;not null"` - GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` - Note string `gorm:"type:text"` + DocumentPath sql.NullString `gorm:"type:json"` + RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` + RealizationDate time.Time `gorm:"type:date;column:realization_date"` + TransactionDate time.Time `gorm:"type:date;not null"` + Notes string `gorm:"type:text;column:notes"` CreatedBy uint64 `gorm:""` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - // Relations Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` diff --git a/internal/entities/expense_nonstock.go b/internal/entities/expense_nonstock.go index 7be2053a..ccd4194c 100644 --- a/internal/entities/expense_nonstock.go +++ b/internal/entities/expense_nonstock.go @@ -1,20 +1,23 @@ package entities -type ExpenseNonstock struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - ExpenseId *uint64 `gorm:""` - ProjectFlockKandangId *uint64 `gorm:""` - KandangId *uint64 `gorm:""` - NonstockId *uint64 `gorm:""` - Qty float64 `gorm:"type:numeric(15,3);not null"` - UnitPrice float64 `gorm:"type:numeric(15,3);not null"` - TotalPrice float64 `gorm:"type:numeric(15,3);not null"` - Note string `gorm:"type:text"` +import ( + "time" +) + +type ExpenseNonstock struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + ExpenseId *uint64 `gorm:""` + ProjectFlockKandangId *uint64 `gorm:""` + KandangId *uint64 `gorm:""` + NonstockId *uint64 `gorm:""` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Price float64 `gorm:"type:numeric(15,3);not null;column:price"` + Notes string `gorm:"type:text;column:notes"` + CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"` - // Relations Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` Kandang *Kandang `gorm:"foreignKey:KandangId;references:Id"` Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"` - Realization *ExpenseRealization `gorm:"foreignKey:ExpenseNonstockId;references:Id"` + Realization *ExpenseRealization `gorm:"foreignKey:Id;references:ExpenseNonstockId"` } diff --git a/internal/entities/expense_realization.go b/internal/entities/expense_realization.go index 629fdfb7..3c4b1f07 100644 --- a/internal/entities/expense_realization.go +++ b/internal/entities/expense_realization.go @@ -5,16 +5,12 @@ import ( ) type ExpenseRealization struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - ExpenseNonstockId *uint64 `gorm:""` - RealizationQty float64 `gorm:"type:numeric(15,3);not null"` - RealizationUnitPrice float64 `gorm:"type:numeric(15,3);not null"` - RealizationTotalPrice float64 `gorm:"type:numeric(15,3);not null"` - RealizationDate time.Time `gorm:"type:date;not null"` - Note *string `gorm:"type:text"` - CreatedBy *uint64 `gorm:""` + Id uint64 `gorm:"primaryKey;autoIncrement"` + ExpenseNonstockId *uint64 `gorm:""` + Qty float64 `gorm:"type:numeric(15,3);not null;"` + Price float64 `gorm:"type:numeric(15,3);not null;"` + Notes string `gorm:"type:text;"` + CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"` - // Relations ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/fcr.go b/internal/entities/fcr.go index 4bf96eaf..776c314e 100644 --- a/internal/entities/fcr.go +++ b/internal/entities/fcr.go @@ -8,7 +8,7 @@ import ( type Fcr struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:idx_suppliers_name,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:idx_suppliers_name,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/flag.go b/internal/entities/flag.go index aba2c8d5..e86f81ee 100644 --- a/internal/entities/flag.go +++ b/internal/entities/flag.go @@ -9,7 +9,7 @@ const ( type Flag struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:flags_unique_flagable"` + Name string `gorm:"type:varchar(50);size:50;not null;uniqueIndex:flags_unique_flagable"` FlagableID uint `gorm:"not null;uniqueIndex:flags_unique_flagable;index:flags_flagable_lookup,priority:2"` FlagableType string `gorm:"size:50;not null;uniqueIndex:flags_unique_flagable;index:flags_flagable_lookup,priority:1"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 882184b3..7c083d95 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -8,7 +8,7 @@ import ( type Kandang struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` Status string `gorm:"type:varchar(50);not null"` LocationId uint `gorm:"not null"` Capacity float64 `gorm:"not null"` diff --git a/internal/entities/location.go b/internal/entities/location.go index 1dba8f82..58b90c6e 100644 --- a/internal/entities/location.go +++ b/internal/entities/location.go @@ -8,7 +8,7 @@ import ( type Location struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:locations_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:locations_name_unique,where:deleted_at IS NULL"` Address string `gorm:"not null"` AreaId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` diff --git a/internal/entities/nonstock.go b/internal/entities/nonstock.go index 0e78ca8b..ca6f57b7 100644 --- a/internal/entities/nonstock.go +++ b/internal/entities/nonstock.go @@ -8,7 +8,7 @@ import ( type Nonstock struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:nonstocks_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:nonstocks_name_unique,where:deleted_at IS NULL"` UomId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/product-category.go b/internal/entities/product-category.go index 45acc299..c59c9c6f 100644 --- a/internal/entities/product-category.go +++ b/internal/entities/product-category.go @@ -8,7 +8,7 @@ import ( type ProductCategory struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:product_categories_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:product_categories_name_unique,where:deleted_at IS NULL"` Code string `gorm:"not null;size:10;uniqueIndex:product_categories_code_unique,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/product.go b/internal/entities/product.go index db62aa02..d8ce59fc 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -8,8 +8,8 @@ import ( type Product struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:products_name_unique,where:deleted_at IS NULL"` - Brand string `gorm:"not null"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:products_name_unique,where:deleted_at IS NULL"` + Brand string `gorm:"type:varchar(50);not null"` Sku *string `gorm:"size:100;uniqueIndex:products_sku_unique,where:deleted_at IS NULL"` UomId uint `gorm:"not null"` ProductCategoryId uint `gorm:"not null"` diff --git a/internal/entities/project_budget.go b/internal/entities/project_budget.go new file mode 100644 index 00000000..23c521ac --- /dev/null +++ b/internal/entities/project_budget.go @@ -0,0 +1,15 @@ +package entities + +import ( + "time" +) + +type ProjectBudget struct { + Id uint `gorm:"primaryKey"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Price float64 `gorm:"type:numeric(15,3);not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + Nonstock *Nonstock `gorm:"foreignKey:Id;references:Id"` + ProjectFlock *ProjectFlock `gorm:"foreignKey:Id;references:Id"` +} diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go index 36b698b2..47ac15c8 100644 --- a/internal/entities/purchase.go +++ b/internal/entities/purchase.go @@ -5,11 +5,11 @@ import ( ) type Purchase struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` + Id uint `gorm:"primaryKey;autoIncrement"` PrNumber string `gorm:"not null"` PoNumber *string PoDate *time.Time - SupplierId uint64 `gorm:"not null"` + SupplierId uint `gorm:"not null"` CreditTerm *int DueDate *time.Time GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` @@ -17,7 +17,7 @@ type Purchase struct { CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt *time.Time `gorm:"index"` - CreatedBy uint64 `gorm:"not null"` + CreatedBy uint `gorm:"not null"` // Relations Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index 59f1a030..e5b45bad 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -5,11 +5,11 @@ import ( ) type PurchaseItem struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - PurchaseId uint64 `gorm:"not null"` - ProductId uint64 `gorm:"not null"` - WarehouseId uint64 `gorm:"not null"` - ProductWarehouseId *uint64 + Id uint `gorm:"primaryKey;autoIncrement"` + PurchaseId uint `gorm:"not null"` + ProductId uint `gorm:"not null"` + WarehouseId uint `gorm:"not null"` + ProductWarehouseId *uint ReceivedDate *time.Time TravelNumber *string TravelNumberDocs *string diff --git a/internal/entities/stock_allocation.go b/internal/entities/stock_allocation.go new file mode 100644 index 00000000..614762a1 --- /dev/null +++ b/internal/entities/stock_allocation.go @@ -0,0 +1,33 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + StockAllocationStatusPending = "PENDING" + StockAllocationStatusActive = "ACTIVE" + StockAllocationStatusReleased = "RELEASED" +) + +// StockAllocation links a usable record (consumption) with an incoming stock record. +// The combination lets us trace FIFO deductions while keeping each module focused on its own fields. +type StockAllocation struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"not null;index"` + StockableType string `gorm:"size:100;not null;index:stock_allocations_lookup,priority:1"` + StockableId uint `gorm:"not null;index:stock_allocations_lookup,priority:2"` + UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"` + UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Status string `gorm:"size:20;not null;default:ACTIVE"` + Note *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ReleasedAt *time.Time `gorm:"index"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/supplier.go b/internal/entities/supplier.go index 7d801896..bdbb4dfe 100644 --- a/internal/entities/supplier.go +++ b/internal/entities/supplier.go @@ -8,14 +8,14 @@ import ( type Supplier struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:suppliers_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:suppliers_name_unique,where:deleted_at IS NULL"` Alias string `gorm:"not null;size:5"` - Pic string `gorm:"not null"` + Pic string `gorm:"type:varchar(50);not null"` Type string `gorm:"not null;size:50"` Category string `gorm:"not null;size:20"` - Hatchery *string `gorm:"size:255"` + Hatchery *string `gorm:"type:varchar(50)"` Phone string `gorm:"not null;size:20"` - Email string `gorm:"not null"` + Email string `gorm:"type:varchar(50);not null"` Address string `gorm:"not null"` Npwp *string `gorm:"size:50"` AccountNumber *string `gorm:"size:50"` diff --git a/internal/entities/uom.go b/internal/entities/uom.go index a3335428..8f3e3f91 100644 --- a/internal/entities/uom.go +++ b/internal/entities/uom.go @@ -8,7 +8,7 @@ import ( type Uom struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:uoms_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:uoms_name_unique,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/user.go b/internal/entities/user.go index dcef91d0..d8f4d9c8 100644 --- a/internal/entities/user.go +++ b/internal/entities/user.go @@ -9,8 +9,8 @@ import ( type User struct { Id uint `gorm:"primaryKey"` IdUser int64 `gorm:"uniqueIndex"` - Email string `gorm:"uniqueIndex"` - Name string `gorm:"not null"` + Email string `gorm:"type:varchar(50);uniqueIndex"` + Name string `gorm:"type:varchar(50);not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/internal/entities/warehouse.go b/internal/entities/warehouse.go index 31a0476e..fe2d96aa 100644 --- a/internal/entities/warehouse.go +++ b/internal/entities/warehouse.go @@ -8,7 +8,7 @@ import ( type Warehouse struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` + Name string `gorm:"type:varchar(50);not null"` Type string `gorm:"not null"` AreaId uint `gorm:"not null"` LocationId *uint diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 10f9a3f8..881c3a67 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -105,6 +105,14 @@ func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { return nil, false } +func ActorIDFromContext(c *fiber.Ctx) (uint, error) { + user, ok := AuthenticatedUser(c) + if !ok || user == nil || user.Id == 0 { + return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + return user.Id, nil +} + // AuthDetails returns the full authentication context (token, claims, user). func AuthDetails(c *fiber.Ctx) (*AuthContext, bool) { value := c.Locals(authContextLocalsKey) diff --git a/internal/modules/approvals/route.go b/internal/modules/approvals/route.go index b7d66abd..5dd39616 100644 --- a/internal/modules/approvals/route.go +++ b/internal/modules/approvals/route.go @@ -3,7 +3,7 @@ package approvals import ( // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "github.com/gofiber/fiber/v2" - + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/controllers" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -12,8 +12,8 @@ import ( func ApprovalRoutes(v1 fiber.Router, u user.UserService, s common.ApprovalService) { _ = u ctrl := controller.NewApprovalController(s) - route := v1.Group("/approvals") + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) } diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 08256b24..25806ebb 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -96,30 +96,30 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { } req.Documents = form.File["documents"] - costPerKandangJSON := c.FormValue("cost_per_kandangs") - if costPerKandangJSON != "" { + expenseNonstocksJSON := c.FormValue("expense_nonstocks") + if expenseNonstocksJSON != "" { - if err := json.Unmarshal([]byte(costPerKandangJSON), &req.CostPerKandangs); err != nil { + if err := json.Unmarshal([]byte(expenseNonstocksJSON), &req.ExpenseNonstocks); err != nil { - var singleCostPerKandang validation.CostPerKandang - if err := json.Unmarshal([]byte(costPerKandangJSON), &singleCostPerKandang); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandangs JSON: %v", err)) + var singleExpenseNonstock validation.ExpenseNonstock + if err := json.Unmarshal([]byte(expenseNonstocksJSON), &singleExpenseNonstock); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - if singleCostPerKandang.KandangID == 0 { + if singleExpenseNonstock.KandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required") } - req.CostPerKandangs = []validation.CostPerKandang{singleCostPerKandang} + req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock} } else { - for i, costPerKandang := range req.CostPerKandangs { - if costPerKandang.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for cost_per_kandangs[%d]", i)) + for i, expenseNonstock := range req.ExpenseNonstocks { + if expenseNonstock.KandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) } } } } else { - return fiber.NewError(fiber.StatusBadRequest, "Field cost_per_kandangs is required") + return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required") } result, err := u.ExpenseService.CreateOne(c, req) @@ -155,20 +155,32 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { req.TransactionDate = &transactionDate } - costPerKandangJSON := c.FormValue("cost_per_kandang") - if costPerKandangJSON != "" { - var costPerKandang []validation.CostPerKandang - if err := json.Unmarshal([]byte(costPerKandangJSON), &costPerKandang); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandang JSON: %v", err)) + categoryVal := c.FormValue("category") + req.Category = &categoryVal + + supplierIDVal := c.FormValue("supplier_id") + if supplierIDVal != "" { + supplierID, err := strconv.ParseUint(supplierIDVal, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid supplier_id format") + } + req.SupplierID = &supplierID + } + + expenseNonstocksJSON := c.FormValue("expense_nonstocks") + if expenseNonstocksJSON != "" { + var expenseNonstocks []validation.ExpenseNonstock + if err := json.Unmarshal([]byte(expenseNonstocksJSON), &expenseNonstocks); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - for i, costPerKandang := range costPerKandang { - if costPerKandang.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for cost_per_kandang[%d]", i)) + for i, expenseNonstock := range expenseNonstocks { + if expenseNonstock.KandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) } } - req.CostPerKandang = &costPerKandang + req.ExpenseNonstocks = &expenseNonstocks } result, err := u.ExpenseService.UpdateOne(c, req, uint(id)) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index bee50c6d..c55dba2c 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -15,10 +15,9 @@ import ( // === DTO Structs === type ExpenseRelationDTO struct { - Id uint64 `json:"id"` - PoNumber string `json:"po_number"` - ExpenseDate time.Time `json:"expense_date"` - GrandTotal float64 `json:"grand_total"` + Id uint64 `json:"id"` + PoNumber string `json:"po_number"` + TransactionDate time.Time `json:"transaction_date"` } type ExpenseBaseDTO struct { @@ -28,8 +27,7 @@ type ExpenseBaseDTO struct { Category string `json:"category"` Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"` RealizationDate *time.Time `json:"realization_date,omitempty"` - ExpenseDate time.Time `json:"expense_date"` - GrandTotal float64 `json:"grand_total"` + TransactionDate time.Time `json:"transaction_date"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` } @@ -55,21 +53,26 @@ type ExpenseDetailDTO struct { } type ExpenseNonstockDTO struct { - Id uint64 `json:"id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Note *string `json:"note,omitempty"` - Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + Id uint64 `json:"id"` + ExpenseId *uint64 `json:"expense_id,omitempty"` + ProjectFlockKandangId *uint64 `json:"project_flock_kandang_id,omitempty"` + KandangId *uint64 `json:"kandang_id,omitempty"` + NonstockId *uint64 `json:"nonstock_id,omitempty"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Notes string `json:"notes"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + CreatedAt time.Time `json:"created_at"` } type ExpenseRealizationDTO struct { - Id uint64 `json:"id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Note *string `json:"note,omitempty"` - Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + Id uint64 `json:"id"` + ExpenseNonstockId *uint64 `json:"expense_nonstock_id,omitempty"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Notes string `json:"notes"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + CreatedAt time.Time `json:"created_at"` } type KandangGroupDTO struct { @@ -89,10 +92,9 @@ type DocumentDTO struct { func ToExpenseRelationDTO(e entity.Expense) ExpenseRelationDTO { return ExpenseRelationDTO{ - Id: e.Id, - PoNumber: e.PoNumber, - ExpenseDate: e.ExpenseDate, - GrandTotal: e.GrandTotal, + Id: e.Id, + PoNumber: e.PoNumber, + TransactionDate: e.TransactionDate, } } @@ -124,8 +126,7 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { Category: e.Category, Supplier: supplier, RealizationDate: realizationDate, - ExpenseDate: e.ExpenseDate, - GrandTotal: e.GrandTotal, + TransactionDate: e.TransactionDate, Location: location, } } @@ -192,10 +193,9 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { for _, ns := range e.Nonstocks { pengajuanDTO := ToExpenseNonstockDTO(ns) - pengajuans = append(pengajuans, pengajuanDTO) - if ns.Realization != nil && ns.Realization.Id != 0 { + if ns.Realization != nil { var nonstock *nonstockDTO.NonstockRelationDTO if ns.Nonstock != nil && ns.Nonstock.Id != 0 { mapped := nonstockDTO.ToNonstockRelationDTO(*ns.Nonstock) @@ -203,12 +203,13 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { } realisasiDTO := ExpenseRealizationDTO{ - Id: ns.Realization.Id, - Qty: ns.Realization.RealizationQty, - UnitPrice: ns.Realization.RealizationUnitPrice, - TotalPrice: ns.Realization.RealizationTotalPrice, - Note: ns.Realization.Note, - Nonstock: nonstock, + Id: ns.Realization.Id, + ExpenseNonstockId: ns.Realization.ExpenseNonstockId, + Qty: ns.Realization.Qty, + Price: ns.Realization.Price, + Notes: ns.Realization.Notes, + Nonstock: nonstock, + CreatedAt: ns.Realization.CreatedAt, } realisasi = append(realisasi, realisasiDTO) } @@ -217,12 +218,12 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var totalPengajuan float64 for _, p := range pengajuans { - totalPengajuan += p.TotalPrice + totalPengajuan += p.Qty * p.Price } var totalRealisasi float64 for _, r := range realisasi { - totalRealisasi += r.TotalPrice + totalRealisasi += r.Qty * r.Price } kandangs := ToKandangGroupDTO(pengajuans, realisasi, e.Nonstocks) @@ -248,12 +249,16 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { } return ExpenseNonstockDTO{ - Id: ns.Id, - Qty: ns.Qty, - UnitPrice: ns.UnitPrice, - TotalPrice: ns.TotalPrice, - Note: &ns.Note, - Nonstock: nonstock, + Id: ns.Id, + ExpenseId: ns.ExpenseId, + ProjectFlockKandangId: ns.ProjectFlockKandangId, + KandangId: ns.KandangId, + NonstockId: ns.NonstockId, + Qty: ns.Qty, + Price: ns.Price, + Notes: ns.Notes, + Nonstock: nonstock, + CreatedAt: ns.CreatedAt, } } @@ -264,11 +269,13 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali var kandangId uint64 var kandangName string - for _, ns := range nonstocks { - if ns.Id == p.Id && ns.Kandang != nil { - kandangId = uint64(ns.Kandang.Id) - kandangName = ns.Kandang.Name - break + if p.KandangId != nil { + kandangId = *p.KandangId + for _, ns := range nonstocks { + if ns.Id == p.Id && ns.Kandang != nil { + kandangName = ns.Kandang.Name + break + } } } diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go index 588583da..9e97a180 100644 --- a/internal/modules/expenses/repositories/expense.repository.go +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -10,7 +10,7 @@ import ( type ExpenseRepository interface { repository.BaseRepository[entity.Expense] - IdExists(ctx context.Context, id uint64) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) GetNextSequence(ctx context.Context) (int, error) GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error) } @@ -25,8 +25,8 @@ func NewExpenseRepository(db *gorm.DB) ExpenseRepository { } } -func (r *ExpenseRepositoryImpl) IdExists(ctx context.Context, id uint64) (bool, error) { - return repository.Exists[entity.Expense](ctx, r.DB(), uint(id)) +func (r *ExpenseRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Expense](ctx, r.DB(), id) } func (r *ExpenseRepositoryImpl) GetNextSequence(ctx context.Context) (int, error) { diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 805cb886..5a8b66fc 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -1,7 +1,7 @@ package expenses import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/controllers" expense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService ctrl := controller.NewExpenseController(s) route := v1.Group("/expenses") - + route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 0d0779f0..2bd00a0f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "mime/multipart" - "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -148,8 +147,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return nil, err } - for _, costPerKandang := range req.CostPerKandangs { - for _, costItem := range costPerKandang.CostItems { + for _, expenseNonstock := range req.ExpenseNonstocks { + for _, costItem := range expenseNonstock.CostItems { nonstockId := uint(costItem.NonstockID) if err := commonSvc.EnsureRelations(c.Context(), @@ -189,21 +188,13 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") } - var grandTotal float64 - for _, costPerKandang := range req.CostPerKandangs { - for _, costItem := range costPerKandang.CostItems { - grandTotal += costItem.TotalCost - } - } - createdBy := uint64(1) //todo get from auth expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: req.SupplierID, - ExpenseDate: expenseDate, - GrandTotal: grandTotal, + TransactionDate: expenseDate, CreatedBy: createdBy, } @@ -211,15 +202,15 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense") } - if len(req.CostPerKandangs) > 0 { + if len(req.ExpenseNonstocks) > 0 { - for _, costPerKandang := range req.CostPerKandangs { + for _, expenseNonstock := range req.ExpenseNonstocks { var projectFlockKandangId *uint64 if req.Category == "BOP" { - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(costPerKandang.KandangID)) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") @@ -230,16 +221,16 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen projectFlockKandangId = &id } - for _, costItem := range costPerKandang.CostItems { + for _, costItem := range expenseNonstock.CostItems { nonstockId := costItem.NonstockID var kandangId *uint64 if req.Category == "NON-BOP" { - id := uint64(costPerKandang.KandangID) + id := uint64(expenseNonstock.KandangID) kandangId = &id } else if req.Category == "BOP" { if projectFlockKandangId != nil { - kandangId = &costPerKandang.KandangID + kandangId = &expenseNonstock.KandangID } } @@ -249,8 +240,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen KandangId: kandangId, NonstockId: &nonstockId, Qty: costItem.Quantity, - TotalPrice: costItem.TotalCost, - Note: costItem.Notes, + Price: costItem.Price, + Notes: costItem.Notes, } if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { @@ -302,9 +293,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } @@ -328,10 +317,27 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") } - updateBody["expense_date"] = expenseDate + updateBody["transaction_date"] = expenseDate } - if len(updateBody) == 0 && req.CostPerKandang == nil && len(req.Documents) == 0 { + if req.Category != nil { + updateBody["category"] = *req.Category + } + + if req.SupplierID != nil { + supplierID := uint(*req.SupplierID) + supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) { + return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) + } + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, + ); err != nil { + return nil, err + } + updateBody["supplier_id"] = *req.SupplierID + } + + if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { responseDTO, err := s.GetOne(c, id) if err != nil { @@ -346,6 +352,21 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) + currentExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + + categoryChanged := false + var newCategory string + if req.Category != nil && *req.Category != currentExpense.Category { + categoryChanged = true + newCategory = *req.Category + } + if len(updateBody) > 0 { if err := expenseRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -355,41 +376,79 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if req.CostPerKandang != nil { + if categoryChanged { + if currentExpense.Category == "BOP" && newCategory == "NON-BOP" { - if err := tx.Where("expense_id = ?", id).Delete(&entity.ExpenseNonstock{}).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items") + var existingExpenseNonstocks []entity.ExpenseNonstock + if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks") + } + + for _, ens := range existingExpenseNonstocks { + updateData := map[string]interface{}{ + "project_flock_kandang_id": nil, + } + if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id to null") + } + } + } else if currentExpense.Category == "NON-BOP" && newCategory == "BOP" { + + var existingExpenseNonstocks []entity.ExpenseNonstock + if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks") + } + + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) + for _, ens := range existingExpenseNonstocks { + if ens.KandangId != nil { + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.KandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + } + projectFlockKandangId := uint64(projectFlockKandang.Id) + + updateData := map[string]interface{}{ + "project_flock_kandang_id": projectFlockKandangId, + } + if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id") + } + } + } + } + } + + if req.ExpenseNonstocks != nil { + + var existingExpenseNonstocks []entity.ExpenseNonstock + if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks for deletion") } - var grandTotal float64 - for _, cpk := range *req.CostPerKandang { - for _, costItem := range cpk.CostItems { - grandTotal += costItem.TotalCost + for _, ens := range existingExpenseNonstocks { + if err := expenseNonstockRepoTx.DeleteOne(c.Context(), uint(ens.Id)); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete expense nonstock") } } - if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{ - "grand_total": grandTotal, - }, nil); err != nil { - - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense grand total") + updatedExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get updated expense") } - for _, cpk := range *req.CostPerKandang { + for _, expenseNonstock := range *req.ExpenseNonstocks { var projectFlockKandangId *uint64 - expense, err := expenseRepoTx.GetByID(c.Context(), id, nil) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Expense not found") - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") - } - - if expense.Category == "BOP" { - + if updatedExpense.Category == "BOP" { projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(cpk.KandangID)) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") @@ -400,7 +459,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) projectFlockKandangId = &id } - for _, costItem := range cpk.CostItems { + for _, costItem := range expenseNonstock.CostItems { nonstockId := uint(costItem.NonstockID) if err := commonSvc.EnsureRelations(c.Context(), @@ -410,13 +469,12 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } var kandangId *uint64 - if expense.Category == "NON-BOP" { - id := uint64(cpk.KandangID) + if updatedExpense.Category == "NON-BOP" { + id := uint64(expenseNonstock.KandangID) kandangId = &id - } else if expense.Category == "BOP" { - + } else if updatedExpense.Category == "BOP" { if projectFlockKandangId != nil { - kandangId = &cpk.KandangID + kandangId = &expenseNonstock.KandangID } } @@ -427,8 +485,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) KandangId: kandangId, NonstockId: &costItem.NonstockID, Qty: costItem.Quantity, - TotalPrice: costItem.TotalCost, - Note: costItem.Notes, + Price: costItem.Price, + Notes: costItem.Notes, } if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { @@ -481,9 +539,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return err } @@ -506,9 +562,7 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } @@ -518,8 +572,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format") } - createdBy := uint64(1) // TODO: replace with authenticated user id - if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) @@ -543,13 +595,14 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va } realization := &entity.ExpenseRealization{ - ExpenseNonstockId: &expenseNonstockID, - RealizationQty: realizationItem.Qty, - RealizationUnitPrice: realizationItem.UnitPrice, - RealizationTotalPrice: realizationItem.TotalPrice, - RealizationDate: realizationDate, - Note: realizationItem.Notes, - CreatedBy: &createdBy, + ExpenseNonstockId: &expenseNonstockID, + Qty: realizationItem.Qty, + Price: realizationItem.Price, + Notes: "", + } + + if realizationItem.Notes != nil { + realization.Notes = *realizationItem.Notes } if err := realizationRepoTx.CreateOne(c.Context(), realization, nil); err != nil { @@ -576,7 +629,7 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va expenseID, utils.ExpenseStepRealisasi, &approvalAction, - uint(createdBy), + uint(1), // TODO: replace with authenticated user id nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") @@ -597,9 +650,7 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) { if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } @@ -652,14 +703,12 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { - s.Log.Errorf("Validation failed for UpdateRealization: %+v", err) + return nil, err } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } @@ -669,66 +718,56 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") } - if latestApproval != nil && latestApproval.StepNumber != uint16(utils.ExpenseStepRealisasi) { + if latestApproval != nil && (latestApproval.StepNumber < uint16(utils.ExpenseStepRealisasi)) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return nil, fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("Cannot update realization at %s step. Must be at Realisasi step", currentStepName)) + fmt.Sprintf("tidak bisa update realisasi pada step %s. Harus pada step Realisasi atau selesai", currentStepName)) } - var realizationDate *time.Time - if req.RealizationDate != "" { - parsedDate, err := utils.ParseDateString(req.RealizationDate) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format") - } - realizationDate = &parsedDate - } - - if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) realizationRepoTx := repository.NewExpenseRealizationRepository(tx) expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) expenseRepoTx := repository.NewExpenseRepository(tx) - for _, realizationItem := range req.Realizations { + // Check if only updating documents + updateDataOnly := len(req.Realizations) == 0 && len(req.Documents) > 0 - expenseNonstockID := realizationItem.ExpenseNonstockID + if len(req.Realizations) > 0 { + for _, realizationItem := range req.Realizations { - if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { - return err - } + expenseNonstockID := realizationItem.ExpenseNonstockID - existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - - return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock") + if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { + return err } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization") + existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + + return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock") + } + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization") + } + + updateData := map[string]interface{}{ + "qty": realizationItem.Qty, + "price": realizationItem.Price, + } + + if realizationItem.Notes != nil { + updateData["notes"] = *realizationItem.Notes + } + + if err := realizationRepoTx.PatchOne(c.Context(), uint(existingRealization.Id), updateData, nil); err != nil { + s.Log.Errorf("Failed to update realization: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization") + } } - updateData := map[string]interface{}{ - "realization_qty": realizationItem.Qty, - "realization_unit_price": realizationItem.UnitPrice, - "realization_total_price": realizationItem.TotalPrice, - "realization_date": *realizationDate, - } - - if realizationItem.Notes != nil { - updateData["note"] = *realizationItem.Notes - } - - if err := realizationRepoTx.PatchOne(c.Context(), uint(existingRealization.Id), updateData, nil); err != nil { - s.Log.Errorf("Failed to update realization: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization") - } - } - - if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{ - "realization_date": *realizationDate, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") } if len(req.Documents) > 0 { @@ -737,9 +776,28 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } } + if !updateDataOnly && *latestApproval.Action == entity.ApprovalActionUpdated { + actorID := uint(1) // TODO: replace with authenticated user id + approvalAction := entity.ApprovalActionUpdated + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowExpense, + expenseID, + utils.ExpenseStepRealisasi, + &approvalAction, + actorID, + nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") + } + } + return nil - }); err != nil { - return nil, err + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "gagal update realisasi expense") } responseDTO, err := s.GetOne(c, expenseID) @@ -825,9 +883,7 @@ func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx reposito func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { if err := commonSvc.EnsureRelations(ctx.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists}, ); err != nil { return err } @@ -909,9 +965,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, for _, id := range req.ApprovableIds { if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return err } diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 4e909b66..abe6198c 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -5,15 +5,15 @@ import ( ) type Create struct { - PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` - TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` - Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` - SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` - Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` - CostPerKandangs []CostPerKandang `form:"cost_per_kandangs" json:"cost_per_kandangs" validate:"required,min=1,dive"` + PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` + TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` + Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` + SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` } -type CostPerKandang struct { +type ExpenseNonstock struct { KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` } @@ -21,14 +21,16 @@ type CostPerKandang struct { type CostItem struct { NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"` Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"` - TotalCost float64 `form:"total_cost" json:"total_cost" validate:"required,gt=0"` + Price float64 `form:"price" json:"price" validate:"required,gt=0"` Notes string `form:"notes" json:"notes" validate:"required,max=500"` } type Update struct { - TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` - CostPerKandang *[]CostPerKandang `form:"cost_per_kandang" json:"cost_per_kandang" validate:"omitempty,min=1,dive"` - Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` + Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` + SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` } type Query struct { @@ -52,8 +54,7 @@ type UpdateRealization struct { type RealizationItem struct { ExpenseNonstockID uint64 `form:"expense_nonstock_id" json:"expense_nonstock_id" validate:"required,gt=0"` Qty float64 `form:"qty" json:"qty" validate:"required,gt=0"` - UnitPrice float64 `form:"unit_price" json:"unit_price" validate:"required,gt=0"` - TotalPrice float64 `form:"total_price" json:"total_price" validate:"required,gt=0"` + Price float64 `form:"price" json:"price" validate:"required,gt=0"` Notes *string `form:"notes" json:"notes" validate:"omitempty,max=500"` } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index f9cc8012..78f4fbde 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -5,8 +5,8 @@ import ( "strings" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" @@ -78,7 +78,10 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, err } ctx := c.Context() - + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, @@ -131,7 +134,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, - CreatedBy: 1, // TODO: should Get from auth middleware + CreatedBy: actorID, // TODO: should Get from auth middleware } if transactionType == entity.TransactionTypeIncrease { afterQuantity += req.Quantity diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 7ef0283b..94652000 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -27,7 +27,7 @@ type ProductWarehouseRepository interface { GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) IdExists(ctx context.Context, id uint) (bool, error) CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error - EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, createdBy uint64) (uint, error) + EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, createdBy uint) (uint, error) } type ProductWarehouseRepositoryImpl struct { @@ -199,7 +199,7 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( ctx context.Context, productID uint, warehouseID uint, - createdBy uint64, + createdBy uint, ) (uint, error) { record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) if err == nil { diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index a17953b3..ef273664 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -3,15 +3,15 @@ package service import ( "errors" "fmt" - "strings" - 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" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -127,6 +127,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) } } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } // validasi total qty harus lebih besar dari atau sama dengan total qty di delivery compare berdasarkan productid deliveryQtyMap := make(map[uint]float64) @@ -174,7 +178,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Reason: req.TransferReason, TransferDate: transferDate, MovementNumber: movementNumber, - CreatedBy: 1, //todo: get from token + CreatedBy: uint64(actorID), } // Save the transfer entity to the database @@ -280,7 +284,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques LoggableType: entity.LogTypeTransfer, LoggableId: uint(entityTransfer.Id), ProductWarehouseId: sourcePW.Id, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { s.Log.Errorf("Failed to create stock log decrease: %+v", err) @@ -329,7 +333,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques LoggableId: uint(entityTransfer.Id), Notes: "", ProductWarehouseId: destPW.Id, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { s.Log.Errorf("Failed to create stock log increase: %+v", err) diff --git a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go b/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go index 712c6ace..92809f19 100644 --- a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go +++ b/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go @@ -8,6 +8,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" marketingDeliveryProductRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -175,6 +176,11 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) 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) @@ -190,9 +196,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) if latestApproval.StepNumber >= uint16(utils.MarketingDeliveryOrder) { return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery order already exists for this marketing") } - if latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing is not approved - current status: %v", *latestApproval.Action)) - } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -256,7 +259,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) } - actorID := uint(1) // TODO: ambil dari auth context + approvalAction := entity.ApprovalActionApproved if _, err := approvalSvcTx.CreateApproval( c.Context(), diff --git a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go b/internal/modules/marketing/sales-orders/repositories/marketings.repository.go index dd0f99ab..df8a7c98 100644 --- a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go +++ b/internal/modules/marketing/sales-orders/repositories/marketings.repository.go @@ -2,16 +2,22 @@ package repository import ( "context" + "fmt" + "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type MarketingRepository interface { repository.BaseRepository[entity.Marketing] IdExists(ctx context.Context, id uint) (bool, error) GetNextSequence(ctx context.Context) (uint, error) + NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) } type MarketingRepositoryImpl struct { @@ -35,3 +41,82 @@ func (r *MarketingRepositoryImpl) GetNextSequence(ctx context.Context) (uint, er } return maxID + 1, nil } + +func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) { + return r.generateSequentialNumber(ctx, tx, "so_number", utils.MarketingSoNumberPrefix, utils.MarketingNumberPadding) +} + +func parseNumericSuffix(value, prefix string) (int, bool) { + if !strings.HasPrefix(value, prefix) { + return 0, false + } + suffix := strings.TrimPrefix(value, prefix) + if suffix == "" { + return 0, false + } + trimmed := strings.TrimLeft(suffix, "0") + if trimmed == "" { + trimmed = "0" + } + number, err := strconv.Atoi(trimmed) + if err != nil { + return 0, false + } + return number, true +} + +func (r *MarketingRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, column, value string) (bool, error) { + var count int64 + if err := db.WithContext(ctx). + Model(&entity.Marketing{}). + Where(fmt.Sprintf("%s = ?", column), value). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *MarketingRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { + + db := tx + if db == nil { + db = r.DB() + } + + var values []string + err := db.WithContext(ctx). + Model(&entity.Marketing{}). + Where(fmt.Sprintf("%s LIKE ?", column), prefix+"%"). + Select(column). + Order(fmt.Sprintf("%s DESC", column)). + Limit(20). + Clauses(clause.Locking{Strength: "UPDATE"}). + Pluck(column, &values).Error + if err != nil { + return "", err + } + + next := 1 + for _, value := range values { + if number, ok := parseNumericSuffix(value, prefix); ok { + next = number + 1 + break + } + } + + const maxAttempts = 20 + for attempt := 0; attempt < maxAttempts; attempt++ { + candidate := fmt.Sprintf("%s%0*d", prefix, padding, next) + exists, err := r.numberExists(ctx, db, column, candidate) + if err != nil { + return "", err + } + if !exists { + return candidate, nil + } + next++ + } + + return "", fmt.Errorf("unable to generate unique %s", column) + +} diff --git a/internal/modules/marketing/sales-orders/services/sales-orders.service.go b/internal/modules/marketing/sales-orders/services/sales-orders.service.go index d750c4a4..8acef29d 100644 --- a/internal/modules/marketing/sales-orders/services/sales-orders.service.go +++ b/internal/modules/marketing/sales-orders/services/sales-orders.service.go @@ -9,6 +9,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rInvMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" @@ -90,6 +91,11 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Customer", ID: &req.CustomerId, Exists: s.CustomerRepo.IdExists}, ); err != nil { @@ -109,11 +115,10 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format") } - nextSeq, err := s.MarketingRepo.GetNextSequence(c.Context()) + soNumber, err := s.MarketingRepo.NextSoNumber(context.Background(), nil) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate SO number") } - soNumber := fmt.Sprintf("SO-%05d", nextSeq) var marketing *entity.Marketing err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -129,7 +134,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e SoDate: soDate, SalesPersonId: req.SalesPersonId, Notes: req.Notes, - CreatedBy: 1, + CreatedBy: actorID, } if err := marketingRepoTx.CreateOne(c.Context(), marketing, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create salesOrders") @@ -143,7 +148,6 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } } - actorID := uint(1) // TODO: ambil dari auth context approvalAction := entity.ApprovalActionCreated if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -180,6 +184,11 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists}, commonSvc.RelationCheck{Name: "Customer", ID: &req.CustomerId, Exists: s.CustomerRepo.IdExists}, @@ -321,7 +330,6 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } } if latestApproval != nil { - actorID := uint(1) // todo: ambil dari auth context action := entity.ApprovalActionUpdated _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -405,6 +413,11 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.MarketingRepo.DB())) var action entity.ApprovalAction @@ -448,7 +461,7 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e } } - err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) @@ -479,7 +492,6 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e nextStep = approvalutils.ApprovalStep(currentStep) } - actorID := uint(1) // todo ambil dari auth context if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowMarketing, diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index 1925a592..0a976567 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -5,6 +5,7 @@ import ( "fmt" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -87,10 +88,14 @@ func (s *areaService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.A return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Area with name %s already exists", req.Name)) } - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + createBody := &entity.Area{ Name: req.Name, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/areas/validations/area.validation.go b/internal/modules/master/areas/validations/area.validation.go index 56bbd601..a7004c26 100644 --- a/internal/modules/master/areas/validations/area.validation.go +++ b/internal/modules/master/areas/validations/area.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` } type Query struct { diff --git a/internal/modules/master/banks/validations/bank.validation.go b/internal/modules/master/banks/validations/bank.validation.go index 9d2bd897..34f1db27 100644 --- a/internal/modules/master/banks/validations/bank.validation.go +++ b/internal/modules/master/banks/validations/bank.validation.go @@ -1,16 +1,16 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Alias string `json:"alias" validate:"required_strict"` - Owner *string `json:"owner,omitempty" validate:"omitempty"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Alias string `json:"alias" validate:"required_strict,max=5"` + Owner *string `json:"owner,omitempty" validate:"omitempty,max=50"` AccountNumber string `json:"account_number" validate:"required_strict,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - Alias *string `json:"alias,omitempty" validate:"omitempty"` - Owner *string `json:"owner,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Alias *string `json:"alias,omitempty" validate:"omitempty,max=5"` + Owner *string `json:"owner,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` } diff --git a/internal/modules/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go index b2cc1e85..12a31441 100644 --- a/internal/modules/master/customers/services/customer.service.go +++ b/internal/modules/master/customers/services/customer.service.go @@ -3,13 +3,13 @@ package service import ( "errors" "fmt" - "strings" - common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -81,6 +81,10 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti if err := s.Validate.Struct(req); err != nil { return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { s.Log.Errorf("Failed to check customer name: %+v", err) @@ -100,7 +104,6 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, err } - //TODO: created by dummy createBody := &entity.Customer{ Name: req.Name, PicId: req.PicId, @@ -109,7 +112,7 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti Phone: req.Phone, Email: req.Email, AccountNumber: req.AccountNumber, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/customers/validations/customer.validation.go b/internal/modules/master/customers/validations/customer.validation.go index a7a666ec..457bbf9a 100644 --- a/internal/modules/master/customers/validations/customer.validation.go +++ b/internal/modules/master/customers/validations/customer.validation.go @@ -1,23 +1,23 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` - Type string `json:"type" validate:"required_strict"` + Type string `json:"type" validate:"required_strict,max=50"` Address string `json:"address" validate:"required_strict"` Phone string `json:"phone" validate:"required_strict,max=20"` - Email string `json:"email" validate:"required_strict,email"` - AccountNumber string `json:"account_number" validate:"required_strict"` + Email string `json:"email" validate:"required_strict,email,max=50"` + AccountNumber string `json:"account_number" validate:"required_strict,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` - Type *string `json:"type,omitempty" validate:"omitempty"` + Type *string `json:"type,omitempty" validate:"omitempty,max=50"` Address *string `json:"address,omitempty" validate:"omitempty"` - Phone *string `json:"phone,omitempty" validate:"omitempty"` - Email *string `json:"email,omitempty" validate:"omitempty"` - AccountNumber *string `json:"account_number,omitempty" validate:"omitempty"` + Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"` + Email *string `json:"email,omitempty" validate:"omitempty,max=50"` + AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` } type Query struct { diff --git a/internal/modules/master/kandangs/route.go b/internal/modules/master/kandangs/route.go index 1e384b1f..6a425b64 100644 --- a/internal/modules/master/kandangs/route.go +++ b/internal/modules/master/kandangs/route.go @@ -1,7 +1,7 @@ package kandangs import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/controllers" kandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func KandangRoutes(v1 fiber.Router, u user.UserService, s kandang.KandangService ctrl := controller.NewKandangController(s) route := v1.Group("/kandangs") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index e65348fc..35fe2c30 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -3,13 +3,13 @@ package service import ( "errors" "fmt" - "strings" - common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -130,14 +130,18 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + createBody := &entity.Kandang{ Name: req.Name, LocationId: req.LocationId, Capacity: req.Capacity, Status: status, PicId: req.PicId, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index 6d7c090b..f4adc55e 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -1,8 +1,8 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Status string `json:"status,omitempty" validate:"omitempty,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Status string `json:"status,omitempty" validate:"omitempty,min=3,max=50"` Capacity float64 `json:"capacity" validate:"required_strict,gt=0"` LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` @@ -10,8 +10,8 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - Status *string `json:"status,omitempty" validate:"omitempty,min=3"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Status *string `json:"status,omitempty" validate:"omitempty,min=3,max=50"` Capacity *float64 `json:"capacity" validate:"omitempty,gt=0"` LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 7b7599ea..19894d10 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -4,15 +4,15 @@ import ( "errors" "fmt" - common "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/validations" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -97,12 +97,16 @@ func (s *locationService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, err } - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + createBody := &entity.Location{ Name: req.Name, Address: req.Address, AreaId: req.AreaId, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go index 029953c0..61ab4125 100644 --- a/internal/modules/master/locations/validations/location.validation.go +++ b/internal/modules/master/locations/validations/location.validation.go @@ -1,13 +1,13 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` Address string `json:"address" validate:"required_strict"` AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` Address *string `json:"address,omitempty" validate:"omitempty"` AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index 9d93ce3d..c421b7ec 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -1,14 +1,14 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` UomID uint `json:"uom_id" validate:"required,gt=0"` SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` Flags []string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` diff --git a/internal/modules/master/product-categories/validations/product-category.validation.go b/internal/modules/master/product-categories/validations/product-category.validation.go index 7a7d6e40..46cfaedb 100644 --- a/internal/modules/master/product-categories/validations/product-category.validation.go +++ b/internal/modules/master/product-categories/validations/product-category.validation.go @@ -1,12 +1,12 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` Code string `json:"code" validate:"required_strict,max=10"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` Code *string `json:"code,omitempty" validate:"omitempty,max=10"` } diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index 3b2370b2..dfd4c86f 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -12,12 +12,13 @@ import ( // === DTO Structs === type ProductRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - ProductPrice float64 `gorm:"type:numeric(15,3);not null"` - SellingPrice *float64 `gorm:"type:numeric(15,3)"` - Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` - Flags *[]string `json:"flags,omitempty"` + Id uint `json:"id"` + Name string `json:"name"` + ProductPrice float64 `gorm:"type:numeric(15,3);not null"` + SellingPrice *float64 `gorm:"type:numeric(15,3)"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + Flags *[]string `json:"flags,omitempty"` + ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` } type ProductListDTO struct { @@ -55,13 +56,20 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO { uomRef = &mapped } + var categoryRef *productCategoryDTO.ProductCategoryRelationDTO + if e.ProductCategory.Id != 0 { + mapped := productCategoryDTO.ToProductCategoryRelationDTO(e.ProductCategory) + categoryRef = &mapped + } + return ProductRelationDTO{ - Id: e.Id, - Name: e.Name, - ProductPrice: e.ProductPrice, - SellingPrice: e.SellingPrice, - Flags: &flags, - Uom: uomRef, + Id: e.Id, + Name: e.Name, + ProductPrice: e.ProductPrice, + SellingPrice: e.SellingPrice, + Flags: &flags, + Uom: uomRef, + ProductCategory: categoryRef, } } diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go index 70e23a74..e732d054 100644 --- a/internal/modules/master/products/validations/product.validation.go +++ b/internal/modules/master/products/validations/product.validation.go @@ -1,9 +1,9 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Brand string `json:"brand" validate:"required_strict,min=2"` - Sku *string `json:"sku,omitempty" validate:"omitempty"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Brand string `json:"brand" validate:"required_strict,min=2,max=50"` + Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"` UomID uint `json:"uom_id" validate:"required,gt=0"` ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` ProductPrice float64 `json:"product_price" validate:"required"` diff --git a/internal/modules/master/suppliers/route.go b/internal/modules/master/suppliers/route.go index 3a57f645..17271d4a 100644 --- a/internal/modules/master/suppliers/route.go +++ b/internal/modules/master/suppliers/route.go @@ -1,7 +1,7 @@ package suppliers import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/controllers" supplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func SupplierRoutes(v1 fiber.Router, u user.UserService, s supplier.SupplierServ ctrl := controller.NewSupplierController(s) route := v1.Group("/suppliers") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index 30ff4b9b..75d8fa04 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -5,14 +5,14 @@ import ( "fmt" "strings" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/validations" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -124,8 +124,10 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti } alias := strings.TrimSpace(strings.ToUpper(req.Alias)) - - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } createBody := &entity.Supplier{ Name: req.Name, Alias: alias, @@ -139,7 +141,7 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti Npwp: req.Npwp, AccountNumber: req.AccountNumber, DueDate: req.DueDate, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/suppliers/validations/supplier.validation.go b/internal/modules/master/suppliers/validations/supplier.validation.go index fa1d135d..ec02cd8e 100644 --- a/internal/modules/master/suppliers/validations/supplier.validation.go +++ b/internal/modules/master/suppliers/validations/supplier.validation.go @@ -1,14 +1,14 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` Alias string `json:"alias" validate:"required_strict,max=5"` - Pic string `json:"pic" validate:"required_strict"` - Type string `json:"type" validate:"required_strict"` - Category string `json:"category" validate:"required_strict"` - Hatchery *string `json:"hatchery,omitempty" validate:"omitempty"` + Pic string `json:"pic" validate:"required_strict,max=50"` + Type string `json:"type" validate:"required_strict,max=50"` + Category string `json:"category" validate:"required_strict,max=20"` + Hatchery *string `json:"hatchery,omitempty" validate:"omitempty,max=50"` Phone string `json:"phone" validate:"required_strict,max=20"` - Email string `json:"email" validate:"required_strict,email"` + Email string `json:"email" validate:"required_strict,email,max=50"` Address string `json:"address" validate:"required_strict"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` @@ -16,14 +16,14 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` Alias *string `json:"alias,omitempty" validate:"omitempty,max=5"` - Pic *string `json:"pic,omitempty" validate:"omitempty"` - Type *string `json:"type,omitempty" validate:"omitempty"` - Category *string `json:"category,omitempty" validate:"omitempty"` - Hatchery *string `json:"hatchery,omitempty" validate:"omitempty"` + Pic *string `json:"pic,omitempty" validate:"omitempty,max=50"` + Type *string `json:"type,omitempty" validate:"omitempty,max=50"` + Category *string `json:"category,omitempty" validate:"omitempty,max=20"` + Hatchery *string `json:"hatchery,omitempty" validate:"omitempty,max=50"` Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"` - Email *string `json:"email,omitempty" validate:"omitempty,email"` + Email *string `json:"email,omitempty" validate:"omitempty,email,max=50"` Address *string `json:"address,omitempty" validate:"omitempty"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` diff --git a/internal/modules/master/uoms/services/uom.service.go b/internal/modules/master/uoms/services/uom.service.go index b0888751..5396849b 100644 --- a/internal/modules/master/uoms/services/uom.service.go +++ b/internal/modules/master/uoms/services/uom.service.go @@ -4,14 +4,14 @@ import ( "errors" "fmt" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/validations" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -87,10 +87,13 @@ func (s *uomService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Uo return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Uom with name %s already exists", req.Name)) } - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } createBody := &entity.Uom{ Name: req.Name, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/uoms/validations/uom.validation.go b/internal/modules/master/uoms/validations/uom.validation.go index 56bbd601..a7004c26 100644 --- a/internal/modules/master/uoms/validations/uom.validation.go +++ b/internal/modules/master/uoms/validations/uom.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` } type Query struct { diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go index ff05b3a1..e879e01a 100644 --- a/internal/modules/master/warehouses/repositories/warehouse.repository.go +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -17,7 +17,6 @@ type WarehouseRepository interface { IdExists(ctx context.Context, id uint) (bool, error) GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) - GetDetailByID(ctx context.Context, id uint) (*entity.Warehouse, error) } type WarehouseRepositoryImpl struct { @@ -63,18 +62,6 @@ func (r *WarehouseRepositoryImpl) GetByKandangID(ctx context.Context, kandangId return &warehouse, nil } -func (r *WarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.Warehouse, error) { - var warehouse entity.Warehouse - err := r.db.WithContext(ctx). - Preload("Area"). - Preload("Location"). - First(&warehouse, id).Error - if err != nil { - return nil, err - } - return &warehouse, nil -} - func (r *WarehouseRepositoryImpl) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) { var warehouse entity.Warehouse err := r.db.WithContext(ctx). diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 6cf45e0a..4c15b94c 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -3,13 +3,13 @@ package service import ( "errors" "fmt" - "strings" - common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -105,13 +105,15 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent ); err != nil { return nil, err } - - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } createBody := &entity.Warehouse{ Name: req.Name, Type: typ, AreaId: req.AreaId, - CreatedBy: 1, + CreatedBy: actorID, } if req.LocationId != nil { createBody.LocationId = req.LocationId diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index 809ef0c4..6046defe 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -1,16 +1,16 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Type string `json:"type" validate:"required_strict"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Type string `json:"type" validate:"required_strict,max=50"` AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` KandangId *uint `json:"kandang_id,omitempty" validate:"omitempty,number,gt=0"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - Type *string `json:"type,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Type *string `json:"type,omitempty" validate:"omitempty,max=50"` AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` KandangId *uint `json:"kandang_id,omitempty" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index d310fa39..660f1e7e 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -8,6 +8,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" @@ -125,7 +126,10 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - actorID := uint(1) // todo nanti ambil dari auth context + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } newChikins := make([]*entity.ProjectChickin, 0) for _, chickinReq := range req.ChickinRequests { @@ -356,6 +360,11 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB())) var action entity.ApprovalAction @@ -397,14 +406,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit step = utils.ProjectFlockKandangStepDisetujui } - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { - actorID := uint(1) // todo nanti ambil dari auth context if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowProjectFlockKandang, diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index 8057e847..7bab770e 100644 --- a/internal/modules/production/project-flock-kandangs/route.go +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -1,7 +1,7 @@ package project_flock_kandangs import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/controllers" projectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo ctrl := controller.NewProjectFlockKandangController(s) route := v1.Group("/project-flock-kandangs") - + route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne) diff --git a/internal/modules/production/project_flocks/repositories/project_budget.repository.go b/internal/modules/production/project_flocks/repositories/project_budget.repository.go new file mode 100644 index 00000000..943a22b3 --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/project_budget.repository.go @@ -0,0 +1,23 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectBudgetRepository interface { + repository.BaseRepository[entity.ProjectBudget] +} + +type ProjectBudgetRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectBudget] + db *gorm.DB +} + +func NewProjectBudgetRepository(db *gorm.DB) ProjectBudgetRepository { + return &ProjectBudgetRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectBudget](db), + db: db, + } +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index de4df25d..eede3638 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -11,7 +11,6 @@ import ( "gorm.io/gorm" ) - type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) @@ -42,24 +41,23 @@ func NewProjectflockRepository(db *gorm.DB) ProjectflockRepository { func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) { return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { - return r.applyQueryFilters(db, params) + return r.applyQueryFilters(r.WithDefaultRelations()(db), params) }) } func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db. - Preload("CreatedUser"). - Preload("Area"). - Preload("Fcr"). - Preload("Location"). - Preload("Kandangs"). - Preload("KandangHistory"). - Preload("KandangHistory.Kandang") + Preload("CreatedUser"). + Preload("Area"). + Preload("Fcr"). + Preload("Location"). + Preload("Kandangs"). + Preload("KandangHistory"). + Preload("KandangHistory.Kandang") } } - func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB { if params == nil { return db diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index eb806129..c1e37cd5 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -1,7 +1,7 @@ package project_flocks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/controllers" projectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj ctrl := controller.NewProjectflockController(s) route := v1.Group("/project-flocks") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 19b07447..827e5b19 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -10,8 +10,7 @@ import ( 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" - - // authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" @@ -85,18 +84,17 @@ func NewProjectflockService( } } +func (s projectflockService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, nil, err } - if params.Page <= 0 { - params.Page = 1 - } - if params.Limit <= 0 { - params.Limit = 10 - } - offset := (params.Page - 1) * params.Limit projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) @@ -112,7 +110,7 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e ids[i] = item.Id } - latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), s.approvalWorkflow, ids, s.Repository.WithDefaultRelations()) + latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), s.approvalWorkflow, ids, s.approvalQueryModifier()) if err != nil { s.Log.Warnf("Unable to load latest approvals for projectflocks: %+v", err) } else if len(latestMap) > 0 { @@ -156,7 +154,7 @@ func (s projectflockService) getOneEntityOnly(c *fiber.Ctx, id uint) (*entity.Pr } if s.ApprovalSvc != nil { - approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, s.Repository.WithDefaultRelations()) + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) if err != nil { s.Log.Warnf("Unable to load approvals for projectflock %d: %+v", id, err) } else if len(approvals) > 0 { @@ -183,7 +181,7 @@ func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock } if s.ApprovalSvc != nil { - approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, s.Repository.WithDefaultRelations()) + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) if err != nil { s.Log.Warnf("Unable to load approvals for projectflock %d: %+v", id, err) } else if len(approvals) > 0 { @@ -221,7 +219,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } - actorID, err := actorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } @@ -344,7 +342,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, err } - actorID, err := actorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } @@ -602,7 +600,7 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] return nil, err } - actorID, err := actorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } @@ -847,7 +845,7 @@ func (s projectflockService) GetPeriodSummary(c *fiber.Ctx, locationID uint) ([] summaries := make([]KandangPeriodSummary, 0, len(rows)) for _, row := range rows { - nextPeriod := 0 + nextPeriod := 1 if row.LatestPeriod > 0 { nextPeriod = row.LatestPeriod + 1 } @@ -1046,12 +1044,3 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka } return kandangRepository.NewKandangRepository(s.Repository.DB()) } - -func actorIDFromContext(_ *fiber.Ctx) (uint, error) { - // user, ok := authmiddleware.AuthenticatedUser(c) - // if !ok || user == nil || user.Id == 0 { - // return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } - // return user.Id, nil - return 1, nil -} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index ff6b4ea0..341031e1 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -2,6 +2,7 @@ package recordings import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -14,6 +15,7 @@ import ( rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -26,6 +28,25 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyRecordingStock, + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { @@ -41,6 +62,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo, approvalRepo, approvalService, + fifoService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d9512edd..5feb8d6b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -25,6 +25,7 @@ type RecordingRepository interface { CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error DeleteStocks(tx *gorm.DB, recordingID uint) error ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) + UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error DeleteDepletions(tx *gorm.DB, recordingID uint) error @@ -120,6 +121,15 @@ func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]e return items, nil } +func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error { + return tx.Model(&entity.RecordingStock{}). + Where("id = ?", stockID). + Updates(map[string]any{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { if len(depletions) == 0 { return nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index b31a90c0..82f60433 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -4,20 +4,21 @@ import ( "context" "errors" "fmt" - "math" - "strings" - "time" - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" + "math" + "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -36,6 +37,13 @@ type RecordingService interface { Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } +type RecordingFIFOIntegrationService interface { + ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error + ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error +} + +var recordingStockUsableKey = fifo.UsableKeyRecordingStock + type recordingService struct { Log *logrus.Logger Validate *validator.Validate @@ -45,6 +53,7 @@ type recordingService struct { ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewRecordingService( @@ -54,6 +63,7 @@ func NewRecordingService( projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -65,6 +75,20 @@ func NewRecordingService( ProjectFlockPopulationRepo: projectFlockPopulationRepo, ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, + } +} + +func NewRecordingFIFOIntegrationService( + repo repository.RecordingRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + fifoSvc commonSvc.FifoService, +) RecordingFIFOIntegrationService { + return &recordingService{ + Log: utils.Log, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + FifoSvc: fifoSvc, } } @@ -169,7 +193,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } - + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } var createdRecording entity.Recording transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId) @@ -193,7 +220,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent ProjectFlockKandangId: req.ProjectFlockKandangId, RecordDatetime: recordTime, Day: &day, - CreatedBy: 1, // TODO: replace with authenticated user + CreatedBy: actorID, } if err := s.Repository.CreateOne(ctx, &createdRecording, func(*gorm.DB) *gorm.DB { return tx }); err != nil { @@ -219,6 +246,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } + if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { + return err + } + mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to persist depletions: %+v", err) @@ -231,7 +262,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, nil, nil, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err } @@ -344,6 +375,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } + if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil { + return err + } + if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear stocks: %+v", err) return err @@ -355,8 +390,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err) + if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { return err } } @@ -422,7 +456,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin action := entity.ApprovalActionUpdated actorID := recordingEntity.CreatedBy if actorID == 0 { - actorID = 1 + return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") } var step approvalutils.ApprovalStep @@ -613,7 +647,10 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent } ctx := c.Context() - actorID := uint(1) // TODO: replace with authenticated user once auth is integrated + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { repoTx := s.Repository.WithTx(tx) @@ -685,7 +722,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); err != nil { + if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil { + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, nil, nil, oldEggs, nil)); err != nil { return err } @@ -740,6 +781,77 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } +func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + var desired float64 + if stock.UsageQty != nil { + desired = *stock.UsageQty + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + ProductWarehouseID: stock.ProductWarehouseId, + Quantity: desired, + AllowPending: true, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + return s.consumeRecordingStocks(ctx, tx, stocks) +} + +func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + return s.releaseRecordingStocks(ctx, tx, stocks) +} + func buildWarehouseDeltas( oldDepletions, newDepletions []entity.RecordingDepletion, oldStocks, newStocks []entity.RecordingStock, @@ -752,12 +864,6 @@ func buildWarehouseDeltas( for _, item := range newDepletions { accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty) } - for _, item := range oldStocks { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, usageQtyValue(item.UsageQty)) - } - for _, item := range newStocks { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -usageQtyValue(item.UsageQty)) - } for _, item := range oldEggs { accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty)) } @@ -767,13 +873,6 @@ func buildWarehouseDeltas( return deltas } -func usageQtyValue(val *float64) float64 { - if val == nil { - return 0 - } - return *val -} - func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) { if id == 0 || value == 0 { return @@ -951,7 +1050,7 @@ func (s *recordingService) createRecordingApproval( return fiber.NewError(fiber.StatusBadRequest, "Recording tidak valid untuk approval") } if actorID == 0 { - actorID = 1 + return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") } var svc commonSvc.ApprovalService diff --git a/internal/modules/production/transfer_layings/route.go b/internal/modules/production/transfer_layings/route.go index ad0cb9e1..868454c5 100644 --- a/internal/modules/production/transfer_layings/route.go +++ b/internal/modules/production/transfer_layings/route.go @@ -1,7 +1,7 @@ package transfer_layings import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/controllers" transferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying. ctrl := controller.NewTransferLayingController(s) route := v1.Group("/transfer_layings") - + route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne) diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 51c76e1a..bf2c2ae3 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -10,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" ProjectFlockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" @@ -154,6 +155,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + if _, err := s.ProjectFlockRepo.GetByID(c.Context(), req.SourceProjectFlockId, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Source Project Flock not found") @@ -259,7 +265,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) ToProjectFlockId: req.TargetProjectFlockId, TransferDate: transferDate, PendingUsageQty: &totalSourceQty, - CreatedBy: 1, //todo : harus diambil dari auth + CreatedBy: actorID, } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -592,7 +598,11 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return nil, err } - actorID := uint(1) // TODO: change from auth context + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + var action entity.ApprovalAction switch strings.ToUpper(strings.TrimSpace(req.Action)) { case string(entity.ApprovalActionRejected): @@ -613,7 +623,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( step = utils.TransferToLayingStepDisetujui } - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index d10f42af..b4cf5660 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -23,21 +23,19 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController { } func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { - query := &validation.PurchaseQuery{ + query := &validation.Query{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), - Search: strings.TrimSpace(c.Query("search")), - PrNumber: strings.TrimSpace(c.Query("pr_number")), CreatedFrom: strings.TrimSpace(c.Query("created_from")), CreatedTo: strings.TrimSpace(c.Query("created_to")), + SupplierID: uint(c.QueryInt("supplier_id", 0)), + AreaID: uint(c.QueryInt("area_id", 0)), + LocationID: uint(c.QueryInt("location_id", 0)), + ProductCategoryID: uint(c.QueryInt("product_category_id", 0)), } - if supplierID := c.QueryInt("supplier_id", 0); supplierID > 0 { - query.SupplierID = uint(supplierID) - } - - if status := strings.TrimSpace(c.Query("status")); status != "" { - query.Status = strings.ToUpper(status) + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } results, total, err := ctrl.service.GetAll(c, query) @@ -45,24 +43,15 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { return err } - limit := query.Limit - if limit <= 0 { - limit = 10 - } - page := query.Page - if page <= 0 { - page = 1 - } - return c.Status(fiber.StatusOK). - JSON(response.SuccessWithPaginate[dto.PurchaseListItemDTO]{ + JSON(response.SuccessWithPaginate[dto.PurchaseListDTO]{ Code: fiber.StatusOK, Status: "success", Message: "Purchase fetched successfully", Meta: response.Meta{ - Page: page, - Limit: limit, - TotalPages: int64(math.Ceil(float64(total) / float64(limit))), + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(total) / float64(query.Limit))), TotalResults: total, }, Data: dto.ToPurchaseListDTOs(results), @@ -71,12 +60,13 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { func (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } - result, err := ctrl.service.GetOne(c, id) + result, err := ctrl.service.GetOne(c, uint(id)) if err != nil { return err } @@ -96,7 +86,7 @@ func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - + result, err := ctrl.service.CreateOne(c, req) if err != nil { return err @@ -113,7 +103,7 @@ func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } @@ -123,7 +113,7 @@ func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) } - result, err := ctrl.service.ApproveStaffPurchase(c, id, req) + result, err := ctrl.service.ApproveStaffPurchase(c, uint(id), req) if err != nil { return err } @@ -137,10 +127,9 @@ func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { }) } - func (ctrl *PurchaseController) ApproveManagerPurchase(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } @@ -150,7 +139,7 @@ func (ctrl *PurchaseController) ApproveManagerPurchase(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := ctrl.service.ApproveManagerPurchase(c, id, req) + result, err := ctrl.service.ApproveManagerPurchase(c, uint(id), req) if err != nil { return err } @@ -166,7 +155,7 @@ func (ctrl *PurchaseController) ApproveManagerPurchase(c *fiber.Ctx) error { func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } @@ -176,7 +165,7 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := ctrl.service.ReceiveProducts(c, id, req) + result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err } @@ -192,7 +181,7 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } @@ -202,7 +191,7 @@ func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := ctrl.service.DeleteItems(c, id, req) + result, err := ctrl.service.DeleteItems(c, uint(id), req) if err != nil { return err } @@ -218,12 +207,12 @@ func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { func (ctrl *PurchaseController) DeletePurchase(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } - if err := ctrl.service.DeletePurchase(c, id); err != nil { + if err := ctrl.service.DeletePurchase(c, uint(id)); err != nil { return err } diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index bbd59fdd..4a29d860 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -10,46 +10,51 @@ import ( productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -type PurchaseListItemDTO struct { - Id uint64 `json:"id"` - PrNumber string `json:"pr_number"` - PoNumber *string `json:"po_number"` - Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - CreditTerm *int `json:"credit_term"` - DueDate *time.Time `json:"due_date"` - PoDate *time.Time `json:"po_date"` - GrandTotal float64 `json:"grand_total"` - Notes *string `json:"notes"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval *approvalDTO.ApprovalRelationDTO `json:"approval"` +type PurchaseRelationDTO struct { + Id uint `json:"id"` + PrNumber string `json:"pr_number"` + PoNumber *string `json:"po_number"` + PoDate *time.Time `json:"po_date"` + Notes *string `json:"notes"` +} + + +type PurchaseListDTO struct { + PurchaseRelationDTO + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + CreditTerm *int `json:"credit_term"` + DueDate *time.Time `json:"due_date"` + GrandTotal float64 `json:"grand_total"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } type PurchaseDetailDTO struct { - Id uint64 `json:"id"` - PrNumber string `json:"pr_number"` - PoNumber *string `json:"po_number"` - Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - CreditTerm *int `json:"credit_term"` - DueDate *time.Time `json:"due_date"` - PoDate *time.Time `json:"po_date"` - GrandTotal float64 `json:"grand_total"` - Notes *string `json:"notes"` - Items []PurchaseItemDTO `json:"items"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval *approvalDTO.ApprovalRelationDTO `json:"approval"` + PurchaseRelationDTO + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + CreditTerm *int `json:"credit_term"` + DueDate *time.Time `json:"due_date"` + GrandTotal float64 `json:"grand_total"` + Items []PurchaseItemDTO `json:"items"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } + type PurchaseItemDTO struct { - Id uint64 `json:"id"` - ProductID uint64 `json:"product_id"` + Id uint `json:"id"` + ProductID uint `json:"product_id"` Product *productDTO.ProductRelationDTO `json:"product"` - WarehouseID uint64 `json:"warehouse_id"` + WarehouseID uint `json:"warehouse_id"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse"` - ProductWarehouseID *uint64 `json:"product_warehouse_id"` + ProductWarehouseID *uint `json:"product_warehouse_id"` SubQty float64 `json:"sub_qty"` TotalQty float64 `json:"total_qty"` TotalUsed float64 `json:"total_used"` @@ -61,6 +66,17 @@ type PurchaseItemDTO struct { VehicleNumber *string `json:"vehicle_number"` } + +func ToPurchaseRelationDTO(p *entity.Purchase) PurchaseRelationDTO { + return PurchaseRelationDTO{ + Id: p.Id, + PrNumber: p.PrNumber, + PoNumber: p.PoNumber, + PoDate: p.PoDate, + Notes: p.Notes, + } +} + func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { dto := PurchaseItemDTO{ Id: item.Id, @@ -77,10 +93,12 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { TravelDocumentPath: item.TravelNumberDocs, VehicleNumber: item.VehicleNumber, } + if item.Product != nil && item.Product.Id != 0 { summary := productDTO.ToProductRelationDTO(*item.Product) dto.Product = &summary } + if item.Warehouse != nil && item.Warehouse.Id != 0 { summary := warehouseDTO.ToWarehouseRelationDTO(*item.Warehouse) if item.Warehouse.Area.Id != 0 { @@ -93,6 +111,7 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { } dto.Warehouse = &summary } + return dto } @@ -104,70 +123,78 @@ func ToPurchaseItemDTOs(items []entity.PurchaseItem) []PurchaseItemDTO { return result } -func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { - dto := PurchaseDetailDTO{ - Id: p.Id, - PrNumber: p.PrNumber, - PoNumber: p.PoNumber, - Supplier: mapSupplier(p.Supplier), - CreditTerm: p.CreditTerm, - DueDate: p.DueDate, - PoDate: p.PoDate, - GrandTotal: p.GrandTotal, - Notes: p.Notes, - Items: ToPurchaseItemDTOs(p.Items), - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, +func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { + var supplier *supplierDTO.SupplierRelationDTO + if p.Supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(p.Supplier) + supplier = &mapped } - if approval := toPurchaseApprovalDTO(p); approval != nil { - dto.Approval = approval + + var createdUser *userDTO.UserRelationDTO + if p.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(p.CreatedUser) + createdUser = &mapped + } + + var latestApproval *approvalDTO.ApprovalRelationDTO + if p.LatestApproval != nil && p.LatestApproval.Id != 0 { + mapped := approvalDTO.ToApprovalDTO(*p.LatestApproval) + latestApproval = &mapped + } + + return PurchaseListDTO{ + PurchaseRelationDTO: ToPurchaseRelationDTO(&p), + Supplier: supplier, + CreditTerm: p.CreditTerm, + DueDate: p.DueDate, + GrandTotal: p.GrandTotal, + CreatedUser: createdUser, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + LatestApproval: latestApproval, } - return dto } -func ToPurchaseListDTO(p entity.Purchase) PurchaseListItemDTO { - dto := PurchaseListItemDTO{ - Id: p.Id, - PrNumber: p.PrNumber, - PoNumber: p.PoNumber, - Supplier: mapSupplier(p.Supplier), - CreditTerm: p.CreditTerm, - DueDate: p.DueDate, - PoDate: p.PoDate, - GrandTotal: p.GrandTotal, - Notes: p.Notes, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - } - if approval := toPurchaseApprovalDTO(p); approval != nil { - dto.Approval = approval - } - return dto -} - -func mapSupplier(s entity.Supplier) *supplierDTO.SupplierRelationDTO { - if s.Id == 0 { - return nil - } - summary := supplierDTO.ToSupplierRelationDTO(s) - return &summary -} - -func ToPurchaseListDTOs(items []entity.Purchase) []PurchaseListItemDTO { +func ToPurchaseListDTOs(items []entity.Purchase) []PurchaseListDTO { if len(items) == 0 { - return nil + return make([]PurchaseListDTO, 0) } - result := make([]PurchaseListItemDTO, len(items)) + result := make([]PurchaseListDTO, len(items)) for i, item := range items { result[i] = ToPurchaseListDTO(item) } return result } -func toPurchaseApprovalDTO(p entity.Purchase) *approvalDTO.ApprovalRelationDTO { - if p.LatestApproval == nil || p.LatestApproval.Id == 0 { - return nil +func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { + var supplier *supplierDTO.SupplierRelationDTO + if p.Supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(p.Supplier) + supplier = &mapped } - mapped := approvalDTO.ToApprovalDTO(*p.LatestApproval) - return &mapped -} + + var createdUser *userDTO.UserRelationDTO + if p.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(p.CreatedUser) + createdUser = &mapped + } + + var latestApproval *approvalDTO.ApprovalRelationDTO + if p.LatestApproval != nil && p.LatestApproval.Id != 0 { + mapped := approvalDTO.ToApprovalDTO(*p.LatestApproval) + latestApproval = &mapped + } + + return PurchaseDetailDTO{ + PurchaseRelationDTO: ToPurchaseRelationDTO(&p), + Supplier: supplier, + CreditTerm: p.CreditTerm, + DueDate: p.DueDate, + GrandTotal: p.GrandTotal, + Items: ToPurchaseItemDTOs(p.Items), + CreatedUser: createdUser, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + LatestApproval: latestApproval, + } +} \ No newline at end of file diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 1911e364..56dd5932 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -43,7 +43,6 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo, supplierRepo, productWarehouseRepo, - approvalRepo, approvalService, expenseBridge, ) diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index bc1c038a..49bb07e9 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -18,14 +18,11 @@ import ( type PurchaseRepository interface { repository.BaseRepository[entity.Purchase] CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error - CreateItems(ctx context.Context, purchaseID uint64, items []*entity.PurchaseItem) error - GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) - GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) - UpdatePricing(ctx context.Context, purchaseID uint64, updates []PurchasePricingUpdate, grandTotal float64) error - UpdateReceivingDetails(ctx context.Context, purchaseID uint64, updates []PurchaseReceivingUpdate) error - DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error - WithListRelations() func(*gorm.DB) *gorm.DB - UpdateGrandTotal(ctx context.Context, purchaseID uint64, grandTotal float64) error + CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error + UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate, grandTotal float64) error + UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error + DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error + UpdateGrandTotal(ctx context.Context, purchaseID uint, grandTotal float64) error NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) } @@ -40,19 +37,10 @@ func NewPurchaseRepository(db *gorm.DB) PurchaseRepository { } } -type PurchaseListFilter struct { - SupplierID uint - Search string - PrNumber string - CreatedFrom *time.Time - CreatedTo *time.Time - Status *entity.ApprovalAction - CompletedOnly bool -} - func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error { db := r.DB().WithContext(ctx) + //ambil dari base repository if err := db.Create(purchase).Error; err != nil { return err } @@ -71,7 +59,7 @@ func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase * return nil } -func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint64, items []*entity.PurchaseItem) error { +func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error { if len(items) == 0 { return nil } @@ -86,52 +74,9 @@ func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uin return r.DB().WithContext(ctx).Create(&items).Error } -func (r *PurchaseRepositoryImpl) GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) { - var purchase entity.Purchase - err := r.DB().WithContext(ctx). - Scopes(r.withDetailRelations). - First(&purchase, id).Error - if err != nil { - return nil, err - } - return &purchase, nil -} - -func (r *PurchaseRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) { - return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { - db = r.withListRelations(db) - return r.applyListFilters(db, filter) - }) -} - -func (r *PurchaseRepositoryImpl) WithListRelations() func(*gorm.DB) *gorm.DB { - return func(db *gorm.DB) *gorm.DB { - return r.withListRelations(db) - } -} - -func (r *PurchaseRepositoryImpl) withDetailRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("Supplier"). - Preload("Items", func(db *gorm.DB) *gorm.DB { - return db.Order("id ASC") - }). - Preload("Items.Product"). - Preload("Items.Warehouse"). - Preload("Items.Warehouse.Area"). - Preload("Items.Warehouse.Location"). - Preload("Items.ProductWarehouse") -} - -func (r *PurchaseRepositoryImpl) WithDetailRelations() func(*gorm.DB) *gorm.DB { - return func(db *gorm.DB) *gorm.DB { - return r.withDetailRelations(db) - } -} - type PurchasePricingUpdate struct { - ItemID uint64 - ProductID *uint64 + ItemID uint + ProductID *uint Price float64 TotalPrice float64 Quantity *float64 @@ -139,7 +84,7 @@ type PurchasePricingUpdate struct { } type PurchaseReceivingUpdate struct { - ItemID uint64 + ItemID uint ReceivedDate *time.Time TravelNumber *string TravelDocumentPath *string @@ -152,7 +97,7 @@ type PurchaseReceivingUpdate struct { func (r *PurchaseRepositoryImpl) UpdatePricing( ctx context.Context, - purchaseID uint64, + purchaseID uint, updates []PurchasePricingUpdate, grandTotal float64, ) error { @@ -192,7 +137,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( Where("id = ?", purchaseID). Updates(map[string]interface{}{ "grand_total": grandTotal, - "updated_at": gorm.Expr("NOW()"), }).Error; err != nil { return err } @@ -202,7 +146,7 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( ctx context.Context, - purchaseID uint64, + purchaseID uint, updates []PurchaseReceivingUpdate, ) error { if len(updates) == 0 { @@ -259,7 +203,7 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( func (r *PurchaseRepositoryImpl) UpdateGrandTotal( ctx context.Context, - purchaseID uint64, + purchaseID uint, grandTotal float64, ) error { return r.DB().WithContext(ctx). @@ -271,7 +215,7 @@ func (r *PurchaseRepositoryImpl) UpdateGrandTotal( }).Error } -func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error { +func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error { if len(itemIDs) == 0 { return errors.New("itemIDs cannot be empty") } @@ -361,63 +305,3 @@ func parseNumericSuffix(value, prefix string) (int, bool) { } return number, true } - -func (r *PurchaseRepositoryImpl) withListRelations(db *gorm.DB) *gorm.DB { - return db.Preload("Supplier") -} - -func (r *PurchaseRepositoryImpl) applyListFilters(db *gorm.DB, filter *PurchaseListFilter) *gorm.DB { - if filter == nil { - return db - } - - if filter.SupplierID > 0 { - db = db.Where("purchases.supplier_id = ?", filter.SupplierID) - } - - if search := strings.ToLower(strings.TrimSpace(filter.Search)); search != "" { - like := "%" + search + "%" - db = db.Where("(LOWER(purchases.pr_number) LIKE ? OR LOWER(COALESCE(purchases.notes, '')) LIKE ?)", like, like) - } - - if pr := strings.TrimSpace(filter.PrNumber); pr != "" { - db = db.Where("purchases.pr_number ILIKE ?", "%"+pr+"%") - } - - if filter.CreatedFrom != nil { - db = db.Where("purchases.created_at >= ?", *filter.CreatedFrom) - } - - if filter.CreatedTo != nil { - db = db.Where("purchases.created_at < ?", *filter.CreatedTo) - } - - if filter.CompletedOnly { - step := uint16(utils.PurchaseStepCompleted) - db = r.applyLatestApprovalFilter(db, entity.ApprovalActionApproved, &step) - } else if filter.Status != nil { - db = r.applyLatestApprovalFilter(db, *filter.Status, nil) - } - - return db.Order("purchases.created_at DESC").Order("purchases.id DESC") -} - -func (r *PurchaseRepositoryImpl) applyLatestApprovalFilter(db *gorm.DB, action entity.ApprovalAction, minStep *uint16) *gorm.DB { - latestSub := r.DB(). - Model(&entity.Approval{}). - Select("approvable_id, MAX(action_at) AS latest_action_at"). - Where("approvable_type = ?", utils.ApprovalWorkflowPurchase.String()). - Group("approvable_id") - - db = db. - Joins("LEFT JOIN (?) AS latest_purchase_approvals ON latest_purchase_approvals.approvable_id = purchases.id", latestSub). - Joins( - "LEFT JOIN approvals ON approvals.approvable_id = purchases.id AND approvals.approvable_type = ? AND approvals.action_at = latest_purchase_approvals.latest_action_at", - utils.ApprovalWorkflowPurchase.String(), - ). - Where("approvals.action = ?", string(action)) - if minStep != nil { - db = db.Where("approvals.step_number >= ?", *minStep) - } - return db -} diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go index aedc3ee8..5145bc94 100644 --- a/internal/modules/purchases/route.go +++ b/internal/modules/purchases/route.go @@ -3,7 +3,7 @@ package purchases import ( "github.com/gofiber/fiber/v2" - middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/controllers" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func Routes(router fiber.Router, purchaseService service.PurchaseService, userSe ctrl := controller.NewPurchaseController(purchaseService) route := router.Group("/purchases") - route.Use(middleware.Auth(userService)) + route.Use(m.Auth(userService)) route.Get("/", ctrl.GetAll) route.Get("/:id", ctrl.GetOne) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index b7c96d03..3e857d35 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -9,16 +9,16 @@ import ( // PurchaseExpenseBridge defines hooks that allow purchase flows to stay in sync with expense data once it exists. type PurchaseExpenseBridge interface { - OnItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) error - OnItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) error - OnItemsReceived(ctx context.Context, purchaseID uint64, updates []ExpenseReceivingPayload) error + OnItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error + OnItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) error + OnItemsReceived(ctx context.Context, purchaseID uint, updates []ExpenseReceivingPayload) error } // ExpenseReceivingPayload captures the minimum data expense integration will need once available. type ExpenseReceivingPayload struct { - PurchaseItemID uint64 - ProductID uint64 - WarehouseID uint64 + PurchaseItemID uint + ProductID uint + WarehouseID uint ReceivedQty float64 ReceivedDate *time.Time } @@ -30,14 +30,14 @@ func NewNoopPurchaseExpenseBridge() PurchaseExpenseBridge { return &noopPurchaseExpenseBridge{} } -func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint64, _ []entity.PurchaseItem) error { +func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint, _ []entity.PurchaseItem) error { return nil } -func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint64, _ []uint64) error { +func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint, _ []uint) error { return nil } -func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint64, _ []ExpenseReceivingPayload) error { +func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint, _ []ExpenseReceivingPayload) error { return nil } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b0d5311d..60a65960 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -11,7 +11,7 @@ import ( 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" - authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" @@ -28,19 +28,18 @@ import ( ) type PurchaseService interface { - GetAll(ctx *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) - GetOne(ctx *fiber.Ctx, id uint64) (*entity.Purchase, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Purchase, error) CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) - ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) - ApproveManagerPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) - ReceiveProducts(ctx *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) - DeleteItems(ctx *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) - DeletePurchase(ctx *fiber.Ctx, id uint64) error + ApproveStaffPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) + ApproveManagerPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) + ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) + DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) + DeletePurchase(ctx *fiber.Ctx, id uint) error } const ( - priceTolerance = 0.0001 - queryDateLayout = "2006-01-02" + priceTolerance = 0.0001 ) type purchaseService struct { @@ -51,9 +50,9 @@ type purchaseService struct { WarehouseRepo rWarehouse.WarehouseRepository SupplierRepo rSupplier.SupplierRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository - ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge + approvalWorkflow approvalutils.ApprovalWorkflowKey } type staffAdjustmentPayload struct { @@ -69,7 +68,6 @@ func NewPurchaseService( warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, - approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, ) PurchaseService { @@ -84,70 +82,113 @@ func NewPurchaseService( WarehouseRepo: warehouseRepo, SupplierRepo: supplierRepo, ProductWarehouseRepo: productWarehouseRepo, - ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, + approvalWorkflow: utils.ApprovalWorkflowPurchase, } } +func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { + if db == nil { + return db + } -func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) { + return db. + Preload("Supplier"). + Preload("Items", func(db *gorm.DB) *gorm.DB { + return db.Order("id ASC") + }). + Preload("Items.Product"). + Preload("Items.Product.Uom"). + Preload("Items.Product.ProductCategory"). + Preload("Items.Warehouse"). + Preload("Items.Product.Flags"). + Preload("Items.Warehouse.Area"). + Preload("Items.Warehouse.Location"). + Preload("Items.ProductWarehouse") +} + +func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } - limit := params.Limit - if limit <= 0 { - limit = 10 - } - page := params.Page - if page <= 0 { - page = 1 - } - offset := (page - 1) * limit - - ctx := c.Context() + offset := (params.Page - 1) * params.Limit createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) if err != nil { return nil, 0, err } - statusAction, completedOnly, err := parseApprovalAction(params.Status) - if err != nil { - return nil, 0, err - } + purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) - filter := &rPurchase.PurchaseListFilter{ - SupplierID: params.SupplierID, - Search: params.Search, - PrNumber: params.PrNumber, - CreatedFrom: createdFrom, - CreatedTo: createdTo, - Status: statusAction, - CompletedOnly: completedOnly, - } + if params.SupplierID > 0 { + db = db.Where("supplier_id = ?", params.SupplierID) + } + + if createdFrom != nil { + db = db.Where("created_at >= ?", *createdFrom) + } + + if createdTo != nil { + db = db.Where("created_at < ?", *createdTo) + } + + if params.AreaID > 0 { + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.area_id = ? + )`, + params.AreaID, + ) + } + + if params.LocationID > 0 { + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.location_id = ? + )`, + params.LocationID, + ) + } + + if params.ProductCategoryID > 0 { + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN products p ON p.id = pi.product_id + WHERE pi.purchase_id = purchases.id AND p.product_category_id = ? + )`, + params.ProductCategoryID, + ) + } + + return db.Order("created_at DESC").Order("purchases.id DESC") + }) - purchases, total, err := s.PurchaseRepo.GetAllWithFilters(ctx, offset, limit, filter) if err != nil { s.Log.Errorf("Failed to get purchases: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases") } - if err := s.attachLatestApprovals(ctx, purchases); err != nil { - s.Log.Warnf("Unable to attach latest approvals to purchases: %+v", err) + for i := range purchases { + if err := s.attachLatestApproval(c.Context(), &purchases[i]); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", purchases[i].Id, err) + } } return purchases, total, nil } -func (s *purchaseService) GetOne(c *fiber.Ctx, id uint64) (*entity.Purchase, error) { - if id == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") - } - - ctx := c.Context() - - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) +func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -156,10 +197,9 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint64) (*entity.Purchase, err return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - if err := s.attachLatestApproval(ctx, purchase); err != nil { + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } - return purchase, nil } @@ -168,14 +208,12 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return nil, err } - actorID, err := actorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } - ctx := c.Context() - - if _, err := s.SupplierRepo.GetByID(ctx, req.SupplierID, nil); err != nil { + if _, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") } @@ -184,8 +222,8 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase } type aggregatedItem struct { - productId uint64 - warehouseId uint64 + productId uint + warehouseId uint subQty float64 } @@ -195,13 +233,14 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase warehouseCache := make(map[uint]*entity.Warehouse) productSupplierCache := make(map[uint]bool) - getWarehouse := func(id uint) (*entity.Warehouse, error) { if warehouse, ok := warehouseCache[id]; ok { return warehouse, nil } + warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Area").Preload("location") + }) - warehouse, err := s.WarehouseRepo.GetDetailByID(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) @@ -223,7 +262,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase } if _, checked := productSupplierCache[item.ProductID]; !checked { - linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, item.ProductID, req.SupplierID) + linked, err := s.ProductRepo.IsLinkedToSupplier(c.Context(), item.ProductID, req.SupplierID) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") @@ -234,8 +273,8 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase productSupplierCache[item.ProductID] = true } - productId := uint64(item.ProductID) - warehouseId := uint64(item.WarehouseID) + productId := uint(item.ProductID) + warehouseId := uint(item.WarehouseID) key := fmt.Sprintf("%d:%d", productId, warehouseId) if idx, ok := indexMap[key]; ok { @@ -258,12 +297,12 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase dueDate := &dueDateValue purchase := &entity.Purchase{ - SupplierId: uint64(req.SupplierID), + SupplierId: uint(req.SupplierID), CreditTerm: creditTerm, DueDate: dueDate, GrandTotal: 0, Notes: req.Notes, - CreatedBy: uint64(actorID), + CreatedBy: uint(actorID), } items := make([]*entity.PurchaseItem, 0, len(aggregated)) @@ -279,25 +318,21 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase }) } - transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) - code, err := purchaseRepoTx.NextPrNumber(ctx, tx) + code, err := purchaseRepoTx.NextPrNumber(c.Context(), tx) if err != nil { return err } purchase.PrNumber = code - if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil { + if err := purchaseRepoTx.CreateWithItems(c.Context(), purchase, items); err != nil { return err } actorID := uint(purchase.CreatedBy) - if actorID == 0 { - actorID = 1 - } - action := entity.ApprovalActionCreated - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepPengajuan, action, actorID, nil, false); err != nil { + if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil { return err } @@ -308,37 +343,35 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase") } - created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + created, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { s.Log.Errorf("Failed to load created purchase: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } - if err := s.attachLatestApproval(ctx, created); err != nil { + if err := s.attachLatestApproval(c.Context(), created); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err) } - s.notifyExpenseItemsCreated(ctx, created.Id, created.Items) - return created, nil } -func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { - return s.processStaffPurchaseApproval(c, id, req, false) -} - -func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest, requireStaffApproval bool) (*entity.Purchase, error) { +func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - actorID, err := actorIDFromContext(c) + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } - ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -346,7 +379,7 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - if err := s.attachLatestApproval(ctx, purchase); err != nil { + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } @@ -355,8 +388,8 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, latestStep = purchase.LatestApproval.StepNumber } - if requireStaffApproval && latestStep < uint16(utils.PurchaseStepStaffPurchase) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot be edited before staff approval") + if action == entity.ApprovalActionRejected { + return s.rejectAndReload(c, utils.PurchaseStepStaffPurchase, purchase.Id, actorID, req.Notes) } isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase) @@ -374,68 +407,56 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, syncReceiving := !isInitialApproval && hasReceivingData - payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving) + if len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty for staff approval") + } + + payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving) if err != nil { return nil, err } - transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) grandTotalUpdated := false if len(payload.PricingUpdates) > 0 { - if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { + if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { return err } grandTotalUpdated = true } if len(payload.NewItems) > 0 { - if err := purchaseRepoTx.CreateItems(ctx, purchase.Id, payload.NewItems); err != nil { + if err := purchaseRepoTx.CreateItems(c.Context(), purchase.Id, payload.NewItems); err != nil { return err } } if !grandTotalUpdated { - if err := purchaseRepoTx.UpdateGrandTotal(ctx, purchase.Id, payload.GrandTotal); err != nil { + if err := purchaseRepoTx.UpdateGrandTotal(c.Context(), purchase.Id, payload.GrandTotal); err != nil { return err } } if isInitialApproval { - action := entity.ApprovalActionApproved - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { + if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { return err } return nil } if len(payload.PricingUpdates) > 0 || len(payload.NewItems) > 0 { - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) - if approvalSvc != nil { - latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), nil) - if err != nil { - return err - } - - shouldRecordStaffUpdate := latest == nil || - latest.StepNumber != uint16(utils.PurchaseStepStaffPurchase) || - latest.Action == nil || - (latest.Action != nil && *latest.Action != entity.ApprovalActionUpdated) - - if shouldRecordStaffUpdate { - action := entity.ApprovalActionUpdated - if _, err := approvalSvc.CreateApproval( - ctx, - utils.ApprovalWorkflowPurchase, - uint(purchase.Id), - utils.PurchaseStepStaffPurchase, - &action, - actorID, - req.Notes, - ); err != nil { - return err - } - } + if err := s.createPurchaseApproval( + c.Context(), + tx, + purchase.Id, + utils.PurchaseStepStaffPurchase, + entity.ApprovalActionUpdated, + actorID, + req.Notes, + true, // allowDuplicate = true supaya boleh UPDATED berkali2 + ); err != nil { + return err } } @@ -452,11 +473,11 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update purchase pricing") } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } - if err := s.attachLatestApproval(ctx, updated); err != nil { + if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } @@ -468,25 +489,28 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, } newItems[i] = *item } - s.notifyExpenseItemsCreated(ctx, purchase.Id, newItems) + s.notifyExpenseItemsCreated(c.Context(), purchase.Id, newItems) } return updated, nil } -func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) { +func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - actorID, err := actorIDFromContext(c) + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } - ctx := c.Context() + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -495,7 +519,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - if err := s.attachLatestApproval(ctx, purchase); err != nil { + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } @@ -504,16 +528,19 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval") } - action := entity.ApprovalActionApproved + if action == entity.ApprovalActionRejected { + return s.rejectAndReload(c, utils.PurchaseStepManager, purchase.Id, actorID, req.Notes) + } + now := time.Now().UTC() hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" var generatedNumber string - transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { updateData := map[string]any{} if !hasExistingPO { repoTx := rPurchase.NewPurchaseRepository(tx) - code, err := repoTx.NextPoNumber(ctx, tx) + code, err := repoTx.NextPoNumber(c.Context(), tx) if err != nil { return err } @@ -524,7 +551,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v if len(updateData) > 0 { repoTx := rPurchase.NewPurchaseRepository(tx) - if err := repoTx.PatchOne(ctx, uint(purchase.Id), updateData, nil); err != nil { + if err := repoTx.PatchOne(c.Context(), uint(purchase.Id), updateData, nil); err != nil { return err } } @@ -537,11 +564,11 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v return db.Where("step_number = ?", uint16(step)) } } - latestStaff, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase)) + latestStaff, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase)) if err != nil { return err } - latestManager, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager)) + latestManager, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager)) if err != nil { return err } @@ -550,7 +577,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v } } - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil { + if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil { return err } @@ -566,32 +593,35 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v purchase.PoDate = &now } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { s.Log.Errorf("Failed to load purchase after manager approval: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } - if err := s.attachLatestApproval(ctx, updated); err != nil { + if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } return updated, nil } -func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) { +func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - actorID, err := actorIDFromContext(c) + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } - ctx := c.Context() + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -603,7 +633,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated") } - if err := s.attachLatestApproval(ctx, purchase); err != nil { + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } @@ -612,7 +642,25 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products") } - itemMap := make(map[uint64]*entity.PurchaseItem, len(purchase.Items)) + if action == entity.ApprovalActionApproved && len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must not be empty") + } + + if action == entity.ApprovalActionRejected { + if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil { + return nil, err + } + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + if err := s.attachLatestApproval(c.Context(), updated); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + return updated, nil + } + + itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { itemMap[purchase.Items[i].Id] = &purchase.Items[i] } @@ -626,7 +674,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati receivedQty float64 } - visitedItems := make(map[uint64]struct{}, len(req.Items)) + visitedItems := make(map[uint]struct{}, len(req.Items)) prepared := make([]preparedReceiving, 0, len(req.Items)) for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] @@ -684,10 +732,12 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items") } - receivingAction := entity.ApprovalActionApproved + receivingAction := action completedAction := entity.ApprovalActionApproved - - approvalSvc := s.approvalServiceForDB(nil) + approvalSvc := commonSvc.NewApprovalService( + commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()), + ) + if approvalSvc != nil { filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { @@ -695,7 +745,12 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati } } - latestReceiving, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterStep(utils.PurchaseStepReceiving)) + latestReceiving, err := approvalSvc.LatestByTarget( + c.Context(), + utils.ApprovalWorkflowPurchase, + uint(purchase.Id), + filterStep(utils.PurchaseStepReceiving), + ) if err != nil { s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") @@ -705,8 +760,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati receivingAction = entity.ApprovalActionUpdated } } - - transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { repoTx := rPurchase.NewPurchaseRepository(tx) pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) @@ -727,7 +781,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati clearPW := false if prep.receivedQty > 0 { - pwID, err := pwRepoTx.EnsureProductWarehouse(ctx, uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) + pwID, err := pwRepoTx.EnsureProductWarehouse(c.Context(), uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) if err != nil { return err } @@ -764,23 +818,23 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati updates = append(updates, update) } - if err := repoTx.UpdateReceivingDetails(ctx, purchase.Id, updates); err != nil { + if err := repoTx.UpdateReceivingDetails(c.Context(), purchase.Id, updates); err != nil { return err } - if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil { + if err := pwRepoTx.AdjustQuantities(c.Context(), deltas, nil); err != nil { return err } - if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil { + if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { return err } - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { + if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { return err } - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { + if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { return err } @@ -794,11 +848,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ") } - if err := s.attachLatestApproval(ctx, updated); err != nil { + if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } @@ -808,24 +862,24 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati payload := ExpenseReceivingPayload{ PurchaseItemID: prep.item.Id, ProductID: prep.item.ProductId, - WarehouseID: uint64(prep.warehouseID), + WarehouseID: uint(prep.warehouseID), ReceivedQty: prep.receivedQty, ReceivedDate: &date, } receivingPayloads = append(receivingPayloads, payload) } - s.notifyExpenseItemsReceived(ctx, purchase.Id, receivingPayloads) + s.notifyExpenseItemsReceived(c.Context(), purchase.Id, receivingPayloads) return updated, nil } -func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { +func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -844,19 +898,16 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to delete") } - requested := make(map[uint64]struct{}, len(req.ItemIDs)) + requested := make(map[uint]struct{}, len(req.ItemIDs)) for _, id := range req.ItemIDs { requested[id] = struct{}{} } - toDelete := make([]uint64, 0, len(req.ItemIDs)) + toDelete := make([]uint, 0, len(req.ItemIDs)) var remainingTotal float64 for _, item := range purchase.Items { if _, ok := requested[item.Id]; ok { - if item.TotalQty > 0 || item.TotalUsed > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete item %d because it already has receiving data", item.Id)) - } toDelete = append(toDelete, item.Id) } else { remainingTotal += item.TotalPrice @@ -895,7 +946,7 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete) } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } @@ -906,13 +957,13 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D return updated, nil } -func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { +func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { if id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -920,7 +971,7 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - itemIDs := make([]uint64, 0, len(purchase.Items)) + itemIDs := make([]uint, 0, len(purchase.Items)) for _, item := range purchase.Items { itemIDs = append(itemIDs, item.Id) } @@ -945,105 +996,13 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { } if len(itemIDs) > 0 { - s.notifyExpenseItemsDeleted(ctx, uint64(id), itemIDs) + s.notifyExpenseItemsDeleted(ctx, uint(id), itemIDs) } return nil } -func (s *purchaseService) createPurchaseApproval( - ctx context.Context, - db *gorm.DB, - purchaseID uint64, - step approvalutils.ApprovalStep, - action entity.ApprovalAction, - actorID uint, - notes *string, - allowDuplicate bool, -) error { - if purchaseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") - } - if actorID == 0 { - actorID = 1 - } - - svc := s.approvalServiceForDB(db) - - modifier := func(db *gorm.DB) *gorm.DB { - return db.Where("step_number = ?", uint16(step)) - } - - latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) - if err != nil { - return err - } - - if !allowDuplicate && latest != nil && - latest.Action != nil && - *latest.Action == action { - return nil - } - - actionCopy := action - _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) - return err -} - -func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService { - if db != nil { - return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) - } - if s.ApprovalSvc != nil { - return s.ApprovalSvc - } - return commonSvc.NewApprovalService(s.ApprovalRepo) -} - -func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error { - if len(items) == 0 || s.ApprovalSvc == nil { - return nil - } - - ids := make([]uint, 0, len(items)) - visited := make(map[uint64]struct{}, len(items)) - for _, item := range items { - if item.Id == 0 { - continue - } - if _, ok := visited[item.Id]; ok { - continue - } - visited[item.Id] = struct{}{} - ids = append(ids, uint(item.Id)) - } - - if len(ids) == 0 { - return nil - } - - latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - return err - } - - for i := range items { - if items[i].Id == 0 { - continue - } - if approval, ok := latestMap[uint(items[i].Id)]; ok { - items[i].LatestApproval = approval - } else { - items[i].LatestApproval = nil - } - } - - return nil -} - -func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) { +func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) { if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { return } @@ -1052,7 +1011,7 @@ func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchas } } -func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint64, payloads []ExpenseReceivingPayload) { +func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint, payloads []ExpenseReceivingPayload) { if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { return } @@ -1061,7 +1020,7 @@ func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purcha } } -func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) { +func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) { if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 { return } @@ -1070,14 +1029,6 @@ func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchas } } -func actorIDFromContext(c *fiber.Ctx) (uint, error) { - user, ok := authmiddleware.AuthenticatedUser(c) - if !ok || user == nil || user.Id == 0 { - return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } - return user.Id, nil -} - func (s *purchaseService) buildStaffAdjustmentPayload( ctx context.Context, purchase *entity.Purchase, @@ -1088,7 +1039,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") } - requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) + requestItems := make(map[uint]validation.StaffPurchaseApprovalItem, len(req.Items)) newPayloads := make([]validation.StaffPurchaseApprovalItem, 0) for _, item := range req.Items { @@ -1111,7 +1062,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( existingCombos[key] = struct{}{} } - allowedWarehouses := make(map[uint64]struct{}, len(purchase.Items)) + allowedWarehouses := make(map[uint]struct{}, len(purchase.Items)) for _, item := range purchase.Items { allowedWarehouses[item.WarehouseId] = struct{}{} } @@ -1176,7 +1127,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") } - productSupplierCache := make(map[uint64]bool) + productSupplierCache := make(map[uint]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) for _, payload := range newPayloads { @@ -1249,6 +1200,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( }, nil } +// ? helper func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) { if quantity <= 0 { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for %s must be greater than 0", ref)) @@ -1257,7 +1209,6 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref)) } - fmt.Println(price, quantity) expectedTotal := price * quantity if provided == nil { @@ -1291,6 +1242,7 @@ func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { var fromPtr *time.Time var toPtr *time.Time + const queryDateLayout = "2006-01-02" if strings.TrimSpace(fromStr) != "" { parsed, err := time.Parse(queryDateLayout, fromStr) @@ -1317,24 +1269,86 @@ func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { return fromPtr, toPtr, nil } -func parseApprovalAction(status string) (*entity.ApprovalAction, bool, error) { - value := strings.TrimSpace(strings.ToUpper(status)) - if value == "" { - return nil, false, nil - } - - if value == "COMPLETED" { - return nil, true, nil - } - - action := entity.ApprovalAction(value) - switch action { - case entity.ApprovalActionApproved, - entity.ApprovalActionRejected, - entity.ApprovalActionCreated, - entity.ApprovalActionUpdated: - return &action, false, nil +func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { + value := strings.ToUpper(strings.TrimSpace(raw)) + switch value { + case string(entity.ApprovalActionApproved): + return entity.ApprovalActionApproved, nil + case string(entity.ApprovalActionRejected): + return entity.ApprovalActionRejected, nil default: - return nil, false, fiber.NewError(fiber.StatusBadRequest, "Invalid status filter") + return "", fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") } } + +func (s *purchaseService) rejectAndReload( + c *fiber.Ctx, + step approvalutils.ApprovalStep, + purchaseID uint, + actorID uint, + notes *string, +) (*entity.Purchase, error) { + + if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil { + return nil, err + } + + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchaseID, s.withRelations) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + if err := s.attachLatestApproval(c.Context(), updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + } + return updated, nil +} + +func (s *purchaseService) createPurchaseApproval( + ctx context.Context, + db *gorm.DB, + purchaseID uint, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, + allowDuplicate bool, +) error { + if purchaseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") + } + if actorID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "ActorId is invalid for approval") + } + + var svc commonSvc.ApprovalService + switch { + case db != nil: + svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + case s.ApprovalSvc != nil: + svc = s.ApprovalSvc + case s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil: + svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) + } + if svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Approval service not available") + } + + modifier := func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + + latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) + if err != nil { + return err + } + + if !allowDuplicate && latest != nil && + latest.Action != nil && + *latest.Action == action { + return nil + } + + actionCopy := action + _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) + return err +} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 4994a927..420b6c63 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -14,26 +14,28 @@ type CreatePurchaseRequest struct { } type StaffPurchaseApprovalItem struct { - PurchaseItemID uint64 `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` + PurchaseItemID uint `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` // For new items (no purchase_item_id), product_id is required. - ProductID uint64 `json:"product_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` - WarehouseID uint64 `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + ProductID uint `json:"product_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + WarehouseID uint `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` Qty *float64 `json:"qty,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` Price float64 `json:"price" validate:"required,gt=0"` TotalPrice float64 `json:"total_price" validate:"required,gt=0"` } type ApproveStaffPurchaseRequest struct { - Items []StaffPurchaseApprovalItem `json:"items" validate:"required,min=1,dive"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` + Items []StaffPurchaseApprovalItem `json:"items,omitempty" validate:"omitempty,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } type ApproveManagerPurchaseRequest struct { - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } type ReceivePurchaseItemRequest struct { - PurchaseItemID uint64 `json:"purchase_item_id" validate:"required,gt=0"` + PurchaseItemID uint `json:"purchase_item_id" validate:"required,gt=0"` WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"` ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"` TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"` @@ -43,21 +45,23 @@ type ReceivePurchaseItemRequest struct { } type ReceivePurchaseRequest struct { - Items []ReceivePurchaseItemRequest `json:"items" validate:"required,min=1,dive"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` + Items []ReceivePurchaseItemRequest `json:"items,omitempty" validate:"omitempty,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } type DeletePurchaseItemsRequest struct { - ItemIDs []uint64 `json:"item_ids" validate:"required,min=1,dive,gt=0"` + ItemIDs []uint `json:"item_ids" validate:"required,min=1,dive,gt=0"` } -type PurchaseQuery struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` - Search string `query:"search" validate:"omitempty,max=100"` - PrNumber string `query:"pr_number" validate:"omitempty,max=50"` - CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` - CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` - Status string `query:"status" validate:"omitempty,oneof=CREATED UPDATED APPROVED REJECTED COMPLETED"` +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` + AreaID uint `query:"area_id" validate:"omitempty,gt=0"` + LocationID uint `query:"location_id" validate:"omitempty,gt=0"` + ProductCategoryID uint `query:"product_category_id" validate:"omitempty,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` + CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 98381df6..e9d0d60d 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -243,6 +243,9 @@ const ( MarketingStepPengajuan approvalutils.ApprovalStep = 1 MarketingStepSalesOrder approvalutils.ApprovalStep = 2 MarketingDeliveryOrder approvalutils.ApprovalStep = 3 + + MarketingSoNumberPrefix = "SO-" + MarketingNumberPadding = 5 ) var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ diff --git a/internal/utils/fifo/README.md b/internal/utils/fifo/README.md new file mode 100644 index 00000000..86f2af6d --- /dev/null +++ b/internal/utils/fifo/README.md @@ -0,0 +1,67 @@ +# Mesin Stok FIFO + +Utilitas FIFO bersifat reusable dan dibagi menjadi dua lapis: + +1. **Registry (`internal/utils/fifo`)** – mendeklarasikan tabel mana yang bersifat `Stockable` (sumber stok) atau `Usable` (pemakai stok). Setiap modul cukup menyebutkan nama tabel dan kolom wajib: + - Stockable: `id`, `product_warehouse_id`, `total_qty`, `total_used_qty`, `created_at` + - Usable: `id`, `product_warehouse_id`, `usage_qty`, `pending_qty`, `created_at` +2. **Service (`internal/common/service/common.fifo.service.go`)** – memakai registry tersebut untuk: + - Menambah stok baru (`Replenish`). + - Menyinkronkan total pemakaian (`Consume`). Method ini idempotent: panggil dengan *total kuantitas yang diinginkan* (mis. saat create/update/delete). Service menghitung selisih terhadap `usage_qty + pending_qty`, kemudian otomatis mengalokasikan tambahan atau melepaskan selisihnya. + - Membatalkan pemakaian (`ReleaseUsage`) yang mengembalikan stok lalu memicu alokasi ulang ke antrian pending. + - Baik `Replenish` maupun pelepasan stok akan menjalankan `resolvePendingForWarehouse`, sehingga pending tertua langsung terisi ketika stok tersedia. + +## Registrasi tabel + +```go +import ( + commonservice "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +func init() { + fifoSvc := commonservice.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("PURCHASE_DETAIL"), + Table: "purchase_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "created_at", + }, + }) + + fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("RECORDING_STOCK"), + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }) +} +``` + +Each registration optionally accepts an order clause or base scope (e.g. to exclude drafts). + +Setiap registrasi bisa diberi klausa urutan atau scope dasar (mis. untuk mengecualikan draft). + +## Menggunakan service di modul + +1. **Saat stok masuk** (mis. purchase selesai): panggil `fifoSvc.Replenish(...)` dengan key stockable, id record, id product warehouse, dan kuantitas yang baru tersedia. Service akan: + - Menambah `total_qty` pada tabel stockable, + - Menambah `product_warehouses.quantity`, + - Mencoba membersihkan `pending_qty` dari semua usable yang terdaftar (sesuai urutan FIFO). +2. **Saat modul memakai stok** (recording, marketing, dsb.) panggil `fifoSvc.Consume(...)` dengan total qty terbaru. + - Jika qty baru lebih besar, service mengambil stok FIFO dan menambah `usage_qty`; kekurangan dicatat sebagai `pending_qty`. + - Jika qty baru lebih kecil, service otomatis menurunkan `pending_qty` lebih dulu, lalu melepaskan alokasi aktif (stok kembali ke gudang) dan langsung dipakai untuk mengisi pending milik entitas lain. + - Hapus data? panggil `Consume` dengan qty 0 atau gunakan `ReleaseUsage`. +3. **Jika dibatalkan penuh**: `fifoSvc.ReleaseUsage(...)` mengosongkan `usage_qty/pending_qty` dan menandai baris pivot sebagai `RELEASED`. + +Tabel pivot (`stock_allocations`) menyimpan asal pemakaian secara presisi, sehingga audit trail dan rollback stok menjadi deterministik. diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go new file mode 100644 index 00000000..c47d3cd7 --- /dev/null +++ b/internal/utils/fifo/constants.go @@ -0,0 +1,5 @@ +package fifo + +const ( + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" +) diff --git a/internal/utils/fifo/registry.go b/internal/utils/fifo/registry.go new file mode 100644 index 00000000..61fed294 --- /dev/null +++ b/internal/utils/fifo/registry.go @@ -0,0 +1,204 @@ +package fifo + +import ( + "errors" + "fmt" + "strings" + "sync" + + "gorm.io/gorm" +) + +// QueryScope allows callers to inject custom query modifiers (preloads, filters, etc). +type QueryScope func(*gorm.DB) *gorm.DB + +type StockableKey string +type UsableKey string + +func (k StockableKey) String() string { + return string(k) +} + +func (k UsableKey) String() string { + return string(k) +} + +// StockableColumns describes the minimum columns required for a stock-bearing row. +type StockableColumns struct { + ID string + ProductWarehouseID string + TotalQuantity string + TotalUsedQuantity string + CreatedAt string +} + +// UsableColumns describes the required columns for rows that consume stock. +type UsableColumns struct { + ID string + ProductWarehouseID string + UsageQuantity string + PendingQuantity string + CreatedAt string +} + +// StockableConfig registers a table that introduces stock into the system (purchases, transfers, etc). +type StockableConfig struct { + Key StockableKey + Table string + Columns StockableColumns + // OrderBy accepts raw column expressions, evaluated in-order (e.g. []string{"created_at ASC", "id ASC"}). + OrderBy []string + // Scope lets a module append base filters (e.g. exclude drafts). + Scope QueryScope +} + +// UsableConfig registers a table that consumes stock (recordings, adjustments, sales, etc). +type UsableConfig struct { + Key UsableKey + Table string + Columns UsableColumns + OrderBy []string + Scope QueryScope +} + +var ( + stockableRegistry = make(map[StockableKey]StockableConfig) + usableRegistry = make(map[UsableKey]UsableConfig) + registryMu sync.RWMutex +) + +// RegisterStockable stores the configuration so services can perform FIFO operations generically. +func RegisterStockable(cfg StockableConfig) error { + if err := validateStockableConfig(cfg); err != nil { + return err + } + + registryMu.Lock() + defer registryMu.Unlock() + + key := StockableKey(strings.TrimSpace(cfg.Key.String())) + if _, exists := stockableRegistry[key]; exists { + return fmt.Errorf("stockable key %q already registered", key) + } + + stockableRegistry[key] = cfg + return nil +} + +// RegisterUsable stores the configuration for stock-consuming tables. +func RegisterUsable(cfg UsableConfig) error { + if err := validateUsableConfig(cfg); err != nil { + return err + } + + registryMu.Lock() + defer registryMu.Unlock() + + key := UsableKey(strings.TrimSpace(cfg.Key.String())) + if _, exists := usableRegistry[key]; exists { + return fmt.Errorf("usable key %q already registered", key) + } + + usableRegistry[key] = cfg + return nil +} + +// Stockable returns the registered configuration for the key (if any). +func Stockable(key StockableKey) (StockableConfig, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + + cfg, ok := stockableRegistry[key] + return cfg, ok +} + +// Usable returns the registered configuration for the key (if any). +func Usable(key UsableKey) (UsableConfig, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + + cfg, ok := usableRegistry[key] + return cfg, ok +} + +// Stockables exposes a copy of the current registry (useful for iterating pending requests). +func Stockables() map[StockableKey]StockableConfig { + registryMu.RLock() + defer registryMu.RUnlock() + + if len(stockableRegistry) == 0 { + return nil + } + + result := make(map[StockableKey]StockableConfig, len(stockableRegistry)) + for key, cfg := range stockableRegistry { + result[key] = cfg + } + return result +} + +// Usables exposes a copy of the usable registry. +func Usables() map[UsableKey]UsableConfig { + registryMu.RLock() + defer registryMu.RUnlock() + + if len(usableRegistry) == 0 { + return nil + } + + result := make(map[UsableKey]UsableConfig, len(usableRegistry)) + for key, cfg := range usableRegistry { + result[key] = cfg + } + return result +} + +func validateStockableConfig(cfg StockableConfig) error { + if strings.TrimSpace(cfg.Key.String()) == "" { + return errors.New("stockable key is required") + } + if strings.TrimSpace(cfg.Table) == "" { + return fmt.Errorf("table name is required for stockable %q", cfg.Key) + } + + cols := cfg.Columns + switch { + case strings.TrimSpace(cols.ID) == "": + return fmt.Errorf("column id is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.ProductWarehouseID) == "": + return fmt.Errorf("column product warehouse id is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.TotalQuantity) == "": + return fmt.Errorf("column total quantity is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.TotalUsedQuantity) == "": + return fmt.Errorf("column total used quantity is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.CreatedAt) == "": + return fmt.Errorf("column created_at is required for stockable %q", cfg.Key) + } + + return nil +} + +func validateUsableConfig(cfg UsableConfig) error { + if strings.TrimSpace(cfg.Key.String()) == "" { + return errors.New("usable key is required") + } + if strings.TrimSpace(cfg.Table) == "" { + return fmt.Errorf("table name is required for usable %q", cfg.Key) + } + + cols := cfg.Columns + switch { + case strings.TrimSpace(cols.ID) == "": + return fmt.Errorf("column id is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.ProductWarehouseID) == "": + return fmt.Errorf("column product warehouse id is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.UsageQuantity) == "": + return fmt.Errorf("column usage quantity is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.PendingQuantity) == "": + return fmt.Errorf("column pending quantity is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.CreatedAt) == "": + return fmt.Errorf("column created_at is required for usable %q", cfg.Key) + } + + return nil +} diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go new file mode 100644 index 00000000..a845e1a2 --- /dev/null +++ b/test/integration/production/recordings/recording_fifo_integration_test.go @@ -0,0 +1,446 @@ +package test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + 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" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) { + db, svc, _, _ := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (pending) failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available") + assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request") + assertWarehouseQuantity(t, db, productWarehouse.Id, 0) + assertAllocationCount(t, db, 0) + + assertAllocationCount(t, db, 0) +} + +func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) { + db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + lot := createStockLot(t, db, productWarehouse.Id) + + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: stockableKey, + StockableID: lot.Id, + ProductWarehouseID: productWarehouse.Id, + Quantity: 12, + }); err != nil { + t.Fatalf("replenish failed: %v", err) + } + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (initial) failed: %v", err) + } + + assertWarehouseQuantity(t, db, productWarehouse.Id, 2) + + desired := 4.0 + stock.UsageQty = &desired + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (edit) failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request") + assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize") + assertWarehouseQuantity(t, db, productWarehouse.Id, 8) + + alloc := fetchSingleAllocation(t, db, stock.Id) + if alloc.Status != entity.StockAllocationStatusActive { + t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status) + } + if mathAbs(alloc.Qty-4) > 1e-6 { + t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty) + } +} + +func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) { + db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + lot := createStockLot(t, db, productWarehouse.Id) + + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: stockableKey, + StockableID: lot.Id, + ProductWarehouseID: productWarehouse.Id, + Quantity: 10, + }); err != nil { + t.Fatalf("replenish failed: %v", err) + } + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks failed: %v", err) + } + + if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("releaseRecordingStocks failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete") + assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete") + assertWarehouseQuantity(t, db, productWarehouse.Id, 10) + + alloc := fetchSingleAllocation(t, db, stock.Id) + if alloc.Status != entity.StockAllocationStatusReleased { + t.Fatalf("expected allocation to be released, got %s", alloc.Status) + } +} + +// --- helpers ---------------------------------------------------------------- + +type recordingStockTable struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + UsageQty *float64 `gorm:"column:usage_qty"` + PendingQty *float64 `gorm:"column:pending_qty"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (recordingStockTable) TableName() string { return "recording_stocks" } + +type productWarehouseTable struct { + Id uint `gorm:"primaryKey"` + ProductId uint `gorm:"column:product_id"` + WarehouseId uint `gorm:"column:warehouse_id"` + Quantity float64 `gorm:"column:quantity"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (productWarehouseTable) TableName() string { return "product_warehouses" } + +type stockAllocationTable struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"not null"` + StockableType string `gorm:"size:100"` + StockableId uint + UsableType string `gorm:"size:100"` + UsableId uint + Qty float64 `gorm:"column:qty"` + Status string `gorm:"size:20"` + Note *string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time + ReleasedAt *time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (stockAllocationTable) TableName() string { return "stock_allocations" } + +type testStockSource struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + TotalQty float64 `gorm:"column:total_qty"` + TotalUsedQty float64 `gorm:"column:total_used_qty"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time +} + +func (testStockSource) TableName() string { return "test_fifo_stockables" } + +func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.AutoMigrate( + &recordingStockTable{}, + &productWarehouseTable{}, + &stockAllocationTable{}, + &testStockSource{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + if err := db.AutoMigrate( + &entity.ProductWarehouse{}, + &entity.StockAllocation{}, + &entity.RecordingStock{}, + ); err != nil { + t.Fatalf("auto migrate entities: %v", err) + } + + stockAllocRepo := newFifoTestStockAllocationRepo(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + registerRecordingUsable(t, fifoSvc) + + key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano())) + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: key, + Table: "test_fifo_stockables", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "created_at", + }, + }); err != nil { + t.Fatalf("register stockable: %v", err) + } + + svc := servicePkg.NewRecordingFIFOIntegrationService( + recordingRepo.NewRecordingRepository(db), + productWarehouseRepo, + fifoSvc, + ) + + return db, svc, fifoSvc, key +} + +func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) { + t.Helper() + err := fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyRecordingStock, + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }) + if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("register usable: %v", err) + } + if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok { + t.Fatal("recording stock usable key not registered") + } +} + +func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { + t.Helper() + pw := entity.ProductWarehouse{ + ProductId: 1, + WarehouseId: 1, + Quantity: qty, + CreatedBy: 1, + } + if err := db.Create(&pw).Error; err != nil { + t.Fatalf("create product warehouse: %v", err) + } + return pw +} + +func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock { + t.Helper() + stock := entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: productWarehouseID, + UsageQty: floatPtr(0), + PendingQty: floatPtr(0), + } + if err := db.Create(&stock).Error; err != nil { + t.Fatalf("create recording stock: %v", err) + } + stock.UsageQty = floatPtr(desired) + return stock +} + +func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource { + t.Helper() + lot := testStockSource{ + ProductWarehouseId: productWarehouseID, + CreatedAt: time.Now(), + } + if err := db.Create(&lot).Error; err != nil { + t.Fatalf("create stock lot: %v", err) + } + return lot +} + +func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock { + t.Helper() + var stock entity.RecordingStock + if err := db.First(&stock, id).Error; err != nil { + t.Fatalf("fetch recording stock: %v", err) + } + return stock +} + +func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation { + t.Helper() + var alloc entity.StockAllocation + if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil { + t.Fatalf("fetch allocation: %v", err) + } + return alloc +} + +func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) { + t.Helper() + var count int64 + if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { + t.Fatalf("count allocations: %v", err) + } + if count != expected { + t.Fatalf("expected %d allocations, got %d", expected, count) + } +} + +func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) { + t.Helper() + var pw entity.ProductWarehouse + if err := db.First(&pw, id).Error; err != nil { + t.Fatalf("fetch product warehouse: %v", err) + } + if mathAbs(pw.Quantity-expected) > 1e-6 { + t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity) + } +} + +func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) { + t.Helper() + if value == nil { + t.Fatalf("expected %s %.3f, got nil", msg, expected) + } + if mathAbs(*value-expected) > 1e-6 { + t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value) + } +} + +func floatPtr(v float64) *float64 { + p := new(float64) + *p = v + return p +} + +func mathAbs(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func sanitizeKey(name string) string { + if name == "" { + return "CASE" + } + clean := strings.Map(func(r rune) rune { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + if r >= 'a' && r <= 'z' { + return r - 32 + } + return '_' + }, name) + return clean +} + +type fifoTestStockAllocationRepo struct { + commonRepo.StockAllocationRepository + db *gorm.DB +} + +func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository { + return &fifoTestStockAllocationRepo{ + StockAllocationRepository: commonRepo.NewStockAllocationRepository(db), + db: db, + } +} + +func (r *fifoTestStockAllocationRepo) PatchOne( + ctx context.Context, + id uint, + updates map[string]any, + modifier func(*gorm.DB) *gorm.DB, +) error { + base := r.db + + setClauses := make([]string, 0, len(updates)) + args := make([]any, 0, len(updates)+1) + for column, value := range updates { + colName := column + if strings.EqualFold(column, "quantity") { + colName = "qty" + } + setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName)) + args = append(args, value) + } + args = append(args, id) + sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", ")) + + result := base.Exec(sql, args...) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *fifoTestStockAllocationRepo) ReleaseByUsable( + ctx context.Context, + usableType string, + usableID uint, + note *string, + modifier func(*gorm.DB) *gorm.DB, +) error { + base := r.db + + setClause := "status = ?, released_at = ?" + args := []any{entity.StockAllocationStatusReleased, time.Now()} + if note != nil { + setClause += ", note = ?" + args = append(args, *note) + } + args = append(args, usableType, usableID, entity.StockAllocationStatusActive) + sql := fmt.Sprintf( + "UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?", + setClause, + ) + + result := base.Exec(sql, args...) + return result.Error +}