Merge branch 'codex/sales-at-farm-level' into 'development'

[FIX][BE]: adjust calculate total price create sales order for telur and convertion peti and qty

See merge request mbugroup/lti-api!400
This commit is contained in:
Adnan Zahir
2026-04-07 22:44:49 +07:00
11 changed files with 4572 additions and 474 deletions
@@ -0,0 +1,548 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/gofiber/fiber/v2"
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"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *transferService) CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error) {
if req == nil {
return nil, fmt.Errorf("system transfer request is required")
}
if strings.TrimSpace(req.TransferReason) == "" {
return nil, fmt.Errorf("transfer reason is required")
}
if req.TransferDate.IsZero() {
return nil, fmt.Errorf("transfer date is required")
}
if req.SourceWarehouseID == 0 || req.DestinationWarehouseID == 0 {
return nil, fmt.Errorf("source and destination warehouse are required")
}
if req.SourceWarehouseID == req.DestinationWarehouseID {
return nil, fmt.Errorf("source and destination warehouse must be different")
}
if req.ActorID == 0 {
return nil, fmt.Errorf("actor id is required")
}
if err := s.validateTransferWarehousesAndProducts(ctx, req.SourceWarehouseID, req.DestinationWarehouseID, req.Products); err != nil {
return nil, err
}
var result *entity.StockTransfer
err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
movementResult, err := s.createTransferMovement(ctx, tx, req)
if err != nil {
return err
}
result = movementResult.Transfer
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *transferService) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
if id == 0 {
return fmt.Errorf("transfer id is required")
}
if actorID == 0 {
return fmt.Errorf("actor id is required")
}
var deletedDetails []entity.StockTransferDetail
err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
deletedDetails, err = s.deleteTransferCore(ctx, tx, uint64(id), actorID)
return err
})
if err != nil {
return err
}
if len(deletedDetails) > 0 && s.ExpenseBridge != nil {
if err := s.ExpenseBridge.OnItemsDeleted(ctx, uint64(id), deletedDetails); err != nil {
s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense")
}
}
return nil
}
func (s *transferService) validateTransferWarehousesAndProducts(
ctx context.Context,
sourceWarehouseID uint,
destinationWarehouseID uint,
products []SystemTransferProduct,
) error {
if len(products) == 0 {
return fmt.Errorf("transfer products are required")
}
pwIDs := make([]uint, 0, len(products))
for _, product := range products {
if product.ProductID == 0 {
return fmt.Errorf("product id is required")
}
if product.ProductQty <= 0 {
return fmt.Errorf("product qty must be greater than 0 for product %d", product.ProductID)
}
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, sourceWarehouseID,
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, sourceWarehouseID))
}
s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, sourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
}
if sourcePW.Quantity < product.ProductQty {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
}
pwIDs = append(pwIDs, sourcePW.Id)
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, s.StockTransferRepo.DB(), pwIDs); err != nil {
return err
}
destPfkID, err := s.getActiveProjectFlockKandangID(ctx, destinationWarehouseID)
if err != nil {
return err
}
if destPfkID == 0 {
return nil
}
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(ctx, destPfkID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
return nil
}
func (s *transferService) createTransferMovement(
ctx context.Context,
tx *gorm.DB,
req *SystemTransferRequest,
) (*transferMovementResult, error) {
if tx == nil {
return nil, fmt.Errorf("transaction is required")
}
stockTransferRepoTX := s.StockTransferRepo.WithTx(tx)
stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogsRepoTX := rStockLogs.NewStockLogRepository(tx)
movementNumber := strings.TrimSpace(req.MovementNumber)
if movementNumber == "" {
var err error
movementNumber, err = s.StockTransferRepo.GenerateMovementNumber(ctx)
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
}
}
entityTransfer := &entity.StockTransfer{
FromWarehouseId: uint64(req.SourceWarehouseID),
ToWarehouseId: uint64(req.DestinationWarehouseID),
Reason: req.TransferReason,
TransferDate: req.TransferDate,
MovementNumber: movementNumber,
CreatedBy: uint64(req.ActorID),
}
if err := stockTransferRepoTX.CreateOne(ctx, entityTransfer, nil); err != nil {
return nil, err
}
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail, len(req.Products))
for _, product := range req.Products {
sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, req.SourceWarehouseID,
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
}
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, req.DestinationWarehouseID,
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, req.DestinationWarehouseID)
if err != nil {
return nil, err
}
var pfkID *uint
if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID
}
destPW = &entity.ProductWarehouse{
ProductId: product.ProductID,
WarehouseId: req.DestinationWarehouseID,
Quantity: 0,
ProjectFlockKandangId: pfkID,
}
if err := productWarehouseRepoTX.CreateOne(ctx, destPW, nil); err != nil {
s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
}
}
detail := &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
SourceProductWarehouseID: func() *uint64 {
id := uint64(sourcePW.Id)
return &id
}(),
UsageQty: 0,
PendingQty: 0,
DestProductWarehouseID: func() *uint64 {
id := uint64(destPW.Id)
return &id
}(),
TotalQty: 0,
TotalUsed: 0,
}
details = append(details, detail)
detailMap[uint64(product.ProductID)] = detail
}
if err := stockTransferDetailRepoTX.CreateMany(ctx, details, nil); err != nil {
return nil, err
}
flagGroupByProduct := make(map[uint]string, len(req.Products))
for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)]
if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid")
}
flagGroupCode, ok := flagGroupByProduct[product.ProductID]
if !ok {
var err error
flagGroupCode, err = s.resolveTransferFlagGroup(ctx, tx, product.ProductID)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err))
}
flagGroupByProduct[product.ProductID] = flagGroupCode
}
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": product.ProductQty,
"pending_qty": 0,
"total_qty": product.ProductQty,
}).Error; err != nil {
s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
asOf := req.TransferDate
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err))
}
type usageSnapshot struct {
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
}
var usage usageSnapshot
if err := tx.WithContext(ctx).
Table("stock_transfer_details").
Select("usage_qty, pending_qty").
Where("id = ?", detail.Id).
Take(&usage).Error; err != nil {
s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking")
}
outUsageQty := usage.UsageQty
outPendingQty := usage.PendingQty
if outPendingQty > 1e-6 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: req.ActorID,
Increase: 0,
Decrease: outUsageQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: req.StockLogNotes,
}
stockLogs, err := stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.SourceProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stockLogsRepoTX.CreateOne(ctx, stockLogDecrease, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: req.ActorID,
Increase: outUsageQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: req.StockLogNotes,
}
stockLogs, err = stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.DestProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stockLogsRepoTX.CreateOne(ctx, stockLogIncrease, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
return &transferMovementResult{
Transfer: entityTransfer,
DetailByPID: detailMap,
}, nil
}
func (s *transferService) deleteTransferCore(
ctx context.Context,
tx *gorm.DB,
transferID uint64,
actorID uint,
) ([]entity.StockTransferDetail, error) {
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
var transfer entity.StockTransfer
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", transferID).
Where("deleted_at IS NULL").
Take(&transfer).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", transferID))
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
}
var details []entity.StockTransferDetail
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Order("id ASC").
Find(&details).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
}
if len(details) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk")
}
detailIDs := make([]uint64, 0, len(details))
for _, detail := range details {
detailIDs = append(detailIDs, detail.Id)
}
if err := s.ensureDeletePolicyForDownstreamConsumption(ctx, tx, detailIDs); err != nil {
return nil, err
}
type reflowKey struct {
flagGroupCode string
productWarehouseID uint
}
destReflows := make(map[reflowKey]struct{})
for _, detail := range details {
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id))
}
if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
}
flagGroupCode, err := s.resolveTransferFlagGroup(ctx, tx, uint(detail.ProductId))
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, commonSvc.FifoStockV2RollbackRequest{
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Usable: commonSvc.FifoStockV2Ref{
ID: uint(detail.Id),
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
FunctionCode: "STOCK_TRANSFER_OUT",
},
Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber),
Tx: tx,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err))
}
releasedQty := 0.0
if rollbackRes != nil {
releasedQty = rollbackRes.ReleasedQty
}
if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty),
)
}
if releasedQty > 1e-6 {
if err := s.appendStockLog(
ctx,
stockLogRepoTx,
uint(*detail.SourceProductWarehouseID),
actorID,
releasedQty,
0,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return nil, err
}
}
destDecreaseQty := detail.TotalQty
if destDecreaseQty <= 1e-6 {
destDecreaseQty = detail.UsageQty
}
if destDecreaseQty > 1e-6 {
if err := s.appendStockLog(
ctx,
stockLogRepoTx,
uint(*detail.DestProductWarehouseID),
actorID,
0,
destDecreaseQty,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return nil, err
}
}
destReflows[reflowKey{
flagGroupCode: flagGroupCode,
productWarehouseID: uint(*detail.DestProductWarehouseID),
}] = struct{}{}
}
now := time.Now().UTC()
if err := tx.WithContext(ctx).
Where("stock_transfer_detail_id IN ?", detailIDs).
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
}
if err := tx.WithContext(ctx).
Model(&entity.StockTransferDelivery{}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
}
if err := tx.WithContext(ctx).
Model(&entity.StockTransferDetail{}).
Where("id IN ?", detailIDs).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
}
asOf := transfer.TransferDate
for key := range destReflows {
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: key.flagGroupCode,
ProductWarehouseID: key.productWarehouseID,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
}
}
if err := tx.WithContext(ctx).
Model(&entity.StockTransfer{}).
Where("id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
}
return details, nil
}
@@ -0,0 +1,481 @@
package service
import (
"context"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/go-playground/validator/v10"
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"
rTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestCreateSystemTransferCreatesAuditableMovement(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
transferDate := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
result, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07",
TransferDate: transferDate,
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{
{ProductID: 8, ProductQty: 50},
},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-0001",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected transfer result")
}
if result.MovementNumber != "PND-LTI-TEST-0001" {
t.Fatalf("expected movement number to be preserved, got %s", result.MovementNumber)
}
var transfer entity.StockTransfer
if err := db.WithContext(context.Background()).First(&transfer, result.Id).Error; err != nil {
t.Fatalf("failed to load created transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", transfer.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
if detail.UsageQty != 50 {
t.Fatalf("expected usage qty 50, got %v", detail.UsageQty)
}
if detail.TotalQty != 50 {
t.Fatalf("expected total qty 50, got %v", detail.TotalQty)
}
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID != 10 {
t.Fatalf("expected source product warehouse 10, got %+v", detail.SourceProductWarehouseID)
}
if detail.DestProductWarehouseID == nil {
t.Fatal("expected destination product warehouse to be created")
}
var destPW entity.ProductWarehouse
if err := db.WithContext(context.Background()).
First(&destPW, *detail.DestProductWarehouseID).Error; err != nil {
t.Fatalf("failed to load destination product warehouse: %v", err)
}
if destPW.WarehouseId != 2 {
t.Fatalf("expected destination warehouse id 2, got %d", destPW.WarehouseId)
}
if destPW.ProductId != 8 {
t.Fatalf("expected destination product id 8, got %d", destPW.ProductId)
}
if destPW.ProjectFlockKandangId != nil {
t.Fatalf("expected destination product warehouse to stay shared, got %+v", destPW.ProjectFlockKandangId)
}
var stockLogs []entity.StockLog
if err := db.WithContext(context.Background()).
Order("id ASC").
Find(&stockLogs).Error; err != nil {
t.Fatalf("failed to load stock logs: %v", err)
}
if len(stockLogs) != 3 {
t.Fatalf("expected 3 stock logs (seed + out + in), got %d", len(stockLogs))
}
if stockLogs[1].ProductWarehouseId != 10 || stockLogs[1].Decrease != 50 || stockLogs[1].Stock != 0 {
t.Fatalf("unexpected source stock log after transfer: %+v", stockLogs[1])
}
if stockLogs[2].ProductWarehouseId != destPW.Id || stockLogs[2].Increase != 50 || stockLogs[2].Stock != 50 {
t.Fatalf("unexpected destination stock log after transfer: %+v", stockLogs[2])
}
if len(fifoStub.reflowCalls) != 2 {
t.Fatalf("expected 2 reflow calls, got %d", len(fifoStub.reflowCalls))
}
if fifoStub.reflowCalls[0].ProductWarehouseID != 10 {
t.Fatalf("expected first reflow on source pw 10, got %d", fifoStub.reflowCalls[0].ProductWarehouseID)
}
if fifoStub.reflowCalls[1].ProductWarehouseID != destPW.Id {
t.Fatalf("expected second reflow on destination pw %d, got %d", destPW.Id, fifoStub.reflowCalls[1].ProductWarehouseID)
}
}
func TestDeleteSystemTransferRollsBackTransferWhenUnused(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07",
TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-ROLLBACK",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("failed to create transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", created.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
fifoStub.rollbackReleasedQty[detail.Id] = detail.UsageQty
if err := svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99); err != nil {
t.Fatalf("expected delete to succeed, got %v", err)
}
var deletedTransfer entity.StockTransfer
if err := db.WithContext(context.Background()).Unscoped().First(&deletedTransfer, created.Id).Error; err != nil {
t.Fatalf("failed to load deleted transfer: %v", err)
}
if deletedTransfer.DeletedAt == nil {
t.Fatal("expected transfer to be soft deleted")
}
var deletedDetail entity.StockTransferDetail
if err := db.WithContext(context.Background()).Unscoped().First(&deletedDetail, detail.Id).Error; err != nil {
t.Fatalf("failed to load deleted transfer detail: %v", err)
}
if deletedDetail.DeletedAt == nil {
t.Fatal("expected transfer detail to be soft deleted")
}
var stockLogs []entity.StockLog
if err := db.WithContext(context.Background()).
Order("id ASC").
Find(&stockLogs).Error; err != nil {
t.Fatalf("failed to load stock logs: %v", err)
}
if len(stockLogs) != 5 {
t.Fatalf("expected 5 stock logs (seed + create out/in + delete in/out), got %d", len(stockLogs))
}
if stockLogs[3].ProductWarehouseId != 10 || stockLogs[3].Increase != 50 || stockLogs[3].Stock != 50 {
t.Fatalf("unexpected rollback source stock log: %+v", stockLogs[3])
}
if stockLogs[4].Decrease != 50 || stockLogs[4].Stock != 0 {
t.Fatalf("unexpected rollback destination stock log: %+v", stockLogs[4])
}
if len(fifoStub.rollbackCalls) != 1 {
t.Fatalf("expected 1 rollback call, got %d", len(fifoStub.rollbackCalls))
}
if len(fifoStub.reflowCalls) != 3 {
t.Fatalf("expected 3 reflow calls (2 create + 1 delete), got %d", len(fifoStub.reflowCalls))
}
}
func TestDeleteSystemTransferRejectsRollbackWhenDownstreamConsumptionExists(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07",
TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-GUARD",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("failed to create transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", created.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
if err := db.Exec(`
INSERT INTO stock_allocations (
id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty,
allocation_purpose, status, function_code, flag_group_code, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
`, 1, *detail.DestProductWarehouseID, fifo.StockableKeyStockTransferIn.String(), detail.Id, fifo.UsableKeyRecordingStock.String(), 9001, 10,
entity.StockAllocationPurposeConsume, entity.StockAllocationStatusActive, "RECORDING_STOCK_OUT", "EGG").Error; err != nil {
t.Fatalf("failed to seed stock allocation: %v", err)
}
err = svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99)
if err == nil {
t.Fatal("expected delete to be blocked by downstream consumption")
}
if !strings.Contains(err.Error(), "tidak dapat dihapus") {
t.Fatalf("expected downstream guard error, got %v", err)
}
if len(fifoStub.rollbackCalls) != 0 {
t.Fatalf("expected rollback not to be called, got %d calls", len(fifoStub.rollbackCalls))
}
var transfer entity.StockTransfer
if err := db.WithContext(context.Background()).First(&transfer, created.Id).Error; err != nil {
t.Fatalf("failed to reload transfer: %v", err)
}
if transfer.DeletedAt != nil {
t.Fatal("expected transfer to remain active after guard failure")
}
}
type fifoStockV2Stub struct {
reflowCalls []commonSvc.FifoStockV2ReflowRequest
rollbackCalls []commonSvc.FifoStockV2RollbackRequest
rollbackReleasedQty map[uint64]float64
}
func (f *fifoStockV2Stub) Gather(ctx context.Context, req commonSvc.FifoStockV2GatherRequest) ([]commonSvc.FifoStockV2GatherRow, error) {
return nil, nil
}
func (f *fifoStockV2Stub) Allocate(ctx context.Context, req commonSvc.FifoStockV2AllocateRequest) (*commonSvc.FifoStockV2AllocateResult, error) {
return nil, nil
}
func (f *fifoStockV2Stub) Rollback(ctx context.Context, req commonSvc.FifoStockV2RollbackRequest) (*commonSvc.FifoStockV2RollbackResult, error) {
f.rollbackCalls = append(f.rollbackCalls, req)
return &commonSvc.FifoStockV2RollbackResult{
ReleasedQty: f.rollbackReleasedQty[uint64(req.Usable.ID)],
}, nil
}
func (f *fifoStockV2Stub) Reflow(ctx context.Context, req commonSvc.FifoStockV2ReflowRequest) (*commonSvc.FifoStockV2ReflowResult, error) {
f.reflowCalls = append(f.reflowCalls, req)
return &commonSvc.FifoStockV2ReflowResult{}, nil
}
func (f *fifoStockV2Stub) Recalculate(ctx context.Context, req commonSvc.FifoStockV2RecalculateRequest) (*commonSvc.FifoStockV2RecalculateResult, error) {
return &commonSvc.FifoStockV2RecalculateResult{}, nil
}
func newSystemTransferTestService(t *testing.T, db *gorm.DB) (TransferService, *fifoStockV2Stub) {
t.Helper()
fifoStub := &fifoStockV2Stub{rollbackReleasedQty: make(map[uint64]float64)}
return NewTransferService(
validator.New(),
rTransfer.NewStockTransferRepository(db),
rTransfer.NewStockTransferDetailRepository(db),
rTransfer.NewStockTransferDeliveryRepository(db),
rTransfer.NewStockTransferDeliveryItemRepository(db),
rStockLogs.NewStockLogRepository(db),
rProductWarehouse.NewProductWarehouseRepository(db),
nil,
rWarehouse.NewWarehouseRepository(db),
nil,
nil,
nil,
fifoStub,
nil,
), fifoStub
}
func setupSystemTransferTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
area_id INTEGER NOT NULL DEFAULT 1,
location_id INTEGER NULL,
kandang_id INTEGER NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE product_categories (
id INTEGER PRIMARY KEY,
name TEXT NULL,
code TEXT NOT NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
brand TEXT NOT NULL DEFAULT '',
sku TEXT NULL,
uom_id INTEGER NOT NULL DEFAULT 1,
product_category_id INTEGER NULL,
product_price NUMERIC NOT NULL DEFAULT 0,
selling_price NUMERIC NULL,
tax NUMERIC NULL,
expiry_period INTEGER NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
is_visible BOOLEAN NOT NULL DEFAULT 1
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
qty NUMERIC NOT NULL DEFAULT 0
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
flagable_id INTEGER NOT NULL,
flagable_type TEXT NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_flag_groups (
code TEXT PRIMARY KEY,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_flag_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flag_name TEXT NOT NULL,
flag_group_code TEXT NOT NULL,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_route_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lane TEXT NOT NULL,
function_code TEXT NOT NULL,
source_table TEXT NOT NULL,
flag_group_code TEXT NOT NULL,
legacy_type_key TEXT NULL,
allow_pending_default BOOLEAN NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_overconsume_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lane TEXT NOT NULL,
flag_group_code TEXT NULL,
function_code TEXT NULL,
allow_overconsume BOOLEAN NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
movement_number TEXT NOT NULL,
from_warehouse_id INTEGER NOT NULL,
to_warehouse_id INTEGER NOT NULL,
transfer_date TIMESTAMP NOT NULL,
reason TEXT,
created_by INTEGER NOT NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
source_product_warehouse_id INTEGER NULL,
usage_qty NUMERIC NOT NULL DEFAULT 0,
pending_qty NUMERIC NOT NULL DEFAULT 0,
dest_product_warehouse_id INTEGER NULL,
total_qty NUMERIC NOT NULL DEFAULT 0,
total_used NUMERIC NOT NULL DEFAULT 0,
expense_nonstock_id INTEGER NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_id INTEGER NOT NULL,
supplier_id INTEGER NULL,
vehicle_plate TEXT NULL,
driver_name TEXT NULL,
shipping_cost_item NUMERIC NULL,
shipping_cost_total NUMERIC NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_delivery_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_delivery_id INTEGER NOT NULL,
stock_transfer_detail_id INTEGER NOT NULL,
quantity NUMERIC NOT NULL DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_warehouse_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
increase NUMERIC NOT NULL DEFAULT 0,
decrease NUMERIC NOT NULL DEFAULT 0,
stock NUMERIC NOT NULL DEFAULT 0,
loggable_type TEXT NOT NULL,
loggable_id INTEGER NOT NULL,
notes TEXT NULL,
created_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_warehouse_id INTEGER NOT NULL,
stockable_type TEXT NOT NULL,
stockable_id INTEGER NOT NULL,
usable_type TEXT NOT NULL,
usable_id INTEGER NOT NULL,
qty NUMERIC NOT NULL DEFAULT 0,
allocation_purpose TEXT NOT NULL,
status TEXT NOT NULL,
function_code TEXT NULL,
flag_group_code TEXT NULL,
deleted_at TIMESTAMP NULL
)`,
`INSERT INTO warehouses (id, name, type, area_id, location_id, kandang_id, created_by, created_at, updated_at, deleted_at) VALUES
(1, 'Gudang Kandang Legacy', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL),
(2, 'Gudang Farm Jamali', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO product_categories (id, name, code, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Egg', 'EGG', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO products (
id, name, brand, sku, uom_id, product_category_id, product_price, selling_price, tax,
expiry_period, created_by, created_at, updated_at, deleted_at, is_visible
) VALUES (
8, 'Telur Utuh', '', NULL, 1, 1, 0, NULL, NULL, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, 1
)`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES
(10, 8, 1, NULL, 50)`,
`INSERT INTO flags (name, flagable_id, flagable_type) VALUES ('TELUR', 8, 'products')`,
`INSERT INTO fifo_stock_v2_flag_groups (code, is_active) VALUES ('EGG', 1)`,
`INSERT INTO fifo_stock_v2_flag_members (flag_name, flag_group_code, is_active) VALUES ('TELUR', 'EGG', 1)`,
`INSERT INTO fifo_stock_v2_route_rules (lane, function_code, source_table, flag_group_code, legacy_type_key, allow_pending_default, is_active) VALUES
('USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'EGG', 'STOCK_TRANSFER_OUT', 0, 1)`,
`INSERT INTO stock_logs (id, product_warehouse_id, created_by, increase, decrease, stock, loggable_type, loggable_id, notes, created_at) VALUES
(1, 10, 1, 50, 0, 50, 'PURCHASE', 1, 'seed', CURRENT_TIMESTAMP)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing test schema: %v\nstatement: %s", err, stmt)
}
}
return db
}
@@ -26,7 +26,6 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type TransferService interface {
@@ -34,6 +33,8 @@ type TransferService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
type transferService struct {
@@ -63,6 +64,27 @@ type downstreamDependency struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
type SystemTransferProduct struct {
ProductID uint
ProductQty float64
}
type SystemTransferRequest struct {
TransferReason string
TransferDate time.Time
SourceWarehouseID uint
DestinationWarehouseID uint
Products []SystemTransferProduct
ActorID uint
MovementNumber string
StockLogNotes string
}
type transferMovementResult struct {
Transfer *entity.StockTransfer
DetailByPID map[uint64]*entity.StockTransferDetail
}
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{
Log: utils.Log,
@@ -185,50 +207,17 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
}
func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) {
pwIDs := make([]uint, 0, len(req.Products))
products := make([]SystemTransferProduct, 0, len(req.Products))
for _, product := range req.Products {
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
}
if sourcePW.Quantity < product.ProductQty {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
}
pwIDs = append(pwIDs, sourcePW.Id)
products = append(products, SystemTransferProduct{
ProductID: uint(product.ProductID),
ProductQty: product.ProductQty,
})
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(
c.Context(),
s.StockTransferRepo.DB(),
pwIDs,
); err != nil {
if err := s.validateTransferWarehousesAndProducts(c.Context(), uint(req.SourceWarehouseID), uint(req.DestinationWarehouseID), products); err != nil {
return nil, err
}
destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID))
if err != nil {
return nil, err
}
if destPfkID > 0 {
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
@@ -249,11 +238,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
if delivery.VehiclePlate == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih")
}
@@ -280,104 +267,28 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
}
transferDate, _ := utils.ParseDateString(req.TransferDate)
entityTransfer := &entity.StockTransfer{
FromWarehouseId: uint64(req.SourceWarehouseID),
ToWarehouseId: uint64(req.DestinationWarehouseID),
Reason: req.TransferReason,
TransferDate: transferDate,
MovementNumber: movementNumber,
CreatedBy: uint64(actorID),
}
expensePayloads := make([]TransferExpenseReceivingPayload, 0)
var detailMap map[uint64]*entity.StockTransferDetail
var createdTransfer *entity.StockTransfer
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
stockTransferRepoTX := s.StockTransferRepo.WithTx(tx)
stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx)
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stocklogsRepoTx := s.StockLogsRepository.WithTx(tx)
if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil {
return err
}
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail)
for _, product := range req.Products {
sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
}
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context()
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
if err != nil {
return err
}
var pfkID *uint
if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID
}
destPW = &entity.ProductWarehouse{
ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0,
ProjectFlockKandangId: pfkID,
}
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil {
s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
}
}
detail := &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(),
UsageQty: 0,
PendingQty: 0,
DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(),
TotalQty: 0,
TotalUsed: 0,
}
details = append(details, detail)
detailMap[uint64(product.ProductID)] = detail
}
if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil {
movementResult, err := s.createTransferMovement(c.Context(), tx, &SystemTransferRequest{
TransferReason: req.TransferReason,
TransferDate: transferDate,
SourceWarehouseID: uint(req.SourceWarehouseID),
DestinationWarehouseID: uint(req.DestinationWarehouseID),
Products: products,
ActorID: actorID,
})
if err != nil {
return err
}
detailMap = movementResult.DetailByPID
createdTransfer = movementResult.Transfer
var deliveries []*entity.StockTransferDelivery
for _, delivery := range req.Deliveries {
@@ -389,7 +300,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil
}()
deliveries = append(deliveries, &entity.StockTransferDelivery{
StockTransferId: entityTransfer.Id,
StockTransferId: createdTransfer.Id,
SupplierId: supplierId,
VehiclePlate: delivery.VehiclePlate,
DriverName: delivery.DriverName,
@@ -402,7 +313,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
var deliveryItems []*entity.StockTransferDeliveryItem
for i, delivery := range deliveries {
item := req.Deliveries[i]
for _, prod := range item.Products {
@@ -422,14 +332,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
if s.DocumentSvc != nil && len(files) > 0 {
for deliveryIdx, delivery := range deliveries {
reqDelivery := req.Deliveries[deliveryIdx]
if reqDelivery.DocumentIndex < 0 {
continue
}
if reqDelivery.DocumentIndex >= len(files) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("DocumentIndex %d untuk delivery %d melebihi jumlah file yang diupload (%d)",
@@ -437,14 +344,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
file := files[reqDelivery.DocumentIndex]
documentFiles := []commonSvc.DocumentFile{
{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &reqDelivery.DocumentIndex,
},
}
documentFiles := []commonSvc.DocumentFile{{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &reqDelivery.DocumentIndex,
}}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeTransfer),
DocumentableID: delivery.Id,
@@ -459,160 +363,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
flagGroupByProduct := make(map[uint]string, len(req.Products))
for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)]
if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid")
for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)]
if !ok {
flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err))
}
flagGroupByProduct[uint(product.ProductID)] = flagGroupCode
}
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": product.ProductQty,
"pending_qty": 0,
"total_qty": product.ProductQty,
}).Error; err != nil {
s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
asOf := transferDate
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err))
}
type usageSnapshot struct {
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
}
var usage usageSnapshot
if err := tx.WithContext(c.Context()).
Table("stock_transfer_details").
Select("usage_qty, pending_qty").
Where("id = ?", detail.Id).
Take(&usage).Error; err != nil {
s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking")
}
outUsageQty := usage.UsageQty
outPendingQty := usage.PendingQty
if outPendingQty > 1e-6 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID),
Increase: 0,
Decrease: outUsageQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
inAddedQty := outUsageQty
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: uint(actorID),
Increase: inAddedQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
if len(req.Deliveries) > 0 {
for _, delivery := range req.Deliveries {
// Skip adding to expensePayloads if SupplierID is 0 (optional)
if delivery.SupplierID == 0 {
for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)]
if detail == nil {
continue
}
for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)]
if detail == nil {
continue
}
warehouseID := uint(req.DestinationWarehouseID)
supplierID := uint(delivery.SupplierID)
deliveredDate := transferDate
deliveredQty := prod.ProductQty
payload := TransferExpenseReceivingPayload{
TransferDetailID: detail.Id,
ProductID: uint64(prod.ProductID),
WarehouseID: uint64(warehouseID),
SupplierID: uint64(supplierID),
DeliveredQty: deliveredQty,
DeliveredDate: &deliveredDate,
}
expensePayloads = append(expensePayloads, payload)
}
warehouseID := uint(req.DestinationWarehouseID)
supplierID := uint(delivery.SupplierID)
deliveredDate := transferDate
expensePayloads = append(expensePayloads, TransferExpenseReceivingPayload{
TransferDetailID: detail.Id,
ProductID: uint64(prod.ProductID),
WarehouseID: uint64(warehouseID),
SupplierID: uint64(supplierID),
DeliveredQty: prod.ProductQty,
DeliveredDate: &deliveredDate,
})
}
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
@@ -620,14 +395,13 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
}
result, err := s.GetOne(c, uint(entityTransfer.Id))
result, err := s.GetOne(c, uint(createdTransfer.Id))
if err != nil {
return nil, err
}
if len(expensePayloads) > 0 {
if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.MovementNumber, err)
if err := s.notifyExpenseItemsDelivered(c, createdTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", createdTransfer.Id, createdTransfer.MovementNumber, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
}
}
@@ -650,177 +424,9 @@ func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
var deletedDetails []entity.StockTransferDetail
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
var transfer entity.StockTransfer
if err := tx.WithContext(c.Context()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", uint64(id)).
Where("deleted_at IS NULL").
Take(&transfer).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
}
var details []entity.StockTransferDetail
if err := tx.WithContext(c.Context()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Order("id ASC").
Find(&details).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
}
if len(details) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk")
}
detailIDs := make([]uint64, 0, len(details))
for _, detail := range details {
detailIDs = append(detailIDs, detail.Id)
}
if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil {
return err
}
type reflowKey struct {
flagGroupCode string
productWarehouseID uint
}
destReflows := make(map[reflowKey]struct{})
for _, detail := range details {
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id))
}
if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
}
flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Usable: commonSvc.FifoStockV2Ref{
ID: uint(detail.Id),
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
FunctionCode: "STOCK_TRANSFER_OUT",
},
Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber),
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err))
}
releasedQty := 0.0
if rollbackRes != nil {
releasedQty = rollbackRes.ReleasedQty
}
if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty),
)
}
if releasedQty > 1e-6 {
if err := s.appendStockLog(
c.Context(),
stockLogRepoTx,
uint(*detail.SourceProductWarehouseID),
actorID,
releasedQty,
0,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return err
}
}
destDecreaseQty := detail.TotalQty
if destDecreaseQty <= 1e-6 {
destDecreaseQty = detail.UsageQty
}
if destDecreaseQty > 1e-6 {
if err := s.appendStockLog(
c.Context(),
stockLogRepoTx,
uint(*detail.DestProductWarehouseID),
actorID,
0,
destDecreaseQty,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return err
}
}
destReflows[reflowKey{
flagGroupCode: flagGroupCode,
productWarehouseID: uint(*detail.DestProductWarehouseID),
}] = struct{}{}
}
now := time.Now().UTC()
if err := tx.WithContext(c.Context()).
Where("stock_transfer_detail_id IN ?", detailIDs).
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransferDelivery{}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransferDetail{}).
Where("id IN ?", detailIDs).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
}
asOf := transfer.TransferDate
for key := range destReflows {
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: key.flagGroupCode,
ProductWarehouseID: key.productWarehouseID,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
}
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransfer{}).
Where("id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
}
deletedDetails = append(deletedDetails, details...)
return nil
var err error
deletedDetails, err = s.deleteTransferCore(c.Context(), tx, uint64(id), actorID)
return err
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
@@ -863,13 +469,31 @@ func (s *transferService) resolveTransferFlagGroup(
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = ?
AND fm.flag_group_code = rr.flag_group_code
FROM products p
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE p.id = ?
AND (
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = p.id
AND fm.flag_group_code = rr.flag_group_code
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = ?
AND f_any.flagable_id = p.id
)
AND rr.flag_group_code = ?
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
)
`, entity.FlagableTypeProduct, productID).
`, productID, entity.FlagableTypeProduct, entity.FlagableTypeProduct, utils.LegacyFlagGroupCodeByProductCategoryCode("EGG")).
Order("rr.id ASC").
Limit(1).
Take(&selected).Error
@@ -141,6 +141,12 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid")
}
if item.MarketingType == string(utils.MarketingTypeTelur) &&
item.ConvertionUnit != nil &&
*item.ConvertionUnit == string(utils.ConvertionUnitPeti) &&
(item.WeightPerConvertion == nil || *item.WeightPerConvertion <= 0) {
return nil, fiber.NewError(fiber.StatusBadRequest, "weight_per_convertion wajib diisi dan > 0 untuk TELUR dengan convertion_unit PETI")
}
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err
}
@@ -308,6 +314,12 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid")
}
if item.MarketingType == string(utils.MarketingTypeTelur) &&
item.ConvertionUnit != nil &&
*item.ConvertionUnit == string(utils.ConvertionUnitPeti) &&
(item.WeightPerConvertion == nil || *item.WeightPerConvertion <= 0) {
return nil, fiber.NewError(fiber.StatusBadRequest, "weight_per_convertion wajib diisi dan > 0 untuk TELUR dengan convertion_unit PETI")
}
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err
}
@@ -386,7 +398,15 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, rp := range req.MarketingProducts {
if old, ok := oldByPW[rp.ProductWarehouseId]; ok {
totalWeight, totalPrice := s.calculatePriceByMarketingType(rp.MarketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week)
totalWeight, totalPrice := s.calculatePriceByMarketingType(
rp.MarketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -750,7 +770,15 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week)
totalWeight, totalPrice := s.calculatePriceByMarketingType(
marketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId,
@@ -787,7 +815,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
return nil
}
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) {
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, weightPerConvertion *float64) (totalWeight, totalPrice float64) {
if marketingType == string(utils.MarketingTypeTrading) {
totalWeight = 0
totalPrice = math.Round(qty*unitPrice*100) / 100
@@ -796,6 +824,21 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
} else {
totalWeight = math.Round(qty*avgWeight*100) / 100
if marketingType == string(utils.MarketingTypeTelur) && convertionUnit != nil {
switch *convertionUnit {
case string(utils.ConvertionUnitQty):
totalPrice = math.Round(qty*unitPrice*100) / 100
return totalWeight, totalPrice
case string(utils.ConvertionUnitPeti):
if weightPerConvertion != nil && *weightPerConvertion > 0 {
totalPeti := totalWeight / *weightPerConvertion
totalPrice = math.Round(totalPeti*unitPrice*100) / 100
return totalWeight, totalPrice
}
}
}
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
}
return totalWeight, totalPrice