mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user