mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Merge branch 'dev/ragil' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi
This commit is contained in:
+27
-6
@@ -6,24 +6,45 @@ deploy-dev:
|
|||||||
image: alpine:3.20
|
image: alpine:3.20
|
||||||
variables:
|
variables:
|
||||||
DEPLOY_APP: "LTI-MBUGROUP"
|
DEPLOY_APP: "LTI-MBUGROUP"
|
||||||
|
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
|
||||||
|
GIT_SUBMODULE_STRATEGY: recursive
|
||||||
|
GIT_DEPTH: "1"
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- echo "🧰 Installing dependencies..."
|
- echo "🧰 Installing dependencies..."
|
||||||
- apk update && apk add --no-cache openssh git curl
|
- apk update && apk add --no-cache openssh git curl bash
|
||||||
|
|
||||||
|
# Setup SSH di runner
|
||||||
- mkdir -p ~/.ssh
|
- mkdir -p ~/.ssh
|
||||||
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
|
||||||
- chmod 600 ~/.ssh/id_rsa
|
- chmod 600 ~/.ssh/id_rsa
|
||||||
- eval $(ssh-agent -s)
|
- eval "$(ssh-agent -s)"
|
||||||
- ssh-add ~/.ssh/id_rsa
|
- ssh-add ~/.ssh/id_rsa
|
||||||
|
|
||||||
|
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
|
||||||
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
|
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
|
||||||
|
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
script:
|
script:
|
||||||
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
|
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
|
||||||
|
|
||||||
- >
|
- >
|
||||||
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
|
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
|
||||||
cd /home/devops/docker/deployment/development/lti-api &&
|
set -e
|
||||||
git fetch origin development &&
|
|
||||||
git reset --hard origin/development &&
|
cd /home/devops/docker/deployment/development/lti-api
|
||||||
|
|
||||||
|
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
|
||||||
|
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
|
||||||
|
|
||||||
|
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# Fetch/reset pakai SSH
|
||||||
|
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
|
||||||
|
git reset --hard origin/development
|
||||||
|
|
||||||
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
|
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
|
||||||
"; then
|
"; then
|
||||||
STATUS='success';
|
STATUS='success';
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
CREATE TABLE expenses (
|
CREATE TABLE expenses (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
reference_number VARCHAR(50) UNIQUE NOT NULL,
|
reference_number VARCHAR(50) UNIQUE NOT NULL,
|
||||||
supplier_id BIGINT NULL,
|
supplier_id BIGINT NOT NULL,
|
||||||
category VARCHAR(50) NOT NULL CHECK (
|
category VARCHAR(50) NOT NULL CHECK (
|
||||||
category IN ('BOP', 'NON-BOP')
|
category IN ('BOP', 'NON-BOP')
|
||||||
),
|
),
|
||||||
|
|||||||
+44
@@ -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();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP Table IF EXISTS project_budgets;
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -13,18 +13,16 @@ type Expense struct {
|
|||||||
SupplierId uint64 `gorm:""`
|
SupplierId uint64 `gorm:""`
|
||||||
Category string `gorm:"type:varchar(50);not null"`
|
Category string `gorm:"type:varchar(50);not null"`
|
||||||
PoNumber string `gorm:"type:varchar(50)"`
|
PoNumber string `gorm:"type:varchar(50)"`
|
||||||
DocumentPath sql.NullString `gorm:"type:json"` // Dokumen pengajuan
|
DocumentPath sql.NullString `gorm:"type:json"`
|
||||||
RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` // Dokumen realisasi
|
RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"`
|
||||||
RealizationDate time.Time `gorm:"type:date;column:realization_date"` // Tanggal realisasi
|
RealizationDate time.Time `gorm:"type:date;column:realization_date"`
|
||||||
ExpenseDate time.Time `gorm:"type:date;not null"`
|
TransactionDate time.Time `gorm:"type:date;not null"`
|
||||||
GrandTotal float64 `gorm:"type:numeric(15,3);default:0"`
|
Notes string `gorm:"type:text;column:notes"`
|
||||||
Note string `gorm:"type:text"`
|
|
||||||
CreatedBy uint64 `gorm:""`
|
CreatedBy uint64 `gorm:""`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
// Relations
|
|
||||||
Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"`
|
Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"`
|
||||||
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||||
Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"`
|
Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"`
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
package entities
|
package entities
|
||||||
|
|
||||||
type ExpenseNonstock struct {
|
import (
|
||||||
Id uint64 `gorm:"primaryKey;autoIncrement"`
|
"time"
|
||||||
ExpenseId *uint64 `gorm:""`
|
)
|
||||||
ProjectFlockKandangId *uint64 `gorm:""`
|
|
||||||
KandangId *uint64 `gorm:""`
|
type ExpenseNonstock struct {
|
||||||
NonstockId *uint64 `gorm:""`
|
Id uint64 `gorm:"primaryKey;autoIncrement"`
|
||||||
Qty float64 `gorm:"type:numeric(15,3);not null"`
|
ExpenseId *uint64 `gorm:""`
|
||||||
UnitPrice float64 `gorm:"type:numeric(15,3);not null"`
|
ProjectFlockKandangId *uint64 `gorm:""`
|
||||||
TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
|
KandangId *uint64 `gorm:""`
|
||||||
Note string `gorm:"type:text"`
|
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"`
|
Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"`
|
||||||
ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
|
ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
|
||||||
Kandang *Kandang `gorm:"foreignKey:KandangId;references:Id"`
|
Kandang *Kandang `gorm:"foreignKey:KandangId;references:Id"`
|
||||||
Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"`
|
Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"`
|
||||||
Realization *ExpenseRealization `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
|
Realization *ExpenseRealization `gorm:"foreignKey:Id;references:ExpenseNonstockId"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ExpenseRealization struct {
|
type ExpenseRealization struct {
|
||||||
Id uint64 `gorm:"primaryKey;autoIncrement"`
|
Id uint64 `gorm:"primaryKey;autoIncrement"`
|
||||||
ExpenseNonstockId *uint64 `gorm:""`
|
ExpenseNonstockId *uint64 `gorm:""`
|
||||||
RealizationQty float64 `gorm:"type:numeric(15,3);not null"`
|
Qty float64 `gorm:"type:numeric(15,3);not null;"`
|
||||||
RealizationUnitPrice float64 `gorm:"type:numeric(15,3);not null"`
|
Price float64 `gorm:"type:numeric(15,3);not null;"`
|
||||||
RealizationTotalPrice float64 `gorm:"type:numeric(15,3);not null"`
|
Notes string `gorm:"type:text;"`
|
||||||
RealizationDate time.Time `gorm:"type:date;not null"`
|
CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"`
|
||||||
Note *string `gorm:"type:text"`
|
|
||||||
CreatedBy *uint64 `gorm:""`
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
|
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
|
||||||
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
|
||||||
|
service "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
|
||||||
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/response"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClosingController struct {
|
||||||
|
ClosingService service.ClosingService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClosingController(closingService service.ClosingService) *ClosingController {
|
||||||
|
return &ClosingController{
|
||||||
|
ClosingService: closingService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ClosingController) GetAll(c *fiber.Ctx) error {
|
||||||
|
query := &validation.Query{
|
||||||
|
Page: c.QueryInt("page", 1),
|
||||||
|
Limit: c.QueryInt("limit", 10),
|
||||||
|
Search: c.Query("search", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, totalResults, err := u.ClosingService.GetAll(c, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).
|
||||||
|
JSON(response.SuccessWithPaginate[dto.ClosingListDTO]{
|
||||||
|
Code: fiber.StatusOK,
|
||||||
|
Status: "success",
|
||||||
|
Message: "Get all closings successfully",
|
||||||
|
Meta: response.Meta{
|
||||||
|
Page: query.Page,
|
||||||
|
Limit: query.Limit,
|
||||||
|
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
|
||||||
|
TotalResults: totalResults,
|
||||||
|
},
|
||||||
|
Data: dto.ToClosingListDTOs(result),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ClosingController) GetOne(c *fiber.Ctx) error {
|
||||||
|
param := c.Params("id")
|
||||||
|
|
||||||
|
id, err := strconv.Atoi(param)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := u.ClosingService.GetOne(c, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).
|
||||||
|
JSON(response.Success{
|
||||||
|
Code: fiber.StatusOK,
|
||||||
|
Status: "success",
|
||||||
|
Message: "Get closing successfully",
|
||||||
|
Data: dto.ToClosingListDTO(*result),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// === DTO Structs ===
|
||||||
|
|
||||||
|
type ClosingRelationDTO struct {
|
||||||
|
Id uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClosingListDTO struct {
|
||||||
|
Id uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClosingDetailDTO struct {
|
||||||
|
ClosingListDTO
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Mapper Functions ===
|
||||||
|
|
||||||
|
func ToClosingRelationDTO(e entity.ProjectFlock) ClosingRelationDTO {
|
||||||
|
return ClosingRelationDTO{
|
||||||
|
Id: e.Id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToClosingListDTO(e entity.ProjectFlock) ClosingListDTO {
|
||||||
|
var createdUser *userDTO.UserRelationDTO
|
||||||
|
if e.CreatedUser.Id != 0 {
|
||||||
|
mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
|
||||||
|
createdUser = &mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClosingListDTO{
|
||||||
|
Id: e.Id,
|
||||||
|
CreatedAt: e.CreatedAt,
|
||||||
|
UpdatedAt: e.UpdatedAt,
|
||||||
|
CreatedUser: createdUser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToClosingListDTOs(e []entity.ProjectFlock) []ClosingListDTO {
|
||||||
|
result := make([]ClosingListDTO, len(e))
|
||||||
|
for i, r := range e {
|
||||||
|
result[i] = ToClosingListDTO(r)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToClosingDetailDTO(e entity.ProjectFlock) ClosingDetailDTO {
|
||||||
|
return ClosingDetailDTO{
|
||||||
|
ClosingListDTO: ToClosingListDTO(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package closings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
|
||||||
|
sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
|
||||||
|
|
||||||
|
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||||
|
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClosingModule struct{}
|
||||||
|
|
||||||
|
func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
|
||||||
|
closingRepo := rClosing.NewClosingRepository(db)
|
||||||
|
userRepo := rUser.NewUserRepository(db)
|
||||||
|
|
||||||
|
closingService := sClosing.NewClosingService(closingRepo, validate)
|
||||||
|
userService := sUser.NewUserService(userRepo, validate)
|
||||||
|
|
||||||
|
ClosingRoutes(router, userService, closingService)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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 ClosingRepository interface {
|
||||||
|
repository.BaseRepository[entity.ProjectFlock]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClosingRepositoryImpl struct {
|
||||||
|
*repository.BaseRepositoryImpl[entity.ProjectFlock]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClosingRepository(db *gorm.DB) ClosingRepository {
|
||||||
|
return &ClosingRepositoryImpl{
|
||||||
|
BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlock](db),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package closings
|
||||||
|
|
||||||
|
import (
|
||||||
|
// m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||||
|
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/controllers"
|
||||||
|
closing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
|
||||||
|
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService) {
|
||||||
|
ctrl := controller.NewClosingController(s)
|
||||||
|
|
||||||
|
route := v1.Group("/closings")
|
||||||
|
|
||||||
|
// route.Get("/", m.Auth(u), ctrl.GetAll)
|
||||||
|
// route.Post("/", m.Auth(u), ctrl.CreateOne)
|
||||||
|
// route.Get("/:id", m.Auth(u), ctrl.GetOne)
|
||||||
|
// route.Patch("/:id", m.Auth(u), ctrl.UpdateOne)
|
||||||
|
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
|
||||||
|
|
||||||
|
route.Get("/", ctrl.GetAll)
|
||||||
|
route.Get("/:id", ctrl.GetOne)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
|
||||||
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClosingService interface {
|
||||||
|
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error)
|
||||||
|
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type closingService struct {
|
||||||
|
Log *logrus.Logger
|
||||||
|
Validate *validator.Validate
|
||||||
|
Repository repository.ClosingRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClosingService(repo repository.ClosingRepository, validate *validator.Validate) ClosingService {
|
||||||
|
return &closingService{
|
||||||
|
Log: utils.Log,
|
||||||
|
Validate: validate,
|
||||||
|
Repository: repo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s closingService) withRelations(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Preload("CreatedUser")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) {
|
||||||
|
if err := s.Validate.Struct(params); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (params.Page - 1) * params.Limit
|
||||||
|
|
||||||
|
closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
||||||
|
db = s.withRelations(db)
|
||||||
|
if params.Search != "" {
|
||||||
|
return db.Where("name LIKE ?", "%"+params.Search+"%")
|
||||||
|
}
|
||||||
|
return db.Order("created_at DESC").Order("updated_at DESC")
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.Log.Errorf("Failed to get closings: %+v", err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return closings, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s closingService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
|
||||||
|
closing, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fiber.NewError(fiber.StatusNotFound, "Closing not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.Log.Errorf("Failed get closing by id: %+v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return closing, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
type Create struct {
|
||||||
|
Name string `json:"name" validate:"required_strict,min=3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Update struct {
|
||||||
|
Name *string `json:"name,omitempty" validate:"omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query struct {
|
||||||
|
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||||
|
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||||
|
Search string `query:"search" validate:"omitempty,max=50"`
|
||||||
|
}
|
||||||
@@ -96,30 +96,30 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
req.Documents = form.File["documents"]
|
req.Documents = form.File["documents"]
|
||||||
|
|
||||||
costPerKandangJSON := c.FormValue("cost_per_kandangs")
|
expenseNonstocksJSON := c.FormValue("expense_nonstocks")
|
||||||
if costPerKandangJSON != "" {
|
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
|
var singleExpenseNonstock validation.ExpenseNonstock
|
||||||
if err := json.Unmarshal([]byte(costPerKandangJSON), &singleCostPerKandang); err != nil {
|
if err := json.Unmarshal([]byte(expenseNonstocksJSON), &singleExpenseNonstock); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandangs JSON: %v", err))
|
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")
|
return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
req.CostPerKandangs = []validation.CostPerKandang{singleCostPerKandang}
|
req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock}
|
||||||
} else {
|
} else {
|
||||||
for i, costPerKandang := range req.CostPerKandangs {
|
for i, expenseNonstock := range req.ExpenseNonstocks {
|
||||||
if costPerKandang.KandangID == 0 {
|
if expenseNonstock.KandangID == 0 {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for cost_per_kandangs[%d]", i))
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
result, err := u.ExpenseService.CreateOne(c, req)
|
||||||
@@ -155,20 +155,32 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
|
|||||||
req.TransactionDate = &transactionDate
|
req.TransactionDate = &transactionDate
|
||||||
}
|
}
|
||||||
|
|
||||||
costPerKandangJSON := c.FormValue("cost_per_kandang")
|
categoryVal := c.FormValue("category")
|
||||||
if costPerKandangJSON != "" {
|
req.Category = &categoryVal
|
||||||
var costPerKandang []validation.CostPerKandang
|
|
||||||
if err := json.Unmarshal([]byte(costPerKandangJSON), &costPerKandang); err != nil {
|
supplierIDVal := c.FormValue("supplier_id")
|
||||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandang JSON: %v", err))
|
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 {
|
for i, expenseNonstock := range expenseNonstocks {
|
||||||
if costPerKandang.KandangID == 0 {
|
if expenseNonstock.KandangID == 0 {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for cost_per_kandang[%d]", i))
|
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))
|
result, err := u.ExpenseService.UpdateOne(c, req, uint(id))
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ import (
|
|||||||
// === DTO Structs ===
|
// === DTO Structs ===
|
||||||
|
|
||||||
type ExpenseRelationDTO struct {
|
type ExpenseRelationDTO struct {
|
||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
PoNumber string `json:"po_number"`
|
PoNumber string `json:"po_number"`
|
||||||
ExpenseDate time.Time `json:"expense_date"`
|
TransactionDate time.Time `json:"transaction_date"`
|
||||||
GrandTotal float64 `json:"grand_total"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExpenseBaseDTO struct {
|
type ExpenseBaseDTO struct {
|
||||||
@@ -28,8 +27,7 @@ type ExpenseBaseDTO struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"`
|
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"`
|
||||||
RealizationDate *time.Time `json:"realization_date,omitempty"`
|
RealizationDate *time.Time `json:"realization_date,omitempty"`
|
||||||
ExpenseDate time.Time `json:"expense_date"`
|
TransactionDate time.Time `json:"transaction_date"`
|
||||||
GrandTotal float64 `json:"grand_total"`
|
|
||||||
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
|
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,21 +53,26 @@ type ExpenseDetailDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExpenseNonstockDTO struct {
|
type ExpenseNonstockDTO struct {
|
||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
Qty float64 `json:"qty"`
|
ExpenseId *uint64 `json:"expense_id,omitempty"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
ProjectFlockKandangId *uint64 `json:"project_flock_kandang_id,omitempty"`
|
||||||
TotalPrice float64 `json:"total_price"`
|
KandangId *uint64 `json:"kandang_id,omitempty"`
|
||||||
Note *string `json:"note,omitempty"`
|
NonstockId *uint64 `json:"nonstock_id,omitempty"`
|
||||||
Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,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 {
|
type ExpenseRealizationDTO struct {
|
||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
Qty float64 `json:"qty"`
|
ExpenseNonstockId *uint64 `json:"expense_nonstock_id,omitempty"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
Qty float64 `json:"qty"`
|
||||||
TotalPrice float64 `json:"total_price"`
|
Price float64 `json:"price"`
|
||||||
Note *string `json:"note,omitempty"`
|
Notes string `json:"notes"`
|
||||||
Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"`
|
Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KandangGroupDTO struct {
|
type KandangGroupDTO struct {
|
||||||
@@ -89,10 +92,9 @@ type DocumentDTO struct {
|
|||||||
|
|
||||||
func ToExpenseRelationDTO(e entity.Expense) ExpenseRelationDTO {
|
func ToExpenseRelationDTO(e entity.Expense) ExpenseRelationDTO {
|
||||||
return ExpenseRelationDTO{
|
return ExpenseRelationDTO{
|
||||||
Id: e.Id,
|
Id: e.Id,
|
||||||
PoNumber: e.PoNumber,
|
PoNumber: e.PoNumber,
|
||||||
ExpenseDate: e.ExpenseDate,
|
TransactionDate: e.TransactionDate,
|
||||||
GrandTotal: e.GrandTotal,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +126,7 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO {
|
|||||||
Category: e.Category,
|
Category: e.Category,
|
||||||
Supplier: supplier,
|
Supplier: supplier,
|
||||||
RealizationDate: realizationDate,
|
RealizationDate: realizationDate,
|
||||||
ExpenseDate: e.ExpenseDate,
|
TransactionDate: e.TransactionDate,
|
||||||
GrandTotal: e.GrandTotal,
|
|
||||||
Location: location,
|
Location: location,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,10 +193,9 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
|
|||||||
|
|
||||||
for _, ns := range e.Nonstocks {
|
for _, ns := range e.Nonstocks {
|
||||||
pengajuanDTO := ToExpenseNonstockDTO(ns)
|
pengajuanDTO := ToExpenseNonstockDTO(ns)
|
||||||
|
|
||||||
pengajuans = append(pengajuans, pengajuanDTO)
|
pengajuans = append(pengajuans, pengajuanDTO)
|
||||||
|
|
||||||
if ns.Realization != nil && ns.Realization.Id != 0 {
|
if ns.Realization != nil {
|
||||||
var nonstock *nonstockDTO.NonstockRelationDTO
|
var nonstock *nonstockDTO.NonstockRelationDTO
|
||||||
if ns.Nonstock != nil && ns.Nonstock.Id != 0 {
|
if ns.Nonstock != nil && ns.Nonstock.Id != 0 {
|
||||||
mapped := nonstockDTO.ToNonstockRelationDTO(*ns.Nonstock)
|
mapped := nonstockDTO.ToNonstockRelationDTO(*ns.Nonstock)
|
||||||
@@ -203,12 +203,13 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
realisasiDTO := ExpenseRealizationDTO{
|
realisasiDTO := ExpenseRealizationDTO{
|
||||||
Id: ns.Realization.Id,
|
Id: ns.Realization.Id,
|
||||||
Qty: ns.Realization.RealizationQty,
|
ExpenseNonstockId: ns.Realization.ExpenseNonstockId,
|
||||||
UnitPrice: ns.Realization.RealizationUnitPrice,
|
Qty: ns.Realization.Qty,
|
||||||
TotalPrice: ns.Realization.RealizationTotalPrice,
|
Price: ns.Realization.Price,
|
||||||
Note: ns.Realization.Note,
|
Notes: ns.Realization.Notes,
|
||||||
Nonstock: nonstock,
|
Nonstock: nonstock,
|
||||||
|
CreatedAt: ns.Realization.CreatedAt,
|
||||||
}
|
}
|
||||||
realisasi = append(realisasi, realisasiDTO)
|
realisasi = append(realisasi, realisasiDTO)
|
||||||
}
|
}
|
||||||
@@ -217,12 +218,12 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
|
|||||||
|
|
||||||
var totalPengajuan float64
|
var totalPengajuan float64
|
||||||
for _, p := range pengajuans {
|
for _, p := range pengajuans {
|
||||||
totalPengajuan += p.TotalPrice
|
totalPengajuan += p.Qty * p.Price
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalRealisasi float64
|
var totalRealisasi float64
|
||||||
for _, r := range realisasi {
|
for _, r := range realisasi {
|
||||||
totalRealisasi += r.TotalPrice
|
totalRealisasi += r.Qty * r.Price
|
||||||
}
|
}
|
||||||
kandangs := ToKandangGroupDTO(pengajuans, realisasi, e.Nonstocks)
|
kandangs := ToKandangGroupDTO(pengajuans, realisasi, e.Nonstocks)
|
||||||
|
|
||||||
@@ -248,12 +249,16 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ExpenseNonstockDTO{
|
return ExpenseNonstockDTO{
|
||||||
Id: ns.Id,
|
Id: ns.Id,
|
||||||
Qty: ns.Qty,
|
ExpenseId: ns.ExpenseId,
|
||||||
UnitPrice: ns.UnitPrice,
|
ProjectFlockKandangId: ns.ProjectFlockKandangId,
|
||||||
TotalPrice: ns.TotalPrice,
|
KandangId: ns.KandangId,
|
||||||
Note: &ns.Note,
|
NonstockId: ns.NonstockId,
|
||||||
Nonstock: nonstock,
|
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 kandangId uint64
|
||||||
var kandangName string
|
var kandangName string
|
||||||
|
|
||||||
for _, ns := range nonstocks {
|
if p.KandangId != nil {
|
||||||
if ns.Id == p.Id && ns.Kandang != nil {
|
kandangId = *p.KandangId
|
||||||
kandangId = uint64(ns.Kandang.Id)
|
for _, ns := range nonstocks {
|
||||||
kandangName = ns.Kandang.Name
|
if ns.Id == p.Id && ns.Kandang != nil {
|
||||||
break
|
kandangName = ns.Kandang.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
type ExpenseRepository interface {
|
type ExpenseRepository interface {
|
||||||
repository.BaseRepository[entity.Expense]
|
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)
|
GetNextSequence(ctx context.Context) (int, error)
|
||||||
GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error)
|
GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error)
|
||||||
WithProjectFlockKandangFilter(pfkID uint) func(*gorm.DB) *gorm.DB
|
WithProjectFlockKandangFilter(pfkID uint) func(*gorm.DB) *gorm.DB
|
||||||
@@ -29,8 +29,8 @@ func NewExpenseRepository(db *gorm.DB) ExpenseRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExpenseRepositoryImpl) IdExists(ctx context.Context, id uint64) (bool, error) {
|
func (r *ExpenseRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) {
|
||||||
return repository.Exists[entity.Expense](ctx, r.DB(), uint(id))
|
return repository.Exists[entity.Expense](ctx, r.DB(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExpenseRepositoryImpl) GetNextSequence(ctx context.Context) (int, error) {
|
func (r *ExpenseRepositoryImpl) GetNextSequence(ctx context.Context) (int, error) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"time"
|
|
||||||
|
|
||||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, costPerKandang := range req.CostPerKandangs {
|
for _, expenseNonstock := range req.ExpenseNonstocks {
|
||||||
for _, costItem := range costPerKandang.CostItems {
|
for _, costItem := range expenseNonstock.CostItems {
|
||||||
nonstockId := uint(costItem.NonstockID)
|
nonstockId := uint(costItem.NonstockID)
|
||||||
|
|
||||||
if err := commonSvc.EnsureRelations(c.Context(),
|
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")
|
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
|
createdBy := uint64(1) //todo get from auth
|
||||||
expense = &entity.Expense{
|
expense = &entity.Expense{
|
||||||
ReferenceNumber: referenceNumber,
|
ReferenceNumber: referenceNumber,
|
||||||
PoNumber: req.PoNumber,
|
PoNumber: req.PoNumber,
|
||||||
Category: req.Category,
|
Category: req.Category,
|
||||||
SupplierId: req.SupplierID,
|
SupplierId: req.SupplierID,
|
||||||
ExpenseDate: expenseDate,
|
TransactionDate: expenseDate,
|
||||||
GrandTotal: grandTotal,
|
|
||||||
CreatedBy: createdBy,
|
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")
|
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
|
var projectFlockKandangId *uint64
|
||||||
|
|
||||||
if req.Category == "BOP" {
|
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 err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
|
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
|
projectFlockKandangId = &id
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, costItem := range costPerKandang.CostItems {
|
for _, costItem := range expenseNonstock.CostItems {
|
||||||
|
|
||||||
nonstockId := costItem.NonstockID
|
nonstockId := costItem.NonstockID
|
||||||
var kandangId *uint64
|
var kandangId *uint64
|
||||||
if req.Category == "NON-BOP" {
|
if req.Category == "NON-BOP" {
|
||||||
id := uint64(costPerKandang.KandangID)
|
id := uint64(expenseNonstock.KandangID)
|
||||||
kandangId = &id
|
kandangId = &id
|
||||||
} else if req.Category == "BOP" {
|
} else if req.Category == "BOP" {
|
||||||
if projectFlockKandangId != nil {
|
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,
|
KandangId: kandangId,
|
||||||
NonstockId: &nonstockId,
|
NonstockId: &nonstockId,
|
||||||
Qty: costItem.Quantity,
|
Qty: costItem.Quantity,
|
||||||
TotalPrice: costItem.TotalCost,
|
Price: costItem.Price,
|
||||||
Note: costItem.Notes,
|
Notes: costItem.Notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil {
|
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(),
|
if err := commonSvc.EnsureRelations(c.Context(),
|
||||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) {
|
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
|
||||||
return s.Repository.IdExists(ctx, uint64(id))
|
|
||||||
}},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -328,10 +317,27 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
|
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)
|
responseDTO, err := s.GetOne(c, id)
|
||||||
if err != nil {
|
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))
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||||
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(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 len(updateBody) > 0 {
|
||||||
if err := expenseRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil {
|
if err := expenseRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
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 {
|
var existingExpenseNonstocks []entity.ExpenseNonstock
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items")
|
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 _, ens := range existingExpenseNonstocks {
|
||||||
for _, cpk := range *req.CostPerKandang {
|
if err := expenseNonstockRepoTx.DeleteOne(c.Context(), uint(ens.Id)); err != nil {
|
||||||
for _, costItem := range cpk.CostItems {
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete expense nonstock")
|
||||||
grandTotal += costItem.TotalCost
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{
|
updatedExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil)
|
||||||
"grand_total": grandTotal,
|
if err != nil {
|
||||||
}, nil); err != nil {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense grand total")
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get updated expense")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cpk := range *req.CostPerKandang {
|
for _, expenseNonstock := range *req.ExpenseNonstocks {
|
||||||
var projectFlockKandangId *uint64
|
var projectFlockKandangId *uint64
|
||||||
|
|
||||||
expense, err := expenseRepoTx.GetByID(c.Context(), id, nil)
|
if updatedExpense.Category == "BOP" {
|
||||||
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" {
|
|
||||||
|
|
||||||
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
|
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 err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
|
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
|
projectFlockKandangId = &id
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, costItem := range cpk.CostItems {
|
for _, costItem := range expenseNonstock.CostItems {
|
||||||
|
|
||||||
nonstockId := uint(costItem.NonstockID)
|
nonstockId := uint(costItem.NonstockID)
|
||||||
if err := commonSvc.EnsureRelations(c.Context(),
|
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
|
var kandangId *uint64
|
||||||
if expense.Category == "NON-BOP" {
|
if updatedExpense.Category == "NON-BOP" {
|
||||||
id := uint64(cpk.KandangID)
|
id := uint64(expenseNonstock.KandangID)
|
||||||
kandangId = &id
|
kandangId = &id
|
||||||
} else if expense.Category == "BOP" {
|
} else if updatedExpense.Category == "BOP" {
|
||||||
|
|
||||||
if projectFlockKandangId != nil {
|
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,
|
KandangId: kandangId,
|
||||||
NonstockId: &costItem.NonstockID,
|
NonstockId: &costItem.NonstockID,
|
||||||
Qty: costItem.Quantity,
|
Qty: costItem.Quantity,
|
||||||
TotalPrice: costItem.TotalCost,
|
Price: costItem.Price,
|
||||||
Note: costItem.Notes,
|
Notes: costItem.Notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil {
|
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 {
|
func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||||
|
|
||||||
if err := commonSvc.EnsureRelations(c.Context(),
|
if err := commonSvc.EnsureRelations(c.Context(),
|
||||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) {
|
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
|
||||||
return s.Repository.IdExists(ctx, uint64(id))
|
|
||||||
}},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -506,9 +562,7 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := commonSvc.EnsureRelations(c.Context(),
|
if err := commonSvc.EnsureRelations(c.Context(),
|
||||||
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) {
|
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
|
||||||
return s.Repository.IdExists(ctx, uint64(id))
|
|
||||||
}},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
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")
|
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 {
|
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
|
|
||||||
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||||
@@ -543,13 +595,14 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
|||||||
}
|
}
|
||||||
|
|
||||||
realization := &entity.ExpenseRealization{
|
realization := &entity.ExpenseRealization{
|
||||||
ExpenseNonstockId: &expenseNonstockID,
|
ExpenseNonstockId: &expenseNonstockID,
|
||||||
RealizationQty: realizationItem.Qty,
|
Qty: realizationItem.Qty,
|
||||||
RealizationUnitPrice: realizationItem.UnitPrice,
|
Price: realizationItem.Price,
|
||||||
RealizationTotalPrice: realizationItem.TotalPrice,
|
Notes: "",
|
||||||
RealizationDate: realizationDate,
|
}
|
||||||
Note: realizationItem.Notes,
|
|
||||||
CreatedBy: &createdBy,
|
if realizationItem.Notes != nil {
|
||||||
|
realization.Notes = *realizationItem.Notes
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := realizationRepoTx.CreateOne(c.Context(), realization, nil); err != nil {
|
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,
|
expenseID,
|
||||||
utils.ExpenseStepRealisasi,
|
utils.ExpenseStepRealisasi,
|
||||||
&approvalAction,
|
&approvalAction,
|
||||||
uint(createdBy),
|
uint(1), // TODO: replace with authenticated user id
|
||||||
nil); err != nil {
|
nil); err != nil {
|
||||||
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
|
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) {
|
func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) {
|
||||||
|
|
||||||
if err := commonSvc.EnsureRelations(c.Context(),
|
if err := commonSvc.EnsureRelations(c.Context(),
|
||||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) {
|
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
|
||||||
return s.Repository.IdExists(ctx, uint64(id))
|
|
||||||
}},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) {
|
||||||
if err := s.Validate.Struct(req); err != nil {
|
if err := s.Validate.Struct(req); err != nil {
|
||||||
s.Log.Errorf("Validation failed for UpdateRealization: %+v", err)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := commonSvc.EnsureRelations(c.Context(),
|
if err := commonSvc.EnsureRelations(c.Context(),
|
||||||
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) {
|
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
|
||||||
return s.Repository.IdExists(ctx, uint64(id))
|
|
||||||
}},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
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")
|
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)]
|
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest,
|
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
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
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 {
|
|
||||||
|
|
||||||
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||||
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
||||||
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
||||||
expenseRepoTx := repository.NewExpenseRepository(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 {
|
expenseNonstockID := realizationItem.ExpenseNonstockID
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID))
|
if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
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")
|
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 {
|
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
|
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)
|
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 {
|
func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error {
|
||||||
|
|
||||||
if err := commonSvc.EnsureRelations(ctx.Context(),
|
if err := commonSvc.EnsureRelations(ctx.Context(),
|
||||||
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) {
|
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
|
||||||
return s.Repository.IdExists(ctx, uint64(id))
|
|
||||||
}},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -909,9 +965,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
|
|||||||
|
|
||||||
for _, id := range req.ApprovableIds {
|
for _, id := range req.ApprovableIds {
|
||||||
if err := commonSvc.EnsureRelations(c.Context(),
|
if err := commonSvc.EnsureRelations(c.Context(),
|
||||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) {
|
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
|
||||||
return s.Repository.IdExists(ctx, uint64(id))
|
|
||||||
}},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Create struct {
|
type Create struct {
|
||||||
PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"`
|
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"`
|
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"`
|
Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"`
|
||||||
SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"`
|
SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"`
|
||||||
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
|
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"`
|
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"`
|
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"`
|
CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"`
|
||||||
}
|
}
|
||||||
@@ -21,14 +21,16 @@ type CostPerKandang struct {
|
|||||||
type CostItem struct {
|
type CostItem struct {
|
||||||
NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"`
|
NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"`
|
||||||
Quantity float64 `form:"quantity" json:"quantity" 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"`
|
Notes string `form:"notes" json:"notes" validate:"required,max=500"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Update struct {
|
type Update struct {
|
||||||
TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
|
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"`
|
Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"`
|
||||||
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
|
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 {
|
type Query struct {
|
||||||
@@ -52,8 +54,7 @@ type UpdateRealization struct {
|
|||||||
type RealizationItem struct {
|
type RealizationItem struct {
|
||||||
ExpenseNonstockID uint64 `form:"expense_nonstock_id" json:"expense_nonstock_id" validate:"required,gt=0"`
|
ExpenseNonstockID uint64 `form:"expense_nonstock_id" json:"expense_nonstock_id" validate:"required,gt=0"`
|
||||||
Qty float64 `form:"qty" json:"qty" 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"`
|
Price float64 `form:"price" json:"price" validate:"required,gt=0"`
|
||||||
TotalPrice float64 `form:"total_price" json:"total_price" validate:"required,gt=0"`
|
|
||||||
Notes *string `form:"notes" json:"notes" validate:"omitempty,max=500"`
|
Notes *string `form:"notes" json:"notes" validate:"omitempty,max=500"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,9 +196,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
|||||||
if latestApproval.StepNumber >= uint16(utils.MarketingDeliveryOrder) {
|
if latestApproval.StepNumber >= uint16(utils.MarketingDeliveryOrder) {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery order already exists for this marketing")
|
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 {
|
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,22 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MarketingRepository interface {
|
type MarketingRepository interface {
|
||||||
repository.BaseRepository[entity.Marketing]
|
repository.BaseRepository[entity.Marketing]
|
||||||
IdExists(ctx context.Context, id uint) (bool, error)
|
IdExists(ctx context.Context, id uint) (bool, error)
|
||||||
GetNextSequence(ctx context.Context) (uint, error)
|
GetNextSequence(ctx context.Context) (uint, error)
|
||||||
|
NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type MarketingRepositoryImpl struct {
|
type MarketingRepositoryImpl struct {
|
||||||
@@ -35,3 +41,82 @@ func (r *MarketingRepositoryImpl) GetNextSequence(ctx context.Context) (uint, er
|
|||||||
}
|
}
|
||||||
return maxID + 1, nil
|
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)
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,11 +115,10 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
|
|||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
|
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 {
|
if err != nil {
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate SO number")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate SO number")
|
||||||
}
|
}
|
||||||
soNumber := fmt.Sprintf("SO-%05d", nextSeq)
|
|
||||||
|
|
||||||
var marketing *entity.Marketing
|
var marketing *entity.Marketing
|
||||||
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package recordings
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
||||||
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
|
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"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
|
||||||
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||||
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
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)
|
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
|
||||||
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
|
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
|
||||||
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(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)
|
approvalRepo := commonRepo.NewApprovalRepository(db)
|
||||||
approvalService := commonSvc.NewApprovalService(approvalRepo)
|
approvalService := commonSvc.NewApprovalService(approvalRepo)
|
||||||
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil {
|
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,
|
projectFlockPopulationRepo,
|
||||||
approvalRepo,
|
approvalRepo,
|
||||||
approvalService,
|
approvalService,
|
||||||
|
fifoService,
|
||||||
validate,
|
validate,
|
||||||
)
|
)
|
||||||
userService := sUser.NewUserService(userRepo, validate)
|
userService := sUser.NewUserService(userRepo, validate)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type RecordingRepository interface {
|
|||||||
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
|
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
|
||||||
DeleteStocks(tx *gorm.DB, recordingID uint) error
|
DeleteStocks(tx *gorm.DB, recordingID uint) error
|
||||||
ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, 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
|
CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error
|
||||||
DeleteDepletions(tx *gorm.DB, recordingID uint) 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
|
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 {
|
func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error {
|
||||||
if len(depletions) == 0 {
|
if len(depletions) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
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"
|
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -36,6 +37,13 @@ type RecordingService interface {
|
|||||||
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
|
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 {
|
type recordingService struct {
|
||||||
Log *logrus.Logger
|
Log *logrus.Logger
|
||||||
Validate *validator.Validate
|
Validate *validator.Validate
|
||||||
@@ -45,6 +53,7 @@ type recordingService struct {
|
|||||||
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
|
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
|
||||||
ApprovalRepo commonRepo.ApprovalRepository
|
ApprovalRepo commonRepo.ApprovalRepository
|
||||||
ApprovalSvc commonSvc.ApprovalService
|
ApprovalSvc commonSvc.ApprovalService
|
||||||
|
FifoSvc commonSvc.FifoService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRecordingService(
|
func NewRecordingService(
|
||||||
@@ -54,6 +63,7 @@ func NewRecordingService(
|
|||||||
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
|
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
|
||||||
approvalRepo commonRepo.ApprovalRepository,
|
approvalRepo commonRepo.ApprovalRepository,
|
||||||
approvalSvc commonSvc.ApprovalService,
|
approvalSvc commonSvc.ApprovalService,
|
||||||
|
fifoSvc commonSvc.FifoService,
|
||||||
validate *validator.Validate,
|
validate *validator.Validate,
|
||||||
) RecordingService {
|
) RecordingService {
|
||||||
return &recordingService{
|
return &recordingService{
|
||||||
@@ -65,6 +75,20 @@ func NewRecordingService(
|
|||||||
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
|
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
|
||||||
ApprovalRepo: approvalRepo,
|
ApprovalRepo: approvalRepo,
|
||||||
ApprovalSvc: approvalSvc,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +246,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions)
|
mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions)
|
||||||
if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil {
|
if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil {
|
||||||
s.Log.Errorf("Failed to persist depletions: %+v", err)
|
s.Log.Errorf("Failed to persist depletions: %+v", err)
|
||||||
@@ -234,7 +262,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
return err
|
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)
|
s.Log.Errorf("Failed to adjust product warehouses: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -347,6 +375,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil {
|
if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil {
|
||||||
s.Log.Errorf("Failed to clear stocks: %+v", err)
|
s.Log.Errorf("Failed to clear stocks: %+v", err)
|
||||||
return err
|
return err
|
||||||
@@ -358,8 +390,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil {
|
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
|
||||||
s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,7 +722,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +781,77 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
|
|||||||
return nil
|
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(
|
func buildWarehouseDeltas(
|
||||||
oldDepletions, newDepletions []entity.RecordingDepletion,
|
oldDepletions, newDepletions []entity.RecordingDepletion,
|
||||||
oldStocks, newStocks []entity.RecordingStock,
|
oldStocks, newStocks []entity.RecordingStock,
|
||||||
@@ -758,12 +864,6 @@ func buildWarehouseDeltas(
|
|||||||
for _, item := range newDepletions {
|
for _, item := range newDepletions {
|
||||||
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty)
|
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 {
|
for _, item := range oldEggs {
|
||||||
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty))
|
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty))
|
||||||
}
|
}
|
||||||
@@ -773,13 +873,6 @@ func buildWarehouseDeltas(
|
|||||||
return deltas
|
return deltas
|
||||||
}
|
}
|
||||||
|
|
||||||
func usageQtyValue(val *float64) float64 {
|
|
||||||
if val == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return *val
|
|
||||||
}
|
|
||||||
|
|
||||||
func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) {
|
func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) {
|
||||||
if id == 0 || value == 0 {
|
if id == 0 || value == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals"
|
approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals"
|
||||||
|
closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings"
|
||||||
constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants"
|
constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants"
|
||||||
expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses"
|
expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses"
|
||||||
inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory"
|
inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory"
|
||||||
@@ -40,6 +41,7 @@ func Routes(app *fiber.App, db *gorm.DB) {
|
|||||||
ssoModule.Module{},
|
ssoModule.Module{},
|
||||||
expenses.ExpenseModule{},
|
expenses.ExpenseModule{},
|
||||||
ssoModule.Module{},
|
ssoModule.Module{},
|
||||||
|
closings.ClosingModule{},
|
||||||
// MODULE REGISTRY
|
// MODULE REGISTRY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,9 @@ const (
|
|||||||
MarketingStepPengajuan approvalutils.ApprovalStep = 1
|
MarketingStepPengajuan approvalutils.ApprovalStep = 1
|
||||||
MarketingStepSalesOrder approvalutils.ApprovalStep = 2
|
MarketingStepSalesOrder approvalutils.ApprovalStep = 2
|
||||||
MarketingDeliveryOrder approvalutils.ApprovalStep = 3
|
MarketingDeliveryOrder approvalutils.ApprovalStep = 3
|
||||||
|
|
||||||
|
MarketingSoNumberPrefix = "SO-"
|
||||||
|
MarketingNumberPadding = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{
|
var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package fifo
|
||||||
|
|
||||||
|
const (
|
||||||
|
UsableKeyRecordingStock UsableKey = "RECORDING_STOCK"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user