Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-390-Dashboard

This commit is contained in:
ragilap
2026-01-11 18:26:40 +07:00
59 changed files with 1349 additions and 332 deletions
@@ -247,7 +247,7 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
}
func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error {
param := c.Params("project_flock_id")
param := c.Params("projectFlockId")
projectFlockID, err := strconv.Atoi(param)
if err != nil {
@@ -55,16 +55,21 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
kandang = &mapped
}
var realizationDate time.Time
if e.DeliveryDate != nil {
realizationDate = *e.DeliveryDate
}
doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id)
return SalesDTO{
Id: e.Id,
RealizationDate: *e.DeliveryDate,
RealizationDate: realizationDate,
Age: age,
DoNumber: doNumber,
Product: product,
Customer: customer,
Qty: e.UsageQty, // Show allocated quantity from FIFO
Qty: e.UsageQty,
Weight: e.TotalWeight,
AvgWeight: e.AvgWeight,
Price: e.UnitPrice,
@@ -10,6 +10,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
@@ -914,9 +915,8 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C
var rows []ActualUsageCostRow
// Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll)
purchaseStockableKey := "PURCHASE_ITEMS"
transferStockableKey := "STOCK_TRANSFER_DETAILS"
purchaseStockableKey := fifo.StockableKeyPurchaseItems.String()
transferStockableKey := fifo.StockableKeyStockTransferIn.String()
recordingQuery := db.
Table("recordings AS r").
@@ -982,7 +982,6 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C
return nil, err
}
// Part 2: Get usage from project_chickins (DOC, Pullet)
chickinQuery := db.
Table("project_chickins AS pc").
Select(`
@@ -1006,7 +1005,6 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C
return nil, err
}
// Merge results
rows = append(rows, chickinRows...)
return rows, nil
@@ -151,9 +151,19 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entit
return nil, err
}
if len(realisasi) == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Penjualan realisasi not found")
return []entity.MarketingDeliveryProduct{}, nil
}
return realisasi, nil
filtered := make([]entity.MarketingDeliveryProduct, 0, len(realisasi))
for _, item := range realisasi {
if item.UsageQty != 0 || item.TotalWeight != 0 || item.AvgWeight != 0 ||
item.UnitPrice != 0 || item.TotalPrice != 0 {
filtered = append(filtered, item)
}
}
return filtered, nil
}
func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) {
@@ -403,18 +413,9 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.Ove
}
func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: func(ctx context.Context, id uint) (bool, error) {
_, err := s.ProjectFlockRepo.GetByID(ctx, id, nil)
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return err == nil, err
}},
commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists},
); err != nil {
return nil, err
}
@@ -429,13 +430,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
// Get actual usage cost instead of purchase items
actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost")
}
// Convert actual usage rows to pseudo purchase items
purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows)
realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID)
@@ -9,7 +9,7 @@ type Create struct {
type Update struct {
Status string `json:"status" validate:"required"`
RejectReason *string `json:"reject_reason" validate:"required"`
RejectReason *string `json:"reject_reason"`
}
type Query struct {
@@ -101,20 +101,25 @@ func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO {
func partyFromInitial(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
Id: e.PartyId,
Type: e.PartyType,
}
if e.PartyAccountNumber != nil {
party.AccountNumber = *e.PartyAccountNumber
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
if party.AccountNumber == "" {
party.AccountNumber = e.Customer.AccountNumber
}
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
if party.AccountNumber == "" && e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
@@ -120,6 +120,7 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
TransactionType: string(utils.TransactionTypeSaldoAwal),
PartyType: party,
PartyId: req.PartyId,
PartyAccountNumber: nil,
PaymentDate: time.Now(),
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
@@ -106,6 +106,7 @@ func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
TransactionType: string(utils.TransactionTypeInjection),
PartyType: string(utils.PaymentPartyCustomer),
PartyId: 0,
PartyAccountNumber: nil,
PaymentDate: adjustmentDate,
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
@@ -1,17 +1,17 @@
package validation
type Create struct {
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
AdjustmentDate string `json:"adjustment_date" validate:"required_strict"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
AdjustmentDate string `json:"adjustment_date" validate:"required_strict"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
}
type Update struct {
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
type Query struct {
@@ -124,20 +124,25 @@ func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO {
func partyFromPayment(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
Id: e.PartyId,
Type: e.PartyType,
}
if e.PartyAccountNumber != nil {
party.AccountNumber = *e.PartyAccountNumber
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
if party.AccountNumber == "" {
party.AccountNumber = e.Customer.AccountNumber
}
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
if party.AccountNumber == "" && e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
+3 -3
View File
@@ -15,7 +15,7 @@ func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService
route := v1.Group("/payments")
route.Use(m.Auth(u))
route.Post("/",m.RequirePermissions(m.P_Finances_Payments_CreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_Finances_Payments_GetOne), ctrl.GetOne)
route.Patch("/:id",m.RequirePermissions(m.P_Finances_Payments_UpdateOne), ctrl.UpdateOne)
route.Post("/", m.RequirePermissions(m.P_Finances_Payments_CreateOne), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_Finances_Payments_GetOne), ctrl.GetOne)
route.Patch("/:id", m.RequirePermissions(m.P_Finances_Payments_UpdateOne), ctrl.UpdateOne)
}
@@ -121,18 +121,19 @@ func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
}
createBody := &entity.Payment{
PaymentCode: code,
ReferenceNumber: req.ReferenceNumber,
TransactionType: transactionType,
PartyType: party,
PartyId: req.PartyId,
PaymentDate: paymentDate,
PaymentMethod: method,
BankId: req.BankId,
Direction: directionForParty(party),
Nominal: req.Nominal,
Notes: req.Notes,
CreatedBy: actorID,
PaymentCode: code,
ReferenceNumber: req.ReferenceNumber,
TransactionType: transactionType,
PartyType: party,
PartyId: req.PartyId,
PartyAccountNumber: req.PartyAccountNumber,
PaymentDate: paymentDate,
PaymentMethod: method,
BankId: req.BankId,
Direction: directionForParty(party),
Nominal: req.Nominal,
Notes: req.Notes,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
@@ -188,6 +189,9 @@ func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if req.ReferenceNumber != nil {
updateBody["reference_number"] = *req.ReferenceNumber
}
if req.PartyAccountNumber != nil {
updateBody["party_account_number"] = *req.PartyAccountNumber
}
if req.PaymentMethod != nil {
method, err := normalizePaymentMethod(*req.PaymentMethod)
if err != nil {
@@ -1,25 +1,27 @@
package validation
type Create struct {
PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"`
Nominal float64 `json:"nominal" validate:"required_strict"`
ReferenceNumber *string `json:"reference_number,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"`
BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
PartyAccountNumber *string `json:"party_account_number"`
PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"`
Nominal float64 `json:"nominal" validate:"required_strict"`
ReferenceNumber *string `json:"reference_number,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"`
BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
}
type Update struct {
PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"`
PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"`
PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
ReferenceNumber *string `json:"reference_number,omitempty"`
PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"`
PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"`
PartyAccountNumber *string `json:"party_account_number,omitempty"`
PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
ReferenceNumber *string `json:"reference_number,omitempty"`
PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
type Query struct {
@@ -124,20 +124,25 @@ func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO {
func partyFromPayment(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
Id: e.PartyId,
Type: e.PartyType,
}
if e.PartyAccountNumber != nil {
party.AccountNumber = *e.PartyAccountNumber
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
if party.AccountNumber == "" {
party.AccountNumber = e.Customer.AccountNumber
}
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
if party.AccountNumber == "" && e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
@@ -23,6 +23,7 @@ type ProductWarehouseRepository interface {
GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error)
GetByFlagAndWarehouseID(ctx context.Context, flagName string, warehouseId uint) ([]entity.ProductWarehouse, error)
GetFirstProductByFlag(ctx context.Context, flagName string) (*entity.Product, error)
ListProductIDsByFlagPrefixes(ctx context.Context, prefixes []string, visibleStatus *bool) ([]uint, error)
ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB
AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error
GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error)
@@ -380,3 +381,38 @@ func (r *ProductWarehouseRepositoryImpl) GetFirstProductByFlag(ctx context.Conte
}
return &product, nil
}
func (r *ProductWarehouseRepositoryImpl) ListProductIDsByFlagPrefixes(ctx context.Context, prefixes []string, visibleStatus *bool) ([]uint, error) {
if len(prefixes) == 0 {
return []uint{}, nil
}
db := r.DB().WithContext(ctx).
Model(&entity.Product{}).
Distinct("products.id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", entity.FlagableTypeProduct)
applied := false
for _, prefix := range prefixes {
if prefix == "" {
continue
}
like := prefix + "%"
if !applied {
db = db.Where("flags.name LIKE ?", like)
applied = true
continue
}
db = db.Or("flags.name LIKE ?", like)
}
if visibleStatus != nil {
db = db.Where("products.is_visible = ?", *visibleStatus)
}
var ids []uint
if err := db.Pluck("products.id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
@@ -75,6 +75,8 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error {
func (u *TransferController) CreateOne(c *fiber.Ctx) error {
data := c.FormValue("data")
const maxFileSize = 5 * 1024 * 1024
var req validation.TransferRequest
if err := json.Unmarshal([]byte(data), &req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
@@ -87,9 +89,11 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
files := form.File["documents"]
if len(files) != len(req.Deliveries) {
return fiber.NewError(fiber.StatusBadRequest,
fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message)
for i, file := range files {
if file.Size > maxFileSize {
return fiber.NewError(fiber.StatusBadRequest,
"Dokumen ke-"+strconv.Itoa(i+1)+" melebihi ukuran maksimal 5MB")
}
}
result, err := u.TransferService.CreateOne(c, &req, files)
@@ -71,9 +71,11 @@ type TransferDetailDTO struct {
}
type TransferDetailItemDTO struct {
Id uint64 `json:"id"`
Product ProductSimpleDTO `json:"product"`
Quantity float64 `json:"quantity"`
Id uint64 `json:"id"`
Product ProductSimpleDTO `json:"product"`
Quantity float64 `json:"quantity"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item
ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi
}
type TransferDeliveryDTO struct {
@@ -153,14 +155,30 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
var details []TransferDetailItemDTO
for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{
detailDTO := TransferDetailItemDTO{
Id: d.Id,
Product: ProductSimpleDTO{
Id: d.Product.Id,
Name: d.Product.Name,
},
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
})
}
if d.ExpenseNonstock != nil {
priceCopy := d.ExpenseNonstock.Price
detailDTO.TransportPerItem = &priceCopy
if d.ExpenseNonstock.Expense != nil && d.ExpenseNonstock.Expense.Supplier != nil && d.ExpenseNonstock.Expense.Supplier.Id != 0 {
exp := d.ExpenseNonstock.Expense
detailDTO.ExpeditionVendor = &SupplierSimpleDTO{
Id: exp.Supplier.Id,
Name: exp.Supplier.Name,
}
}
}
details = append(details, detailDTO)
}
var deliveries []TransferDeliveryDTO
@@ -223,14 +241,30 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO {
func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
var details []TransferDetailItemDTO
for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{
detailDTO := TransferDetailItemDTO{
Id: d.Id,
Product: ProductSimpleDTO{
Id: d.Product.Id,
Name: d.Product.Name,
},
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
})
}
if d.ExpenseNonstock != nil {
priceCopy := d.ExpenseNonstock.Price
detailDTO.TransportPerItem = &priceCopy
if d.ExpenseNonstock.Expense != nil && d.ExpenseNonstock.Expense.Supplier != nil && d.ExpenseNonstock.Expense.Supplier.Id != 0 {
exp := d.ExpenseNonstock.Expense
detailDTO.ExpeditionVendor = &SupplierSimpleDTO{
Id: exp.Supplier.Id,
Name: exp.Supplier.Name,
}
}
}
details = append(details, detailDTO)
}
var deliveries []TransferDeliveryDTO
+35 -1
View File
@@ -2,6 +2,7 @@ package transfers
import (
"context"
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -9,9 +10,13 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
@@ -35,15 +40,44 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
expenseRepository := expenseRepo.NewExpenseRepository(db)
expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(err)
}
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo)
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register expense approval workflow: %v", err))
}
expenseServiceInstance := expenseService.NewExpenseService(
expenseRepository,
supplierRepo,
nonstockRepo,
approvalSvc,
expenseRealizationRepo,
projectFlockKandangRepo,
documentSvc,
validate,
)
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
expenseBridge := sTransfer.NewTransferExpenseBridge(
db,
stockTransferRepo,
projectFlockKandangRepo,
kandangRepo,
expenseServiceInstance,
)
err = fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyStockTransferIn,
@@ -77,7 +111,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(err)
}
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService)
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, expenseBridge)
userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService)
@@ -2,6 +2,7 @@ package repositories
import (
"context"
"fmt"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -12,6 +13,7 @@ type StockTransferRepository interface {
repository.BaseRepository[entity.StockTransfer]
// get sequence for movement number
GetNextMovementNumber(ctx context.Context) (int64, error)
GenerateMovementNumber(ctx context.Context) (string, error)
}
type StockTransferRepositoryImpl struct {
@@ -32,3 +34,12 @@ func (r *StockTransferRepositoryImpl) GetNextMovementNumber(ctx context.Context)
}
return seq, nil
}
func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context) (string, error) {
seq, err := r.GetNextMovementNumber(ctx)
if err != nil {
return "", err
}
movementNumber := fmt.Sprintf("ST-%05d", seq)
return movementNumber, nil
}
@@ -46,9 +46,10 @@ type transferService struct {
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService
FifoSvc commonSvc.FifoService
ExpenseBridge TransferExpenseBridge
}
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, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService {
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, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{
Log: utils.Log,
Validate: validate,
@@ -63,6 +64,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
FifoSvc: fifoSvc,
ExpenseBridge: expenseBridge,
}
}
@@ -77,6 +79,9 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ToWarehouse.Area").
Preload("Details").
Preload("Details.Product").
Preload("Details.ExpenseNonstock").
Preload("Details.ExpenseNonstock.Expense").
Preload("Details.ExpenseNonstock.Expense.Supplier").
Preload("Deliveries.Items").
Preload("Deliveries.Supplier").
Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB {
@@ -123,7 +128,6 @@ 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) {
// === VALIDASI SOURCE WAREHOUSE ===
pwIDs := make([]uint, 0, len(req.Products))
for _, product := range req.Products {
@@ -155,14 +159,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, err
}
if s.ProjectFlockKandangRepo != nil {
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing")
}
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing")
}
actorID, err := m.ActorIDFromContext(c)
@@ -192,16 +194,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier")
}
if supplier.Category != "BOP" {
if supplier.Category != string(utils.SupplierCategoryBOP) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID))
}
}
seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context())
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number")
}
movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum)
transferDate, _ := utils.ParseDateString(req.TransferDate)
entityTransfer := &entity.StockTransfer{
@@ -213,19 +215,26 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
CreatedBy: uint64(actorID),
}
expensePayloads := make([]TransferExpenseReceivingPayload, 0)
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil {
stockTransferRepoTX := s.StockTransferRepo.WithTx(tx)
stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx)
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil {
return err
}
// Prepare details and fetch product warehouses
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail)
for _, product := range req.Products {
// Get source product warehouse
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
@@ -235,8 +244,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source")
}
// Get or create destination product warehouse
destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -254,7 +262,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Quantity: 0,
ProjectFlockKandangId: &projectFlockKandangID,
}
if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil {
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination")
}
}
@@ -275,7 +283,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
detailMap[uint64(product.ProductID)] = detail
}
if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil {
if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil {
return err
}
@@ -290,7 +298,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
ShippingCostTotal: delivery.DeliveryCost,
})
}
if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil {
if err := stockTransferDeliveryRepoTX.CreateMany(c.Context(), deliveries, nil); err != nil {
return err
}
@@ -310,30 +318,44 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
})
}
}
if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil {
if err := stockTransferDeliveryItemRepoTX.CreateMany(c.Context(), deliveryItems, nil); err != nil {
return err
}
if s.DocumentSvc != nil && len(files) > 0 {
for idx, file := range files {
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)",
reqDelivery.DocumentIndex, deliveryIdx+1, len(files)))
}
file := files[reqDelivery.DocumentIndex]
documentFiles := []commonSvc.DocumentFile{
{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &idx,
Index: &reqDelivery.DocumentIndex,
},
}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeTransfer),
DocumentableID: deliveries[idx].Id,
DocumentableID: delivery.Id,
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)",
idx+1, deliveries[idx].Id, file.Filename)
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", idx+1, err))
deliveryIdx+1, delivery.Id, file.Filename)
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err))
}
}
}
@@ -362,7 +384,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fmt.Errorf("gagal update usage tracking: %w", err)
}
// Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN)
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyStockTransferIn,
@@ -385,6 +406,32 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
if len(req.Deliveries) > 0 {
for _, delivery := range req.Deliveries {
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)
}
}
}
return nil
})
@@ -396,9 +443,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
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 %d: %+v", entityTransfer.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync expense: %v", err))
}
}
return result, nil
}
func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error {
if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsDelivered(c, transferID, payloads)
}
func (s *transferService) notifyExpenseDetailsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error {
if s.ExpenseBridge == nil || transferID == 0 || len(items) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsDeleted(ctx, transferID, items)
}
func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
if err != nil {
@@ -0,0 +1,473 @@
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type TransferExpenseBridge interface {
OnItemsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error
OnItemsDelivered(c *fiber.Ctx, transferID uint64, updates []TransferExpenseReceivingPayload) error
}
type TransferExpenseReceivingPayload struct {
TransferDetailID uint64
ProductID uint64
WarehouseID uint64
SupplierID uint64
TransportPerItem *float64
DeliveredQty float64
DeliveredDate *time.Time
}
type groupedTransferItem struct {
detail *entity.StockTransferDetail
payload TransferExpenseReceivingPayload
projectFK *uint
kandangID *uint
totalPrice float64
shippingCostTotal float64
}
func groupingKey(supplierID uint, date time.Time, warehouseID uint) string {
return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID)
}
type transferExpenseBridge struct {
db *gorm.DB
transferRepo rStockTransfer.StockTransferRepository
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
kandangRepo kandangRepo.KandangRepository
expenseSvc expenseSvc.ExpenseService
}
func NewTransferExpenseBridge(
db *gorm.DB,
transferRepo rStockTransfer.StockTransferRepository,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
kandangRepo kandangRepo.KandangRepository,
expenseSvc expenseSvc.ExpenseService,
) TransferExpenseBridge {
return &transferExpenseBridge{
db: db,
transferRepo: transferRepo,
projectFlockKandangRepo: projectFlockKandangRepo,
kandangRepo: kandangRepo,
expenseSvc: expenseSvc,
}
}
func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, items []entity.StockTransferDetail) error {
if len(items) == 0 {
return nil
}
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
expenseIDs := make(map[uint64]struct{})
expenseNonstockIDs := make([]uint64, 0)
for _, item := range items {
if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 {
expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId)
}
}
if len(expenseNonstockIDs) > 0 {
for _, nsID := range expenseNonstockIDs {
var expenseID uint64
if err := tx.Model(&entity.ExpenseNonstock{}).
Select("expense_id").
Where("id = ?", nsID).
Scan(&expenseID).Error; err != nil {
return err
}
if expenseID != 0 {
expenseIDs[expenseID] = struct{}{}
}
}
if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil {
return err
}
}
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
for expenseID := range expenseIDs {
var count int64
if err := tx.Model(&entity.ExpenseNonstock{}).
Where("expense_id = ?", expenseID).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil {
return err
}
if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil {
return err
}
}
}
return nil
})
}
func (b *transferExpenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error {
if len(expenseIDs) == 0 {
return nil
}
if actorID == 0 {
actorID = 1
}
svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db))
action := entity.ApprovalActionUpdated
for id := range expenseIDs {
if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil {
return err
}
}
return nil
}
func (b *transferExpenseBridge) findExpeditionNonstockID(ctx context.Context, supplierID uint) (uint64, error) {
var id uint64
err := b.db.WithContext(ctx).
Table("nonstocks AS ns").
Select("ns.id").
Joins("JOIN nonstock_suppliers nss ON nss.nonstock_id = ns.id").
Joins("JOIN flags f ON f.flagable_id = ns.id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))).
Where("nss.supplier_id = ?", supplierID).
Order("ns.id").
Limit(1).
Scan(&id).Error
if err != nil {
return 0, err
}
if id == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "supplier id tidak sesuai dengan expedisi")
}
return id, nil
}
func (b *transferExpenseBridge) createExpenseViaService(
c *fiber.Ctx,
transfer *entity.StockTransfer,
items []groupedTransferItem,
expenseDate time.Time,
expeditionNonstockID uint64,
movementNumber string,
supplierID uint,
) (*expenseDto.ExpenseDetailDTO, error) {
ctx := c.Context()
if b.expenseSvc == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "expense service not available")
}
if len(items) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "no items to create expense")
}
kandangID := items[0].kandangID
var locationID uint64
var expenseKandangID *uint64
if kandangID != nil && *kandangID != 0 {
kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB {
return db.Select("id, location_id")
})
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
if kandang == nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
locationID = uint64(kandang.LocationId)
id := uint64(*kandangID)
expenseKandangID = &id
} else {
if transfer.ToWarehouse == nil || transfer.ToWarehouse.LocationId == nil || *transfer.ToWarehouse.LocationId == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Destination warehouse location is required for expense")
}
locationID = uint64(*transfer.ToWarehouse.LocationId)
}
costItems := make([]expenseValidation.CostItem, 0, len(items))
for _, gi := range items {
note := fmt.Sprintf("stock_transfer_detail:%d", gi.detail.Id)
price := gi.shippingCostTotal
if gi.payload.TransportPerItem != nil {
price = *gi.payload.TransportPerItem * gi.payload.DeliveredQty
}
costItems = append(costItems, expenseValidation.CostItem{
NonstockID: expeditionNonstockID,
Quantity: 1,
Price: price,
Notes: note,
})
}
req := &expenseValidation.Create{
PoNumber: "",
TransactionDate: utils.FormatDate(expenseDate),
Category: string(utils.ExpenseCategoryBOP),
SupplierID: uint64(supplierID),
LocationID: locationID,
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{
KandangID: expenseKandangID,
CostItems: costItems,
}},
}
detail, err := b.expenseSvc.CreateOne(c, req)
if err != nil {
return nil, err
}
action := entity.ApprovalActionApproved
actorID := uint(transfer.CreatedBy)
if actorID == 0 {
actorID = 1
}
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db))
if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil {
return nil, err
}
if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil {
return nil, err
}
return detail, nil
}
func (b *transferExpenseBridge) linkExpenseNonstocksToDetails(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedTransferItem) error {
if detail == nil || len(items) == 0 {
return nil
}
noteToExpenseNonstock := mapExpenseNotesForTransfer(detail)
if len(noteToExpenseNonstock) == 0 {
return nil
}
for _, gi := range items {
expenseNonstockID, ok := noteToExpenseNonstock[gi.detail.Id]
if !ok {
continue
}
if err := b.db.WithContext(ctx).
Model(&entity.StockTransferDetail{}).
Where("id = ?", gi.detail.Id).
Update("expense_nonstock_id", expenseNonstockID).Error; err != nil {
return err
}
}
return nil
}
func mapExpenseNotesForTransfer(detail *expenseDto.ExpenseDetailDTO) map[uint64]uint64 {
result := make(map[uint64]uint64)
if detail == nil {
return result
}
for _, kandang := range detail.Kandangs {
for _, pengajuan := range kandang.Pengajuans {
note := strings.TrimSpace(pengajuan.Notes)
if note == "" {
continue
}
const prefix = "stock_transfer_detail:"
if !strings.HasPrefix(note, prefix) {
continue
}
idStr := strings.TrimPrefix(note, prefix)
var detailID uint64
if _, err := fmt.Sscanf(idStr, "%d", &detailID); err != nil {
continue
}
result[detailID] = pengajuan.Id
}
}
return result
}
func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64, updates []TransferExpenseReceivingPayload) error {
if transferID == 0 || len(updates) == 0 {
return nil
}
ctx := c.Context()
transfer, err := b.transferRepo.GetByID(ctx, uint(transferID), func(db *gorm.DB) *gorm.DB {
return db.
Preload("Details").
Preload("Details.Product").
Preload("Details.DestProductWarehouse").
Preload("Details.DeliveryItems").
Preload("Details.DeliveryItems.StockTransferDelivery").
Preload("ToWarehouse")
})
if err != nil {
return err
}
detailMap := make(map[uint64]*entity.StockTransferDetail, len(transfer.Details))
shippingCostMap := make(map[uint64]float64) // detailID -> ShippingCostTotal
for i := range transfer.Details {
detailMap[transfer.Details[i].Id] = &transfer.Details[i]
for _, deliveryItem := range transfer.Details[i].DeliveryItems {
if deliveryItem.StockTransferDelivery != nil {
shippingCostMap[transfer.Details[i].Id] = deliveryItem.StockTransferDelivery.ShippingCostTotal
break
}
}
}
groups := make(map[string][]groupedTransferItem)
for _, payload := range updates {
if payload.DeliveredDate == nil {
return fiber.NewError(fiber.StatusBadRequest, "delivered_date is required")
}
detail := detailMap[payload.TransferDetailID]
if detail == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer detail %d not found", payload.TransferDetailID))
}
if payload.DeliveredQty <= 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Delivered quantity for detail %d must be greater than 0", payload.TransferDetailID))
}
deliveredDate := payload.DeliveredDate.UTC().Truncate(24 * time.Hour)
supplierID := payload.SupplierID
if supplierID == 0 {
supplierID = 1 // Default supplier
}
var kandangID *uint
var projectFK *uint
if detail.DestProductWarehouse.WarehouseId != 0 {
var kandangIDResult uint
if err := b.db.WithContext(ctx).
Table("warehouses").
Select("kandang_id").
Where("id = ?", detail.DestProductWarehouse.WarehouseId).
Scan(&kandangIDResult).Error; err == nil && kandangIDResult != 0 {
id := uint(kandangIDResult)
kandangID = &id
if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, kandangIDResult); err == nil && project != nil {
pid := uint(project.Id)
projectFK = &pid
}
}
}
shippingCostTotal := shippingCostMap[detail.Id]
totalPrice := shippingCostTotal
if payload.TransportPerItem != nil {
totalPrice = *payload.TransportPerItem * payload.DeliveredQty
}
warehouseID := uint(payload.WarehouseID)
if warehouseID == 0 && transfer.ToWarehouse != nil {
warehouseID = uint(transfer.ToWarehouse.Id)
}
if warehouseID == 0 && detail.DestProductWarehouse != nil {
warehouseID = uint(detail.DestProductWarehouse.WarehouseId)
}
key := groupingKey(uint(supplierID), deliveredDate, warehouseID)
groups[key] = append(groups[key], groupedTransferItem{
detail: detail,
payload: payload,
projectFK: projectFK,
kandangID: kandangID,
totalPrice: totalPrice,
shippingCostTotal: shippingCostTotal,
})
}
updatedExpenses := make(map[uint64]struct{})
for key, items := range groups {
if len(items) == 0 {
continue
}
parts := strings.Split(key, ":")
if len(parts) < 3 {
return errors.New("invalid expense grouping key")
}
expenseDate, err := utils.ParseDateString(parts[1])
if err != nil {
return err
}
supplierID, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil {
return err
}
expeditionNonstockID, err := b.findExpeditionNonstockID(ctx, uint(supplierID))
if err != nil {
return err
}
expenseDetail, err := b.createExpenseViaService(c, transfer, items, expenseDate, expeditionNonstockID, transfer.MovementNumber, uint(supplierID))
if err != nil {
return err
}
if err := b.linkExpenseNonstocksToDetails(ctx, expenseDetail, items); err != nil {
return err
}
if expenseDetail != nil && expenseDetail.Id != 0 {
updatedExpenses[uint64(expenseDetail.Id)] = struct{}{}
}
}
if len(updatedExpenses) > 0 {
actorID := uint(1) // Default actor
if err := b.markExpensesUpdated(ctx, updatedExpenses, actorID); err != nil {
return err
}
}
return nil
}
@@ -23,7 +23,7 @@ type TransferDeliveryProduct struct {
type TransferDelivery struct {
DeliveryCost float64 `json:"delivery_cost" validate:"required"`
DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"`
DocumentIndex int `json:"document_index" validate:"min=0"`
DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"`
DriverName string `json:"driver_name" validate:"required"`
VehiclePlate string `json:"vehicle_plate" validate:"required"`
SupplierID uint `json:"supplier_id" validate:"required"`
@@ -14,7 +14,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=500,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
KandangId *uint `query:"kandang_id" validate:"omitempty"`
IsActive *bool `query:"is_active" validate:"omitempty"`
@@ -28,20 +28,12 @@ func (u *PhaseActivityController) GetAll(c *fiber.Ctx) error {
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
query.PhaseIDs = c.Query("phase_ids", "")
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if phaseParam := c.Query("phase_id", ""); phaseParam != "" {
id, err := strconv.Atoi(phaseParam)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "invalid phase_id")
}
temp := uint(id)
query.PhaseId = &temp
}
result, totalResults, err := u.PhaseActivityService.GetAll(c, query)
if err != nil {
return err
@@ -2,6 +2,7 @@ package service
import (
"errors"
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -57,8 +58,11 @@ func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]
if params.Search != "" {
db = db.Where("name LIKE ?", "%"+params.Search+"%")
}
if params.PhaseId != nil {
db = db.Where("phase_id = ?", *params.PhaseId)
if params.PhaseIDs != "" {
ids := parseIDs(params.PhaseIDs)
if len(ids) > 0 {
db = db.Where("phase_id IN ?", ids)
}
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
@@ -166,3 +170,18 @@ func (s phaseActivityService) DeleteOne(c *fiber.Ctx, id uint) error {
}
return nil
}
func parseIDs(raw string) []uint {
parts := strings.Split(raw, ",")
results := make([]uint, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
if n, err := strconv.ParseUint(value, 10, 64); err == nil {
results = append(results, uint(n))
}
}
return results
}
@@ -14,8 +14,8 @@ type Update struct {
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
PhaseId *uint `query:"phase_id" validate:"omitempty"`
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
PhaseIDs string `query:"phase_ids" validate:"omitempty"`
}
@@ -5,6 +5,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
@@ -19,6 +20,7 @@ type ProductRelationDTO struct {
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
Flags *[]string `json:"flags,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"`
}
type ProductListDTO struct {
@@ -33,6 +35,7 @@ type ProductListDTO struct {
Flags []string `json:"flags"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -70,6 +73,7 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO {
Flags: &flags,
Uom: uomRef,
ProductCategory: categoryRef,
Suppliers: toProductSupplierDTOs(e.ProductSuppliers),
}
}
@@ -112,6 +116,7 @@ func ToProductListDTO(e entity.Product) ProductListDTO {
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
ProductCategory: categoryRef,
Suppliers: toProductSupplierDTOs(e.ProductSuppliers),
}
}
@@ -128,3 +133,23 @@ func ToProductDetailDTO(e entity.Product) ProductDetailDTO {
ProductListDTO: ToProductListDTO(e),
}
}
func toProductSupplierDTOs(relations []entity.ProductSupplier) []supplierDTO.SupplierRelationDTO {
if len(relations) == 0 {
return make([]supplierDTO.SupplierRelationDTO, 0)
}
result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations))
for _, relation := range relations {
if relation.Supplier.Id == 0 {
continue
}
result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier))
}
if len(result) == 0 {
return make([]supplierDTO.SupplierRelationDTO, 0)
}
return result
}
@@ -20,6 +20,7 @@ type ProjectflockRepository interface {
GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error)
GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error)
GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error)
IdExists(ctx context.Context, id uint) (bool, error)
AreaExists(ctx context.Context, id uint) (bool, error)
FcrExists(ctx context.Context, id uint) (bool, error)
ProductionStandardExists(ctx context.Context, id uint) (bool, error)
@@ -161,6 +162,10 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s
)
}
func (r *ProjectflockRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProjectFlock](ctx, r.DB(), id)
}
func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.Area](ctx, r.DB(), id)
}
@@ -23,6 +23,7 @@ import (
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories"
purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -308,12 +309,12 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
}
createBody := &entity.ProjectFlock{
AreaId: req.AreaId,
Category: cat,
FcrId: req.FcrId,
AreaId: req.AreaId,
Category: cat,
FcrId: req.FcrId,
ProductionStandardId: req.ProductionStandardId,
LocationId: req.LocationId,
CreatedBy: actorID,
LocationId: req.LocationId,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
@@ -823,22 +824,7 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *
return nil
}
blocked, err := s.pivotRepoWithTx(dbTransaction).FindKandangsWithRecordings(ctx, projectFlockID, kandangIDs)
if err != nil {
s.Log.Errorf("Failed to check recordings before detaching kandangs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandang detachment")
}
if len(blocked) > 0 {
names := make([]string, 0, len(blocked))
for _, item := range blocked {
label := fmt.Sprintf("ID %d", item.Id)
if strings.TrimSpace(item.Name) != "" {
label = fmt.Sprintf("%s (%s)", label, item.Name)
}
names = append(names, label)
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", ")))
}
// NOTE: Recording constraints are enforced via FK cascade; allow detachment even if recordings exist.
pfkIDs, err := s.pivotRepoWithTx(dbTransaction).ListIDsByProjectAndKandang(ctx, projectFlockID, kandangIDs)
if err != nil {
@@ -854,6 +840,14 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *
return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove uniformity data for project flock kandang")
}
db := s.Repository.DB()
if dbTransaction != nil {
db = dbTransaction
}
purchaseRepo := purchaseRepository.NewPurchaseRepository(db)
if err := purchaseRepo.SoftDeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to soft delete purchases for project flock kandang")
}
pwRepo := s.ProductWarehouseRepo
if dbTransaction != nil {
pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction)
@@ -906,6 +900,11 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont
return nil
}
projectFlockID := records[0].ProjectFlockId
if projectFlockID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock id tidak ditemukan")
}
pwRepo := s.ProductWarehouseRepo
if dbTransaction != nil {
pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction)
@@ -920,24 +919,34 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont
warehouseRepo = warehouseRepository.NewWarehouseRepository(s.Repository.DB())
}
flags := []utils.FlagType{
utils.FlagAyamAfkir,
utils.FlagAyamCulling,
utils.FlagAyamMati,
utils.FlagTelurPecah,
utils.FlagTelurUtuh,
db := s.Repository.DB()
if dbTransaction != nil {
db = dbTransaction
}
var category string
if err := db.WithContext(ctx).
Model(&entity.ProjectFlock{}).
Select("category").
Where("id = ?", projectFlockID).
Scan(&category).Error; err != nil {
return err
}
if strings.TrimSpace(category) == "" {
return fiber.NewError(fiber.StatusBadRequest, "Project flock category tidak ditemukan")
}
productIDs := make(map[utils.FlagType]uint, len(flags))
for _, flag := range flags {
product, err := pwRepo.GetFirstProductByFlag(ctx, string(flag))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product untuk flag %s tidak ditemukan", flag))
}
return err
}
productIDs[flag] = product.Id
prefixes := []string{"AYAM-"}
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) {
prefixes = append(prefixes, "TELUR")
}
invisibleOnly := false
productIDs, err := pwRepo.ListProductIDsByFlagPrefixes(ctx, prefixes, &invisibleOnly)
if err != nil {
return err
}
if len(productIDs) == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product dengan flag %s tidak ditemukan", strings.Join(prefixes, ", ")))
}
for _, record := range records {
@@ -953,8 +962,7 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont
return err
}
for _, flag := range flags {
productID := productIDs[flag]
for _, productID := range productIDs {
if _, err := pwRepo.GetByProductWarehouseAndProjectFlockKandang(ctx, productID, warehouse.Id, record.Id); err == nil {
continue
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -25,16 +25,16 @@ type RecordingRelationDTO struct {
CumIntake int `json:"cum_intake"`
FcrValue float64 `json:"fcr_value"`
TotalChickQty float64 `json:"total_chick_qty"`
HandDay float64 `json:"hand_day"`
HandHouse float64 `json:"hand_house"`
HenDay float64 `json:"hen_day"`
HenHouse float64 `json:"hen_house"`
FeedIntake float64 `json:"feed_intake"`
EggMesh float64 `json:"egg_mesh"`
EggMass float64 `json:"egg_mass"`
EggWeight float64 `json:"egg_weight"`
StandardHandDay *float64 `json:"hand_day_std,omitempty"`
StandardHandHouse *float64 `json:"hand_house_std,omitempty"`
StandardHenDay *float64 `json:"hen_day_std,omitempty"`
StandardHenHouse *float64 `json:"hen_house_std,omitempty"`
StandardFeedIntake *float64 `json:"feed_intake_std,omitempty"`
StandardMaxDepletion *float64 `json:"max_depletion_std,omitempty"`
StandardEggMesh *float64 `json:"egg_mesh_std,omitempty"`
StandardEggMass *float64 `json:"egg_mass_std,omitempty"`
StandardEggWeight *float64 `json:"egg_weight_std,omitempty"`
StandardFcr *float64 `json:"fcr_std,omitempty"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
@@ -94,10 +94,10 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
cumIntake int
fcrValue float64
totalChickQty float64
handDay float64
handHouse float64
henDay float64
henHouse float64
feedIntake float64
eggMesh float64
eggMass float64
eggWeight float64
)
@@ -119,17 +119,17 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
if e.TotalChickQty != nil {
totalChickQty = *e.TotalChickQty
}
if e.HandDay != nil {
handDay = *e.HandDay
if e.HenDay != nil {
henDay = *e.HenDay
}
if e.HandHouse != nil {
handHouse = *e.HandHouse
if e.HenHouse != nil {
henHouse = *e.HenHouse
}
if e.FeedIntake != nil {
feedIntake = *e.FeedIntake
}
if e.EggMesh != nil {
eggMesh = *e.EggMesh
if e.EggMass != nil {
eggMass = *e.EggMass
}
if e.EggWeight != nil {
eggWeight = *e.EggWeight
@@ -157,16 +157,16 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
CumIntake: cumIntake,
FcrValue: fcrValue,
TotalChickQty: totalChickQty,
HandDay: handDay,
HandHouse: handHouse,
HenDay: henDay,
HenHouse: henHouse,
FeedIntake: feedIntake,
EggMesh: eggMesh,
EggMass: eggMass,
EggWeight: eggWeight,
StandardHandDay: e.StandardHandDay,
StandardHandHouse: e.StandardHandHouse,
StandardHenDay: e.StandardHenDay,
StandardHenHouse: e.StandardHenHouse,
StandardFeedIntake: e.StandardFeedIntake,
StandardMaxDepletion: e.StandardMaxDepletion,
StandardEggMesh: e.StandardEggMesh,
StandardEggMass: e.StandardEggMass,
StandardEggWeight: e.StandardEggWeight,
StandardFcr: e.StandardFcr,
Approval: latestApproval,
@@ -16,10 +16,10 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS
route.Use(m.Auth(u))
route.Get("/",m.RequirePermissions(m.P_RecordingGetAll), ctrl.GetAll)
route.Get("/next-day",m.RequirePermissions(m.P_RecordingNextDay), ctrl.GetNextDay)
route.Get("/:id",m.RequirePermissions(m.P_RecordingGetOne), ctrl.GetOne)
route.Post("/",m.RequirePermissions(m.P_RecordingCreateOne), ctrl.CreateOne)
route.Patch("/:id",m.RequirePermissions(m.P_RecordingUpdateOne), ctrl.UpdateOne)
route.Delete("/:id",m.RequirePermissions(m.P_RecordingDeleteOne), ctrl.DeleteOne)
route.Get("/next-day",m.RequirePermissions(m.P_RecordingNextDay), ctrl.GetNextDay)
route.Post("/approvals",m.RequirePermissions(m.P_RecordingApproval), ctrl.Approve)
}
@@ -901,47 +901,6 @@ type eggTotals struct {
Weight float64
}
type stockTotals struct {
Usage float64
Pending float64
Total float64
}
func summarizeExistingStocks(stocks []entity.RecordingStock) map[uint]stockTotals {
totals := make(map[uint]stockTotals)
for _, stock := range stocks {
var usage float64
var pending float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
current := totals[stock.ProductWarehouseId]
current.Usage += usage
current.Pending += pending
current.Total += usage + pending
totals[stock.ProductWarehouseId] = current
}
return totals
}
func summarizeIncomingStocks(stocks []validation.Stock) map[uint]stockTotals {
totals := make(map[uint]stockTotals)
for _, stock := range stocks {
var pending float64
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
current := totals[stock.ProductWarehouseId]
current.Usage += stock.Qty
current.Pending += pending
current.Total += stock.Qty + pending
totals[stock.ProductWarehouseId] = current
}
return totals
}
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
hasPending := false
@@ -1156,34 +1115,34 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
recording.FeedIntake = nil
}
var handDay float64
var henDay float64
if remainingChick > 0 && totalEggQty >= 0 {
handDay = (totalEggQty / remainingChick) * 100
updates["hand_day"] = handDay
recording.HandDay = &handDay
henDay = (totalEggQty / remainingChick) * 100
updates["hen_day"] = henDay
recording.HenDay = &henDay
} else {
updates["hand_day"] = gorm.Expr("NULL")
recording.HandDay = nil
updates["hen_day"] = gorm.Expr("NULL")
recording.HenDay = nil
}
var handHouse float64
var henHouse float64
if initialChickin > 0 && cumulativeEggQty >= 0 {
handHouse = cumulativeEggQty / initialChickin
updates["hand_house"] = handHouse
recording.HandHouse = &handHouse
henHouse = cumulativeEggQty / initialChickin
updates["hen_house"] = henHouse
recording.HenHouse = &henHouse
} else {
updates["hand_house"] = gorm.Expr("NULL")
recording.HandHouse = nil
updates["hen_house"] = gorm.Expr("NULL")
recording.HenHouse = nil
}
var eggMesh float64
var eggMass float64
if remainingChick > 0 && totalEggWeightGrams > 0 {
eggMesh = (totalEggWeightGrams / remainingChick) * 1000
updates["egg_mesh"] = eggMesh
recording.EggMesh = &eggMesh
eggMass = (totalEggWeightGrams / remainingChick) * 1000
updates["egg_mass"] = eggMass
recording.EggMass = &eggMass
} else {
updates["egg_mesh"] = gorm.Expr("NULL")
recording.EggMesh = nil
updates["egg_mass"] = gorm.Expr("NULL")
recording.EggMass = nil
}
var eggWeight float64
@@ -1334,11 +1293,11 @@ func (s *recordingService) attachLatestApproval(ctx context.Context, item *entit
}
type productionStandardValues struct {
HandDay *float64
HandHouse *float64
HenDay *float64
HenHouse *float64
FeedIntake *float64
MaxDepletion *float64
EggMesh *float64
EggMass *float64
EggWeight *float64
}
@@ -1389,10 +1348,10 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e
return err
}
if detail != nil {
standard.HandDay = detail.TargetHenDayProduction
standard.HandHouse = detail.TargetHenHouseProduction
standard.HenDay = detail.TargetHenDayProduction
standard.HenHouse = detail.TargetHenHouseProduction
standard.EggWeight = detail.TargetEggWeight
standard.EggMesh = detail.TargetEggMass
standard.EggMass = detail.TargetEggMass
}
}
@@ -1420,11 +1379,11 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e
}
}
item.StandardHandDay = standard.HandDay
item.StandardHandHouse = standard.HandHouse
item.StandardHenDay = standard.HenDay
item.StandardHenHouse = standard.HenHouse
item.StandardFeedIntake = standard.FeedIntake
item.StandardMaxDepletion = standard.MaxDepletion
item.StandardEggMesh = standard.EggMesh
item.StandardEggMass = standard.EggMass
item.StandardEggWeight = standard.EggWeight
item.StandardFcr = standardFcr
@@ -346,17 +346,31 @@ func buildChartWeekSummary(weights []float64) utypes.UniformityChartWeek {
minBucket := bucket
maxBucket := bucket + bucketSize - 1
count := 0.0
bucketWeights := make([]float64, 0)
idealWeights := make([]float64, 0)
outsideWeights := make([]float64, 0)
for _, w := range weights {
if w >= minBucket && w < minBucket+bucketSize {
count++
bucketWeights = append(bucketWeights, w)
if w >= idealMin && w <= idealMax {
idealWeights = append(idealWeights, w)
} else {
outsideWeights = append(outsideWeights, w)
}
}
}
idealRangeLabel := rangeFromValues(idealWeights)
outsideRangeLabel := rangeFromValues(outsideWeights)
isIdealRange := idealRangeLabel != ""
distribution = append(distribution, utypes.UniformityChartRange{
Range: fmt.Sprintf("%d-%d", int(minBucket), int(maxBucket)),
MinWeight: minBucket,
MaxWeight: maxBucket,
BirdCount: count,
IsIdealRange: minBucket >= idealMin && maxBucket <= idealMax,
IsIdealRange: isIdealRange,
IdealRange: idealRangeLabel,
OutsideRange: outsideRangeLabel,
})
}
@@ -391,3 +405,29 @@ func roundToPrecision(value float64, precision int) float64 {
}
return math.Floor(scaled) / scale
}
func rangeFromValues(values []float64) string {
if len(values) == 0 {
return ""
}
minValue := values[0]
maxValue := values[0]
for _, v := range values[1:] {
if v < minValue {
minValue = v
}
if v > maxValue {
maxValue = v
}
}
return formatRange(minValue, maxValue)
}
func formatRange(minValue, maxValue float64) string {
minInt := int(math.Round(minValue))
maxInt := int(math.Round(maxValue))
if minInt == maxInt {
return fmt.Sprintf("%d", minInt)
}
return fmt.Sprintf("%d-%d", minInt, maxInt)
}
@@ -199,14 +199,26 @@ func (s uniformityService) MapCharts(c *fiber.Ctx, items []entity.ProjectFlockKa
for _, group := range grouped {
allWeeks := make(map[int]utypes.UniformityChartWeek)
weekOrder := make([]int, 0, len(group))
weekSeen := make(map[int]struct{}, len(group))
weeksWithData := 0
gaugeWeeks := make([]utypes.UniformityChartGaugeWeek, 0, len(group))
latestByWeek := make(map[int]entity.ProjectFlockKandangUniformity)
for _, item := range group {
if item.Week == 0 {
continue
}
if existing, ok := latestByWeek[item.Week]; !ok || isUniformityNewer(item, existing) {
latestByWeek[item.Week] = item
}
}
for week := range latestByWeek {
weekOrder = append(weekOrder, week)
}
sort.Ints(weekOrder)
for _, week := range weekOrder {
item := latestByWeek[week]
var weekSummary utypes.UniformityChartWeek
if len(item.ChartData) > 0 {
if err := json.Unmarshal(item.ChartData, &weekSummary); err != nil {
@@ -222,16 +234,11 @@ func (s uniformityService) MapCharts(c *fiber.Ctx, items []entity.ProjectFlockKa
if weekSummary.HasData {
weeksWithData++
}
allWeeks[item.Week] = weekSummary
if _, ok := weekSeen[item.Week]; !ok {
weekSeen[item.Week] = struct{}{}
weekOrder = append(weekOrder, item.Week)
}
allWeeks[week] = weekSummary
hasData := item.ChickQtyOfWeight > 0
gaugeWeeks = append(gaugeWeeks, utypes.UniformityChartGaugeWeek{
Week: item.Week,
Week: week,
UniformityPercent: item.Uniformity,
IdealCount: item.UniformQty,
OutsideIdealCount: item.NotUniformQty,
@@ -240,11 +247,6 @@ func (s uniformityService) MapCharts(c *fiber.Ctx, items []entity.ProjectFlockKa
})
}
sort.Ints(weekOrder)
sort.Slice(gaugeWeeks, func(i, j int) bool {
return gaugeWeeks[i].Week < gaugeWeeks[j].Week
})
weekIndex := make(map[int]int, len(weekOrder))
for idx, week := range weekOrder {
weekIndex[week] = idx
@@ -280,6 +282,23 @@ func (s uniformityService) MapCharts(c *fiber.Ctx, items []entity.ProjectFlockKa
return result, nil
}
func isUniformityNewer(a, b entity.ProjectFlockKandangUniformity) bool {
var aDate, bDate time.Time
if a.UniformDate != nil {
aDate = *a.UniformDate
}
if b.UniformDate != nil {
bDate = *b.UniformDate
}
if !aDate.IsZero() || !bDate.IsZero() {
if aDate.Equal(bDate) {
return a.Id > b.Id
}
return aDate.After(bDate)
}
return a.Id > b.Id
}
func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) {
if s.DocumentSvc == nil || len(items) == 0 {
return map[uint]string{}, nil
@@ -586,13 +605,13 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
}
func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error {
if projectFlockKandangID == 0 || week == 0 || uniformDate == nil || uniformDate.IsZero() {
if projectFlockKandangID == 0 || week == 0 {
return nil
}
query := s.Repository.DB().WithContext(ctx).
Model(&entity.ProjectFlockKandangUniformity{}).
Where("project_flock_kandang_id = ? AND week = ? AND uniform_date = ?", projectFlockKandangID, week, *uniformDate)
Where("project_flock_kandang_id = ? AND week = ?", projectFlockKandangID, week)
if id != 0 {
query = query.Where("id <> ?", id)
}
@@ -602,7 +621,7 @@ func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint,
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity uniqueness")
}
if count > 0 {
return fiber.NewError(fiber.StatusConflict, "Uniformity already exists for the same project flock kandang, week, and date")
return fiber.NewError(fiber.StatusConflict, "Uniformity already exists for the same project flock kandang and week")
}
return nil
}
@@ -29,6 +29,8 @@ type UniformityChartRange struct {
MaxWeight float64 `json:"max_weight"`
BirdCount float64 `json:"bird_count"`
IsIdealRange bool `json:"is_ideal_range"`
IdealRange string `json:"ideal_range,omitempty"`
OutsideRange string `json:"outside_range,omitempty"`
}
type UniformityChartIdealRange struct {
@@ -82,4 +84,4 @@ type UniformityChartGauge struct {
type UniformityChartData struct {
BarChart UniformityChartBar `json:"bar_chart"`
GaugeChart UniformityChartGauge `json:"gauge_chart"`
}
}
@@ -25,6 +25,7 @@ type PurchaseRepository interface {
NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error)
NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error)
BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error
SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error)
GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error)
}
@@ -89,6 +90,44 @@ WHERE pi.purchase_id = ?
return r.DB().WithContext(ctx).Exec(query, purchaseID).Error
}
func (r *PurchaseRepositoryImpl) SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error {
if len(projectFlockKandangIDs) == 0 {
return nil
}
return r.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var purchaseIDs []uint
query := `
SELECT pi.purchase_id
FROM purchase_items pi
WHERE pi.project_flock_kandang_id IN (?)
GROUP BY pi.purchase_id
HAVING COUNT(*) = COUNT(CASE WHEN pi.project_flock_kandang_id IN (?) THEN 1 END)
`
if err := tx.Raw(query, projectFlockKandangIDs, projectFlockKandangIDs).Scan(&purchaseIDs).Error; err != nil {
return err
}
now := time.Now().UTC()
if len(purchaseIDs) > 0 {
if err := tx.Model(&entity.Purchase{}).
Where("id IN (?) AND deleted_at IS NULL", purchaseIDs).
Update("deleted_at", now).Error; err != nil {
return err
}
if err := tx.Where("purchase_id IN (?)", purchaseIDs).Delete(&entity.PurchaseItem{}).Error; err != nil {
return err
}
}
deleteItems := tx.Where("project_flock_kandang_id IN (?)", projectFlockKandangIDs)
if len(purchaseIDs) > 0 {
deleteItems = deleteItems.Where("purchase_id NOT IN (?)", purchaseIDs)
}
return deleteItems.Delete(&entity.PurchaseItem{}).Error
})
}
func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error {
if len(items) == 0 {
return nil
@@ -594,7 +594,7 @@ func (b *expenseBridge) createExpenseViaService(
req := &expenseValidation.Create{
PoNumber: "",
TransactionDate: utils.FormatDate(expenseDate),
Category: "BOP",
Category: string(utils.ExpenseCategoryBOP),
SupplierID: uint64(supplierID),
LocationID: locationID,
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{
@@ -133,6 +133,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db = db.Where("purchases.deleted_at IS NULL")
if params.SupplierID > 0 {
db = db.Where("supplier_id = ?", params.SupplierID)
@@ -107,21 +107,21 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs)
purchaseStockableKey := fifo.StockableKeyPurchaseItems.String()
transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String()
transferStockableKey := fifo.StockableKeyStockTransferIn.String()
query := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
k.id AS kandang_id,
COALESCE(SUM(CASE
COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
ELSE 0
END), 0) AS feed_cost,
COALESCE(SUM(CASE
COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
ELSE 0
END), 0) AS ovk_cost`,
utils.FlagPakan, transferStockableKey, utils.FlagPakan,
utils.FlagOVK, transferStockableKey, utils.FlagOVK).