From 9b016dc30a0035eaecec3cd9bd99fc6d9609ac3f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 14 Oct 2025 22:16:50 +0700 Subject: [PATCH 01/26] (BE-58,,59): extend db schema & build stock transfer api - Extend DB schema for stock transfers - Build stock transfer API (create,) --- ...1014024355_create_stock_transfers.down.sql | 4 + ...251014024355_create_stock_transfers.up.sql | 57 +++++ ...642_create_stock_transfer_details.down.sql | 2 + ...24642_create_stock_transfer_details.up.sql | 49 ++++ ..._create_stock_transfer_deliveries.down.sql | 2 + ...56_create_stock_transfer_deliveries.up.sql | 43 ++++ ...ate_stock_transfer_delivery_items.down.sql | 2 + ...reate_stock_transfer_delivery_items.up.sql | 35 +++ internal/entities/stock-transfer.go | 22 ++ internal/entities/stock_log.go | 1 + internal/entities/stock_transfer_delivery.go | 24 ++ .../entities/stock_transfer_delivery_item.go | 12 + internal/entities/stock_transfer_detail.go | 22 ++ internal/modules/inventory/route.go | 2 + .../controllers/transfer.controller.go | 92 +++++++ .../inventory/transfers/dto/transfer.dto.go | 64 +++++ .../modules/inventory/transfers/module.go | 31 +++ .../repositories/stock_transfer.repository.go | 34 +++ .../stock_transfer_delivery.repository.go | 22 ++ ...stock_transfer_delivery_item.repository.go | 22 ++ .../stock_transfer_detail.repository.go | 22 ++ internal/modules/inventory/transfers/route.go | 27 ++ .../transfers/services/transfer.service.go | 240 ++++++++++++++++++ .../validations/transfer.validation.go | 41 +++ internal/utils/time.go | 25 ++ 25 files changed, 897 insertions(+) create mode 100644 internal/database/migrations/20251014024355_create_stock_transfers.down.sql create mode 100644 internal/database/migrations/20251014024355_create_stock_transfers.up.sql create mode 100644 internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql create mode 100644 internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql create mode 100644 internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql create mode 100644 internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql create mode 100644 internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql create mode 100644 internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql create mode 100644 internal/entities/stock-transfer.go create mode 100644 internal/entities/stock_transfer_delivery.go create mode 100644 internal/entities/stock_transfer_delivery_item.go create mode 100644 internal/entities/stock_transfer_detail.go create mode 100644 internal/modules/inventory/transfers/controllers/transfer.controller.go create mode 100644 internal/modules/inventory/transfers/dto/transfer.dto.go create mode 100644 internal/modules/inventory/transfers/module.go create mode 100644 internal/modules/inventory/transfers/repositories/stock_transfer.repository.go create mode 100644 internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go create mode 100644 internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go create mode 100644 internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go create mode 100644 internal/modules/inventory/transfers/route.go create mode 100644 internal/modules/inventory/transfers/services/transfer.service.go create mode 100644 internal/modules/inventory/transfers/validations/transfer.validation.go create mode 100644 internal/utils/time.go diff --git a/internal/database/migrations/20251014024355_create_stock_transfers.down.sql b/internal/database/migrations/20251014024355_create_stock_transfers.down.sql new file mode 100644 index 00000000..c2d70451 --- /dev/null +++ b/internal/database/migrations/20251014024355_create_stock_transfers.down.sql @@ -0,0 +1,4 @@ +-- DROP TABLE: STOCK_TRANSFERS DAN SEQUENCE-NYA +DROP TABLE IF EXISTS stock_transfers CASCADE; + +DROP SEQUENCE IF EXISTS stock_transfer_seq CASCADE; \ No newline at end of file diff --git a/internal/database/migrations/20251014024355_create_stock_transfers.up.sql b/internal/database/migrations/20251014024355_create_stock_transfers.up.sql new file mode 100644 index 00000000..766afe77 --- /dev/null +++ b/internal/database/migrations/20251014024355_create_stock_transfers.up.sql @@ -0,0 +1,57 @@ +-- =============================================================== +-- STOCK TRANSFERS (HEADER) +-- =============================================================== + +CREATE SEQUENCE IF NOT EXISTS stock_transfer_seq START 1; + +CREATE TABLE IF NOT EXISTS stock_transfers ( + id BIGSERIAL PRIMARY KEY, + movement_number VARCHAR(50) UNIQUE NOT NULL, + from_warehouse_id BIGINT NOT NULL, + to_warehouse_id BIGINT NOT NULL, + area_id BIGINT, + reason TEXT, + transfer_date DATE NOT NULL, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'warehouses') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_from_warehouse + FOREIGN KEY (from_warehouse_id) + REFERENCES warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_to_warehouse + FOREIGN KEY (to_warehouse_id) + REFERENCES warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'areas') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_area + FOREIGN KEY (area_id) + REFERENCES areas(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfers_from_warehouse_id ON stock_transfers(from_warehouse_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_to_warehouse_id ON stock_transfers(to_warehouse_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_transfer_date ON stock_transfers(transfer_date); diff --git a/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql b/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql new file mode 100644 index 00000000..64c0c8ed --- /dev/null +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql @@ -0,0 +1,2 @@ +-- DROP TABLE: STOCK_TRANSFER_DETAILS +DROP TABLE IF EXISTS stock_transfer_details CASCADE; diff --git a/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql new file mode 100644 index 00000000..8ff8858c --- /dev/null +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql @@ -0,0 +1,49 @@ +-- =============================================================== +-- STOCK TRANSFER DETAILS (PRODUK) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_details ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0), + before_quantity NUMERIC(15, 3), + after_quantity NUMERIC(15, 3), + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- =============================================================== +-- FOREIGN KEYS (dengan pengecekan tabel agar anti gagal) +-- =============================================================== + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfers') THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_transfer + FOREIGN KEY (stock_transfer_id) + REFERENCES stock_transfers(id) + ON DELETE CASCADE ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'products') THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_product + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; +END $$; + +-- =============================================================== +-- INDEXES +-- =============================================================== + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_transfer_id ON stock_transfer_details (stock_transfer_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_product_id ON stock_transfer_details (product_id); \ No newline at end of file diff --git a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql new file mode 100644 index 00000000..5167737f --- /dev/null +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql @@ -0,0 +1,2 @@ +-- DROP TABLE: STOCK_TRANSFER_DELIVERIES +DROP TABLE IF EXISTS stock_transfer_deliveries CASCADE; diff --git a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql new file mode 100644 index 00000000..f5887b16 --- /dev/null +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql @@ -0,0 +1,43 @@ +-- =============================================================== +-- STOCK TRANSFER DELIVERIES (EKSPEDISI) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_deliveries ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_id BIGINT NOT NULL, + supplier_id BIGINT, + vehicle_plate VARCHAR(20), + driver_name VARCHAR(100), + document_number VARCHAR(50), + document_path TEXT, + shipping_cost_item NUMERIC(15,3), + shipping_cost_total NUMERIC(15,3), + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfers') THEN + ALTER TABLE stock_transfer_deliveries + ADD CONSTRAINT fk_stock_transfer_deliveries_transfer + FOREIGN KEY (stock_transfer_id) + REFERENCES stock_transfers(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN + ALTER TABLE stock_transfer_deliveries + ADD CONSTRAINT fk_stock_transfer_deliveries_supplier + FOREIGN KEY (supplier_id) + REFERENCES suppliers(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfer_deliveries_transfer_id ON stock_transfer_deliveries(stock_transfer_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfer_deliveries_supplier_id ON stock_transfer_deliveries(supplier_id); diff --git a/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql new file mode 100644 index 00000000..15e1253d --- /dev/null +++ b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql @@ -0,0 +1,2 @@ +-- DROP PIVOT TABLE: STOCK_TRANSFER_DELIVERY_ITEMS +DROP TABLE IF EXISTS stock_transfer_delivery_items CASCADE; diff --git a/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql new file mode 100644 index 00000000..cb4c7a11 --- /dev/null +++ b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql @@ -0,0 +1,35 @@ +-- =============================================================== +-- STOCK TRANSFER DELIVERY ITEMS (PIVOT) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_delivery_items ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_delivery_id BIGINT NOT NULL, + stock_transfer_detail_id BIGINT NOT NULL, + quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0) +); + +-- FOREIGN KEYS +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfer_deliveries') THEN + ALTER TABLE stock_transfer_delivery_items + ADD CONSTRAINT fk_delivery_items_delivery + FOREIGN KEY (stock_transfer_delivery_id) + REFERENCES stock_transfer_deliveries(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfer_details') THEN + ALTER TABLE stock_transfer_delivery_items + ADD CONSTRAINT fk_delivery_items_detail + FOREIGN KEY (stock_transfer_detail_id) + REFERENCES stock_transfer_details(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfer_delivery_items_delivery_id ON stock_transfer_delivery_items (stock_transfer_delivery_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_delivery_items_detail_id ON stock_transfer_delivery_items (stock_transfer_detail_id); \ No newline at end of file diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go new file mode 100644 index 00000000..ca615f2d --- /dev/null +++ b/internal/entities/stock-transfer.go @@ -0,0 +1,22 @@ +package entities + +import "time" + +// HEADER +type StockTransfer struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + MovementNumber string `gorm:"uniqueIndex;not null"` + FromWarehouseId uint64 + ToWarehouseId uint64 + TransferDate time.Time + Reason string + CreatedBy uint64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + FromWarehouse *Warehouse `gorm:"foreignKey:FromWarehouseId"` + ToWarehouse *Warehouse `gorm:"foreignKey:ToWarehouseId"` + Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` + Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` +} diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 21e86bd4..6546e790 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -8,6 +8,7 @@ import ( const ( LogTypeAdjustment = "ADJUSTMENT" + LogTypeTransfer = "TRANSFER" ) const ( diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go new file mode 100644 index 00000000..bd156389 --- /dev/null +++ b/internal/entities/stock_transfer_delivery.go @@ -0,0 +1,24 @@ +package entities + +import "time" + +// DETAIL EKSPEDISI +type StockTransferDelivery struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + Note string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Supplier *Supplier `gorm:"foreignKey:SupplierId"` + Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` +} \ No newline at end of file diff --git a/internal/entities/stock_transfer_delivery_item.go b/internal/entities/stock_transfer_delivery_item.go new file mode 100644 index 00000000..cbfa05fb --- /dev/null +++ b/internal/entities/stock_transfer_delivery_item.go @@ -0,0 +1,12 @@ +package entities + +// PIVOT TABLE TRANSFER +type StockTransferDeliveryItem struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferDeliveryId uint64 + StockTransferDetailId uint64 + Quantity float64 + // Relations + StockTransferDelivery *StockTransferDelivery `gorm:"foreignKey:StockTransferDeliveryId"` + StockTransferDetail *StockTransferDetail `gorm:"foreignKey:StockTransferDetailId"` +} diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go new file mode 100644 index 00000000..2a3f2fcf --- /dev/null +++ b/internal/entities/stock_transfer_detail.go @@ -0,0 +1,22 @@ +package entities + +import "time" + + +// DETAIL PRODUK +type StockTransferDetail struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + ProductId uint64 + Quantity float64 + BeforeQuantity float64 + AfterQuantity float64 + Note string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` +} \ No newline at end of file diff --git a/internal/modules/inventory/route.go b/internal/modules/inventory/route.go index f37e8cad..fcb7881a 100644 --- a/internal/modules/inventory/route.go +++ b/internal/modules/inventory/route.go @@ -9,6 +9,7 @@ import ( productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" + transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" // MODULE IMPORTS ) @@ -19,6 +20,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida productWarehouses.ProductWarehouseModule{}, adjustments.AdjustmentModule{}, + transfers.TransferModule{}, // MODULE REGISTRY } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go new file mode 100644 index 00000000..d499639e --- /dev/null +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransferController struct { + TransferService service.TransferService +} + +func NewTransferController(transferService service.TransferService) *TransferController { + return &TransferController{ + TransferService: transferService, + } +} + +func (u *TransferController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.TransferService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransferListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transfers successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToTransferListDTOs(result), + }) +} + +func (u *TransferController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.TransferService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transfer successfully", + Data: dto.ToTransferListDTO(*result), + }) +} + +func (u *TransferController) CreateOne(c *fiber.Ctx) error { + var req validation.TransferRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.TransferService.CreateOne(c, &req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create transfer successfully", + Data: dto.ToTransferListDTO(*result), + }) +} diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go new file mode 100644 index 00000000..5ef74c14 --- /dev/null +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -0,0 +1,64 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// === DTO Structs === + +type TransferBaseDTO struct { + Id uint64 `json:"id"` + TransferReason string `json:"transfer_reason"` + TransferDate string `json:"transfer_date"` + SourceWarehouseId uint64 `json:"source_warehouse_id"` + DestinationWarehouseId uint64 `json:"destination_warehouse_id"` +} + +type TransferListDTO struct { + TransferBaseDTO + CreatedBy uint64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TransferDetailDTO struct { + TransferListDTO + // Tambahkan detail produk, deliveries, dsb jika perlu +} + +// === Mapper Functions === + +func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { + return TransferBaseDTO{ + Id: e.Id, + TransferReason: e.Reason, // atau field lain sesuai entity + TransferDate: e.CreatedAt.Format("2006-01-02"), + SourceWarehouseId: e.FromWarehouseId, + DestinationWarehouseId: e.ToWarehouseId, + } +} + +func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { + return TransferListDTO{ + TransferBaseDTO: ToTransferBaseDTO(e), + CreatedBy: e.CreatedBy, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { + result := make([]TransferListDTO, len(e)) + for i, r := range e { + result[i] = ToTransferListDTO(r) + } + return result +} + +func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { + return TransferDetailDTO{ + TransferListDTO: ToTransferListDTO(e), + } +} diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go new file mode 100644 index 00000000..fa0047d4 --- /dev/null +++ b/internal/modules/inventory/transfers/module.go @@ -0,0 +1,31 @@ +package transfers + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + 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" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransferModule struct{} + +func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + stockTransferRepo := rStockTransfer.NewStockTransferRepository(db) + stockTransferDetailRepo := rStockTransfer.NewStockTransferDetailRepository(db) + stockTransferDeliveryRepo := rStockTransfer.NewStockTransferDeliveryRepository(db) + StockTransferDeliveryItemRepo := rStockTransfer.NewStockTransferDeliveryItemRepository(db) + stockLogsRepo := rStockLogs.NewStockLogRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + userRepo := rUser.NewUserRepository(db) + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo) + userService := sUser.NewUserService(userRepo, validate) + + TransferRoutes(router, userService, transferService) +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go new file mode 100644 index 00000000..e79d6310 --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go @@ -0,0 +1,34 @@ +package repositories + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferRepository interface { + repository.BaseRepository[entity.StockTransfer] + // get sequence for movement number + GetNextMovementNumber(ctx context.Context) (int64, error) +} + +type StockTransferRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransfer] +} + +func NewStockTransferRepository(db *gorm.DB) StockTransferRepository { + return &StockTransferRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransfer](db), + } +} + +func (r *StockTransferRepositoryImpl) GetNextMovementNumber(ctx context.Context) (int64, error) { + var seq int64 + err := r.DB().WithContext(ctx).Raw("SELECT nextval('stock_transfer_seq')").Scan(&seq).Error + if err != nil { + return 0, err + } + return seq, nil +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go new file mode 100644 index 00000000..ae0bfcf5 --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go @@ -0,0 +1,22 @@ +package repositories + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDeliveryRepository interface { + repository.BaseRepository[entity.StockTransferDelivery] + // Tambahkan custom method jika perlu +} + +type StockTransferDeliveryRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDelivery] +} + +func NewStockTransferDeliveryRepository(db *gorm.DB) StockTransferDeliveryRepository { + return &StockTransferDeliveryRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDelivery](db), + } +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go new file mode 100644 index 00000000..86ba0e9b --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go @@ -0,0 +1,22 @@ +package repositories + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDeliveryItemRepository interface { + repository.BaseRepository[entity.StockTransferDeliveryItem] + // Tambahkan custom method jika perlu +} + +type StockTransferDeliveryItemRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDeliveryItem] +} + +func NewStockTransferDeliveryItemRepository(db *gorm.DB) StockTransferDeliveryItemRepository { + return &StockTransferDeliveryItemRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDeliveryItem](db), + } +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go new file mode 100644 index 00000000..7c8ab63d --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go @@ -0,0 +1,22 @@ +package repositories + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDetailRepository interface { + repository.BaseRepository[entity.StockTransferDetail] + // Tambahkan custom method jika perlu +} + +type StockTransferDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDetail] +} + +func NewStockTransferDetailRepository(db *gorm.DB) StockTransferDetailRepository { + return &StockTransferDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDetail](db), + } +} diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go new file mode 100644 index 00000000..544a0674 --- /dev/null +++ b/internal/modules/inventory/transfers/route.go @@ -0,0 +1,27 @@ +package transfers + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/controllers" + transfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferService) { + ctrl := controller.NewTransferController(s) + + route := v1.Group("/transfers") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + +} diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go new file mode 100644 index 00000000..ddc47c02 --- /dev/null +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -0,0 +1,240 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type TransferService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) +} + +type transferService struct { + Log *logrus.Logger + Validate *validator.Validate + StockTransferRepo rStockTransfer.StockTransferRepository + StockTransferDetailRepo rStockTransfer.StockTransferDetailRepository + StockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository + StockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository + StockLogsRepository rStockLogs.StockLogRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository +} + +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository) TransferService { + return &transferService{ + Log: utils.Log, + Validate: validate, + StockTransferRepo: stockTransferRepo, + StockTransferDetailRepo: stockTransferDetailRepo, + StockTransferDeliveryRepo: stockTransferDeliveryRepo, + StockTransferDeliveryItemRepo: stockTransferDeliveryItemRepo, + StockLogsRepository: stockLogsRepo, + ProductWarehouseRepo: productWarehouseRepo, + } +} + +func (s transferService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + // offset := (params.Page - 1) * params.Limit + + // transfers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + // db = s.withRelations(db) + // if params.Search != "" { + // return db.Where("name LIKE ?", "%"+params.Search+"%") + // } + // return db.Order("created_at DESC").Order("updated_at DESC") + // }) + + // if err != nil { + // s.Log.Errorf("Failed to get transfers: %+v", err) + // return nil, 0, err + // } + return nil, 0, nil +} + +func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { + + return nil, nil +} + +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + // Validasi stok di gudang asal + 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("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek stok produk di gudang asal") + } + if sourcePW.Quantity < product.ProductQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) + } + } + + // Generate movement number + // Format: PND-MBU-00001 + seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) + if err != nil { + s.Log.Errorf("Failed to get next movement number: %+v", err) + 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{ + FromWarehouseId: uint64(req.SourceWarehouseID), + ToWarehouseId: uint64(req.DestinationWarehouseID), + Reason: req.TransferReason, + TransferDate: transferDate, + MovementNumber: movementNumber, + CreatedBy: 1, //todo: get from token + } + + // Save the transfer entity to the database + err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + + // Insert header + if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer: %+v", err) + return err + } + s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) + + // insert ke details + var details []*entity.StockTransferDetail + for _, product := range req.Products { + details = append(details, &entity.StockTransferDetail{ + StockTransferId: entityTransfer.Id, + ProductId: uint64(product.ProductID), + Quantity: product.ProductQty, + }) + } + if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer details: %+v", err) + return err + } + s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) + + // Tambahkan proses insert delivery + var deliveries []*entity.StockTransferDelivery + for _, delivery := range req.Deliveries { + deliveries = append(deliveries, &entity.StockTransferDelivery{ + StockTransferId: entityTransfer.Id, + SupplierId: uint64(delivery.SupplierID), + VehiclePlate: delivery.VehiclePlate, + DriverName: delivery.DriverName, + DocumentPath: delivery.Document, + ShippingCostItem: delivery.DeliveryCostPerItem, + ShippingCostTotal: delivery.DeliveryCost, + }) + } + if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) + return err + } + + // tambahkan insert ke delivery items sebagai fivot + var deliveryItems []*entity.StockTransferDeliveryItem + for i, delivery := range req.Deliveries { + for _, item := range delivery.Products { + deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ + StockTransferDeliveryId: deliveries[i].Id, + StockTransferDetailId: uint64(item.ProductID), + Quantity: item.ProductQty, + }) + } + } + if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err) + return err + } + s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) + + // Proses pengurangan stok di gudang asal dan penambahan stok di gudang tujuan + for _, product := range req.Products { + // Kurangi stok di gudang asal + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) + if err != nil { + s.Log.Errorf("Failed to get source product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") + } + if sourcePW.Quantity < product.ProductQty { + s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) + } + sourcePW.Quantity -= product.ProductQty + if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { + s.Log.Errorf("Failed to update source product warehouse: %+v", err) + return err + } + s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) + + // Tambah stok di gudang tujuan + destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get destination product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") + } + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + // Jika belum ada record untuk produk di gudang tujuan, buat baru + destPW = &entity.ProductWarehouse{ + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + CreatedBy: 1, // TODO: should Get from auth middleware + } + if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + s.Log.Errorf("Failed to create destination product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") + } + s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) + } + destPW.Quantity += product.ProductQty + if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { + s.Log.Errorf("Failed to update destination product warehouse: %+v", err) + return err + } + s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) + } + + + return nil + }) + + if err != nil { + s.Log.Errorf("Transaction failed in CreateOne: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + } + + return entityTransfer, nil +} diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go new file mode 100644 index 00000000..05c7215d --- /dev/null +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -0,0 +1,41 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} + +type TransferProduct struct { + ProductID uint `json:"product_id" validate:"required"` + ProductQty float64 `json:"product_qty" validate:"required,gt=0"` +} + +type TransferDeliveryProduct struct { + ProductID uint `json:"product_id" validate:"required"` + ProductQty float64 `json:"product_qty" validate:"required,gt=0"` +} + +type TransferDelivery struct { + DeliveryCost float64 `json:"delivery_cost" validate:"required"` + DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` + Document string `json:"document"` + DriverName string `json:"driver_name" validate:"required"` + DeliveryNoteNumber string `json:"delivery_note_number" validate:"required"` + VehiclePlate string `json:"vehicle_plate" validate:"required"` + SupplierID uint `json:"supplier_id" validate:"required"` + Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` +} + +type TransferRequest struct { + TransferReason string `json:"transfer_reason" validate:"required"` + TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` + SourceWarehouseID uint `json:"source_warehouse_id" validate:"required"` + DestinationWarehouseID uint `json:"destination_warehouse_id" validate:"required"` + Products []TransferProduct `json:"products" validate:"required,dive"` + Deliveries []TransferDelivery `json:"deliveries" validate:"required,dive"` +} diff --git a/internal/utils/time.go b/internal/utils/time.go new file mode 100644 index 00000000..f57a3bb3 --- /dev/null +++ b/internal/utils/time.go @@ -0,0 +1,25 @@ +package utils + +import ( + "time" + "errors" +) + +// ParseDateString mengubah string "YYYY-MM-DD" menjadi time.Time +func ParseDateString(dateStr string) (time.Time, error) { + if dateStr == "" { + return time.Time{}, errors.New("date string is empty") + } + + parsed, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return time.Time{}, errors.New("invalid date format, expected YYYY-MM-DD") + } + + return parsed, nil +} + +// FormatDate mengubah time.Time menjadi string "YYYY-MM-DD" +func FormatDate(t time.Time) string { + return t.Format("2006-01-02") +} From d1b377ddaccae82cc165a79861d1f7e7fbba69b1 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 15 Oct 2025 11:20:32 +0700 Subject: [PATCH 02/26] feat(BE-58,59,60,61): implement stock transfer API, validation, audit log, and schema update - Build stock transfer API with nested details, deliveries, and items - Extend DB schema for stock transfers - Implement validation for transfer request and stock - Prepare/implement transfer audit log structure - Preload all relations for complete response - Update DTOs for nested response - Remove redundant root fields, use relation objects --- ...20250925040409_create_master_tables.up.sql | 2 +- internal/entities/stock-transfer.go | 1 + .../inventory/transfers/dto/transfer.dto.go | 157 ++++++++++++++++-- .../stock_transfer_detail.repository.go | 9 +- .../transfers/services/transfer.service.go | 84 +++++++--- .../validations/transfer.validation.go | 1 - 6 files changed, 210 insertions(+), 44 deletions(-) diff --git a/internal/database/migrations/20250925040409_create_master_tables.up.sql b/internal/database/migrations/20250925040409_create_master_tables.up.sql index 07e3005a..09b1c46e 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.up.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.up.sql @@ -316,7 +316,7 @@ CREATE TABLE stock_logs ( before_quantity NUMERIC(15, 3) NOT NULL, after_quantity NUMERIC(15, 3) NOT NULL, log_type VARCHAR(50) NOT NULL, - log_id BIGINT , + log_id BIGINT, note TEXT, product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE, created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go index ca615f2d..e003d601 100644 --- a/internal/entities/stock-transfer.go +++ b/internal/entities/stock-transfer.go @@ -19,4 +19,5 @@ type StockTransfer struct { ToWarehouse *Warehouse `gorm:"foreignKey:ToWarehouseId"` Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` + CreatedUser *User `gorm:"foreignKey:CreatedBy"` } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 5ef74c14..10bc820b 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -4,48 +4,146 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) // === DTO Structs === type TransferBaseDTO struct { - Id uint64 `json:"id"` - TransferReason string `json:"transfer_reason"` - TransferDate string `json:"transfer_date"` - SourceWarehouseId uint64 `json:"source_warehouse_id"` - DestinationWarehouseId uint64 `json:"destination_warehouse_id"` + Id uint64 `json:"id"` + TransferReason string `json:"transfer_reason"` + TransferDate string `json:"transfer_date"` + SourceWarehouse *WarehouseSimpleDTO `json:"source_warehouse,omitempty"` + DestinationWarehouse *WarehouseSimpleDTO `json:"destination_warehouse,omitempty"` +} + +// Only id and name for warehouse simple view +type WarehouseSimpleDTO struct { + Id uint `json:"id"` + Name string `json:"name"` } type TransferListDTO struct { TransferBaseDTO - CreatedBy uint64 `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Details []TransferDetailItemDTO `json:"details"` + Deliveries []TransferDeliveryDTO `json:"deliveries"` } type TransferDetailDTO struct { TransferListDTO - // Tambahkan detail produk, deliveries, dsb jika perlu + Details []TransferDetailItemDTO `json:"details"` + Deliveries []TransferDeliveryDTO `json:"deliveries"` +} + +// Detail produk +type TransferDetailItemDTO struct { + Id uint64 `json:"id"` + ProductId uint64 `json:"product_id"` + Quantity float64 `json:"quantity"` + BeforeQuantity float64 `json:"before_quantity"` + AfterQuantity float64 `json:"after_quantity"` + Note string `json:"note"` +} + +// Delivery ekspedisi +type TransferDeliveryDTO struct { + Id uint64 `json:"id"` + SupplierId uint64 `json:"supplier_id"` + VehiclePlate string `json:"vehicle_plate"` + DriverName string `json:"driver_name"` + DocumentNumber string `json:"document_number"` + DocumentPath string `json:"document_path"` + ShippingCostItem float64 `json:"shipping_cost_item"` + ShippingCostTotal float64 `json:"shipping_cost_total"` + Note string `json:"note"` + Items []TransferDeliveryItemDTO `json:"items"` +} + +type TransferDeliveryItemDTO struct { + Id uint64 `json:"id"` + StockTransferDetailId uint64 `json:"stock_transfer_detail_id"` + Quantity float64 `json:"quantity"` } // === Mapper Functions === func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { + var sourceWarehouse *WarehouseSimpleDTO + if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { + sourceWarehouse = &WarehouseSimpleDTO{ + Id: e.FromWarehouse.Id, + Name: e.FromWarehouse.Name, + } + } + var destinationWarehouse *WarehouseSimpleDTO + if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 { + destinationWarehouse = &WarehouseSimpleDTO{ + Id: e.ToWarehouse.Id, + Name: e.ToWarehouse.Name, + } + } return TransferBaseDTO{ - Id: e.Id, - TransferReason: e.Reason, // atau field lain sesuai entity - TransferDate: e.CreatedAt.Format("2006-01-02"), - SourceWarehouseId: e.FromWarehouseId, - DestinationWarehouseId: e.ToWarehouseId, + Id: e.Id, + TransferReason: e.Reason, + TransferDate: e.CreatedAt.Format("2006-01-02"), + SourceWarehouse: sourceWarehouse, + DestinationWarehouse: destinationWarehouse, } } func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser != nil { + mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) + createdUser = &mapped + } + // Map details + var details []TransferDetailItemDTO + for _, d := range e.Details { + details = append(details, TransferDetailItemDTO{ + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, + BeforeQuantity: d.BeforeQuantity, + AfterQuantity: d.AfterQuantity, + Note: d.Note, + }) + } + // Map deliveries + var deliveries []TransferDeliveryDTO + for _, del := range e.Deliveries { + // Map delivery items + var items []TransferDeliveryItemDTO + for _, item := range del.Items { + items = append(items, TransferDeliveryItemDTO{ + Id: item.Id, + StockTransferDetailId: item.StockTransferDetailId, + Quantity: item.Quantity, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ + Id: del.Id, + SupplierId: del.SupplierId, + VehiclePlate: del.VehiclePlate, + DriverName: del.DriverName, + DocumentNumber: del.DocumentNumber, + DocumentPath: del.DocumentPath, + ShippingCostItem: del.ShippingCostItem, + ShippingCostTotal: del.ShippingCostTotal, + Note: del.Note, + Items: items, + }) + } return TransferListDTO{ TransferBaseDTO: ToTransferBaseDTO(e), - CreatedBy: e.CreatedBy, + CreatedUser: createdUser, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, + Details: details, + Deliveries: deliveries, } } @@ -58,7 +156,36 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { } func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { + // Map details + var details []TransferDetailItemDTO + for _, d := range e.Details { + details = append(details, TransferDetailItemDTO{ + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, + BeforeQuantity: d.BeforeQuantity, + AfterQuantity: d.AfterQuantity, + Note: d.Note, + }) + } + // Map deliveries + var deliveries []TransferDeliveryDTO + for _, del := range e.Deliveries { + deliveries = append(deliveries, TransferDeliveryDTO{ + Id: del.Id, + SupplierId: del.SupplierId, + VehiclePlate: del.VehiclePlate, + DriverName: del.DriverName, + DocumentNumber: del.DocumentNumber, + DocumentPath: del.DocumentPath, + ShippingCostItem: del.ShippingCostItem, + ShippingCostTotal: del.ShippingCostTotal, + Note: del.Note, + }) + } return TransferDetailDTO{ TransferListDTO: ToTransferListDTO(e), + Details: details, + Deliveries: deliveries, } } diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go index 7c8ab63d..fa9afd57 100644 --- a/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go @@ -1,6 +1,10 @@ +// Find all details by StockTransferId + package repositories import ( + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -8,7 +12,7 @@ import ( type StockTransferDetailRepository interface { repository.BaseRepository[entity.StockTransferDetail] - // Tambahkan custom method jika perlu + FindByTransferId(ctx context.Context, transferId uint64, out *[]entity.StockTransferDetail) error } type StockTransferDetailRepositoryImpl struct { @@ -20,3 +24,6 @@ func NewStockTransferDetailRepository(db *gorm.DB) StockTransferDetailRepository BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDetail](db), } } +func (r *StockTransferDetailRepositoryImpl) FindByTransferId(ctx context.Context, transferId uint64, out *[]entity.StockTransferDetail) error { + return r.DB().WithContext(ctx).Where("stock_transfer_id = ?", transferId).Find(out).Error +} diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index ddc47c02..80267e0c 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -46,9 +47,13 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr ProductWarehouseRepo: productWarehouseRepo, } } - func (s transferService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser") + return db. + Preload("CreatedUser"). + Preload("FromWarehouse"). + Preload("ToWarehouse"). + Preload("Details"). + Preload("Deliveries.Items") } func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { @@ -56,26 +61,37 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - // offset := (params.Page - 1) * params.Limit + offset := (params.Page - 1) * params.Limit - // transfers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - // db = s.withRelations(db) - // if params.Search != "" { - // return db.Where("name LIKE ?", "%"+params.Search+"%") - // } - // return db.Order("created_at DESC").Order("updated_at DESC") - // }) + transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = db.Preload("CreatedUser").Preload("FromWarehouse").Preload("ToWarehouse").Preload("Details").Preload("Deliveries.Items") + if params.Search != "" { + db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + return nil, 0, err + } + + s.Log.Infof("Retrieved %d transfers", len(transfers)) + + return transfers, total, nil - // if err != nil { - // s.Log.Errorf("Failed to get transfers: %+v", err) - // return nil, 0, err - // } - return nil, 0, nil } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - - return nil, nil + var transfer entity.StockTransfer + db := s.StockTransferRepo.DB().WithContext(c.Context()) + db = db.Preload("CreatedUser").Preload("FromWarehouse").Preload("ToWarehouse").Preload("Details").Preload("Deliveries.Items") + if err := db.First(&transfer, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") + } + return &transfer, nil } func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { @@ -160,16 +176,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) return err } - // tambahkan insert ke delivery items sebagai fivot + + detailMap := make(map[uint64]uint64) + for _, d := range details { + detailMap[d.ProductId] = d.Id + } + var deliveryItems []*entity.StockTransferDeliveryItem - for i, delivery := range req.Deliveries { - for _, item := range delivery.Products { - deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ - StockTransferDeliveryId: deliveries[i].Id, - StockTransferDetailId: uint64(item.ProductID), - Quantity: item.ProductQty, - }) + + for _, delivery := range deliveries { + for _, item := range req.Deliveries { + if item.Document == delivery.DocumentPath { + for _, prod := range item.Products { + detailID, ok := detailMap[uint64(prod.ProductID)] + if !ok { + return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) + } + + deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ + StockTransferDeliveryId: delivery.Id, + StockTransferDetailId: detailID, + Quantity: prod.ProductQty, + }) + } + } } } if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { @@ -225,8 +256,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) - } + + } return nil }) diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go index 05c7215d..778bdef4 100644 --- a/internal/modules/inventory/transfers/validations/transfer.validation.go +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -25,7 +25,6 @@ type TransferDelivery struct { DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` Document string `json:"document"` DriverName string `json:"driver_name" validate:"required"` - DeliveryNoteNumber string `json:"delivery_note_number" validate:"required"` VehiclePlate string `json:"vehicle_plate" validate:"required"` SupplierID uint `json:"supplier_id" validate:"required"` Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` From 4107cf19ec433a64056c9695e9ef9fd4b8056ae8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 15 Oct 2025 22:25:50 +0700 Subject: [PATCH 03/26] feat(BE-59,60,61): build stock transfer API with validation and audit log - Implement CreateOne for stock transfer with multi-delivery and validation - Preload warehouse, location, and area relations in transfer response - Add audit log for transfer - Improve transaction handling and error management --- ...24642_create_stock_transfer_details.up.sql | 1 - ...56_create_stock_transfer_deliveries.up.sql | 1 - internal/database/seed/seeder.go | 84 ++++++++++++++++- internal/entities/stock_log.go | 1 - internal/entities/stock_transfer_delivery.go | 1 - internal/entities/stock_transfer_detail.go | 28 +++--- .../controllers/transfer.controller.go | 13 ++- .../inventory/transfers/dto/transfer.dto.go | 93 +++++++++++++------ .../transfers/services/transfer.service.go | 72 ++++++++------ .../validations/transfer.validation.go | 2 +- 10 files changed, 219 insertions(+), 77 deletions(-) diff --git a/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql index 8ff8858c..090014ff 100644 --- a/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql @@ -9,7 +9,6 @@ CREATE TABLE IF NOT EXISTS stock_transfer_details ( quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0), before_quantity NUMERIC(15, 3), after_quantity NUMERIC(15, 3), - note TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ diff --git a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql index f5887b16..52e5b5c2 100644 --- a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql @@ -12,7 +12,6 @@ CREATE TABLE IF NOT EXISTS stock_transfer_deliveries ( document_path TEXT, shipping_cost_item NUMERIC(15,3), shipping_cost_total NUMERIC(15,3), - note TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index b321a784..11717d2c 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -78,6 +78,10 @@ func Run(db *gorm.DB) error { return err } + if err := seedTransferStock(tx, adminID); err != nil { + return err + } + fmt.Println("✅ Master data seeding completed") return nil }) @@ -775,7 +779,7 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { }{ {ProductID: 1, WarehouseID: 1, Quantity: 100}, {ProductID: 2, WarehouseID: 2, Quantity: 200}, - {ProductID: 1, WarehouseID: 1, Quantity: 300}, + {ProductID: 2, WarehouseID: 1, Quantity: 300}, } for _, seed := range seeds { @@ -799,6 +803,84 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { return nil } +func seedTransferStock(tx *gorm.DB, createdBy uint) error { + // Seeder Transfer Stock + // 1. Insert StockTransfer (header) + transfer := entity.StockTransfer{ + FromWarehouseId: 1, + ToWarehouseId: 2, + Reason: "Seed transfer stock", + TransferDate: time.Now(), + MovementNumber: "SEED-TRF-00001", + CreatedBy: 1, + } + if err := tx.Create(&transfer).Error; err != nil { + return err + } + + // 2. Insert StockTransferDetail (detail) + details := []entity.StockTransferDetail{ + { + StockTransferId: transfer.Id, + ProductId: 1, + Quantity: 10, + }, + { + StockTransferId: transfer.Id, + ProductId: 2, + Quantity: 5, + }, + } + for i := range details { + if err := tx.Create(&details[i]).Error; err != nil { + return err + } + } + + // 3. Insert StockTransferDelivery (delivery) + deliveries := []entity.StockTransferDelivery{ + { + StockTransferId: transfer.Id, + SupplierId: 1, + VehiclePlate: "B 1234 XYZ", + DriverName: "Driver Seed", + DocumentPath: "seed.pdf", + ShippingCostItem: 1000, + ShippingCostTotal: 2000, + }, + } + for i := range deliveries { + if err := tx.Create(&deliveries[i]).Error; err != nil { + return err + } + } + + detailMap := make(map[uint64]uint64) + for _, d := range details { + detailMap[d.ProductId] = d.Id + } + + deliveryItems := []entity.StockTransferDeliveryItem{ + { + StockTransferDeliveryId: deliveries[0].Id, + StockTransferDetailId: detailMap[1], + Quantity: 50, + }, + { + StockTransferDeliveryId: deliveries[0].Id, + StockTransferDetailId: detailMap[2], + Quantity: 30, + }, + } + for i := range deliveryItems { + if err := tx.Create(&deliveryItems[i]).Error; err != nil { + return err + } + } + + return nil +} + func ptr[T any](v T) *T { return &v } diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 6546e790..21e86bd4 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -8,7 +8,6 @@ import ( const ( LogTypeAdjustment = "ADJUSTMENT" - LogTypeTransfer = "TRANSFER" ) const ( diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index bd156389..3a7562ea 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -13,7 +13,6 @@ type StockTransferDelivery struct { DocumentPath string ShippingCostItem float64 ShippingCostTotal float64 - Note string CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time `gorm:"index"` diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 2a3f2fcf..253a3bf8 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -2,21 +2,17 @@ package entities import "time" - // DETAIL PRODUK type StockTransferDetail struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - StockTransferId uint64 - ProductId uint64 - Quantity float64 - BeforeQuantity float64 - AfterQuantity float64 - Note string - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Product *Product `gorm:"foreignKey:ProductId"` - DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` -} \ No newline at end of file + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + ProductId uint64 + Quantity float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` +} diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index d499639e..b53d6e9a 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "math" "strconv" @@ -72,11 +73,21 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { } func (u *TransferController) CreateOne(c *fiber.Ctx) error { + data := c.FormValue("data") + var req validation.TransferRequest - if err := c.BodyParser(&req); err != nil { + if err := json.Unmarshal([]byte(data), &req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } + // ambil file + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + _ = form.File["documents"] + // todo: tunggu ada aws baru proses + result, err := u.TransferService.CreateOne(c, &req) if err != nil { return err diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 10bc820b..e7f50781 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -13,8 +13,8 @@ type TransferBaseDTO struct { Id uint64 `json:"id"` TransferReason string `json:"transfer_reason"` TransferDate string `json:"transfer_date"` - SourceWarehouse *WarehouseSimpleDTO `json:"source_warehouse,omitempty"` - DestinationWarehouse *WarehouseSimpleDTO `json:"destination_warehouse,omitempty"` + SourceWarehouse *WarehouseDetailDTO `json:"source_warehouse,omitempty"` + DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` } // Only id and name for warehouse simple view @@ -23,6 +23,24 @@ type WarehouseSimpleDTO struct { Name string `json:"name"` } +type AreaDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Area *AreaDTO `json:"area"` +} + +type WarehouseDetailDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Location *LocationDTO `json:"location"` + Area *AreaDTO `json:"area"` +} + type TransferListDTO struct { TransferBaseDTO CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` @@ -45,7 +63,6 @@ type TransferDetailItemDTO struct { Quantity float64 `json:"quantity"` BeforeQuantity float64 `json:"before_quantity"` AfterQuantity float64 `json:"after_quantity"` - Note string `json:"note"` } // Delivery ekspedisi @@ -58,7 +75,6 @@ type TransferDeliveryDTO struct { DocumentPath string `json:"document_path"` ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` - Note string `json:"note"` Items []TransferDeliveryItemDTO `json:"items"` } @@ -71,19 +87,14 @@ type TransferDeliveryItemDTO struct { // === Mapper Functions === func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { - var sourceWarehouse *WarehouseSimpleDTO + + var sourceWarehouse *WarehouseDetailDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { - sourceWarehouse = &WarehouseSimpleDTO{ - Id: e.FromWarehouse.Id, - Name: e.FromWarehouse.Name, - } + sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) } - var destinationWarehouse *WarehouseSimpleDTO + var destinationWarehouse *WarehouseDetailDTO if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 { - destinationWarehouse = &WarehouseSimpleDTO{ - Id: e.ToWarehouse.Id, - Name: e.ToWarehouse.Name, - } + destinationWarehouse = toWarehouseDetailDTO(e.ToWarehouse) } return TransferBaseDTO{ Id: e.Id, @@ -94,6 +105,40 @@ func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { } } +func toAreaDTO(a *entity.Area) *AreaDTO { + if a == nil { + return nil + } + return &AreaDTO{ + Id: a.Id, + Name: a.Name, + } +} + +func toLocationDTO(l *entity.Location) *LocationDTO { + if l == nil { + return nil + } + // Area selalu diisi jika l.Area ada + return &LocationDTO{ + Id: l.Id, + Name: l.Name, + Area: toAreaDTO(&l.Area), + } +} + +func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { + if w == nil { + return nil + } + return &WarehouseDetailDTO{ + Id: w.Id, + Name: w.Name, + Location: toLocationDTO(w.Location), + Area: toAreaDTO(&w.Area), + } +} + func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { var createdUser *userDTO.UserBaseDTO if e.CreatedUser != nil { @@ -104,12 +149,9 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ - Id: d.Id, - ProductId: d.ProductId, - Quantity: d.Quantity, - BeforeQuantity: d.BeforeQuantity, - AfterQuantity: d.AfterQuantity, - Note: d.Note, + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, }) } // Map deliveries @@ -133,7 +175,6 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, - Note: del.Note, Items: items, }) } @@ -160,12 +201,9 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ - Id: d.Id, - ProductId: d.ProductId, - Quantity: d.Quantity, - BeforeQuantity: d.BeforeQuantity, - AfterQuantity: d.AfterQuantity, - Note: d.Note, + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, }) } // Map deliveries @@ -180,7 +218,6 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, - Note: del.Note, }) } return TransferDetailDTO{ diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 80267e0c..5e3b778e 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -51,7 +51,11 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("FromWarehouse"). + Preload("FromWarehouse.Location"). + Preload("FromWarehouse.Area"). Preload("ToWarehouse"). + Preload("ToWarehouse.Location"). + Preload("ToWarehouse.Area"). Preload("Details"). Preload("Deliveries.Items") } @@ -64,7 +68,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit offset := (params.Page - 1) * params.Limit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = db.Preload("CreatedUser").Preload("FromWarehouse").Preload("ToWarehouse").Preload("Details").Preload("Deliveries.Items") + db = s.withRelations(db) if params.Search != "" { db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } @@ -83,8 +87,10 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { var transfer entity.StockTransfer + db := s.StockTransferRepo.DB().WithContext(c.Context()) - db = db.Preload("CreatedUser").Preload("FromWarehouse").Preload("ToWarehouse").Preload("Details").Preload("Deliveries.Items") + db = s.withRelations(db) + if err := db.First(&transfer, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") @@ -95,11 +101,8 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e } func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - // Validasi stok di gudang asal + // Validasi stok di gudang asal harus exist dan mencukupi for _, product := range req.Products { sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), @@ -115,6 +118,22 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } + // validasi total qty harus lebih besar dari atau sama dengan total qty di delivery compare berdasarkan productid + deliveryQtyMap := make(map[uint]float64) + for _, delivery := range req.Deliveries { + for _, prod := range delivery.Products { + deliveryQtyMap[prod.ProductID] += prod.ProductQty + } + } + + // Cek: qty delivery tidak boleh melebihi qty di root + for _, product := range req.Products { + if deliveryQtyMap[product.ProductID] > product.ProductQty { + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Total qty delivery untuk produk %d (%v) melebihi qty transfer (%v)", product.ProductID, deliveryQtyMap[product.ProductID], product.ProductQty)) + } + } + // Generate movement number // Format: PND-MBU-00001 seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) @@ -167,7 +186,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: delivery.Document, + DocumentPath: "dummy duls", // todo: tunggu ada aws baru proses ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostTotal: delivery.DeliveryCost, }) @@ -176,8 +195,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) return err } - // tambahkan insert ke delivery items sebagai fivot - + // tambahkan insert ke delivery items sebagai pivot detailMap := make(map[uint64]uint64) for _, d := range details { detailMap[d.ProductId] = d.Id @@ -185,22 +203,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques var deliveryItems []*entity.StockTransferDeliveryItem - for _, delivery := range deliveries { - for _, item := range req.Deliveries { - if item.Document == delivery.DocumentPath { - for _, prod := range item.Products { - detailID, ok := detailMap[uint64(prod.ProductID)] - if !ok { - return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) - } - - deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ - StockTransferDeliveryId: delivery.Id, - StockTransferDetailId: detailID, - Quantity: prod.ProductQty, - }) - } + for i, delivery := range deliveries { + item := req.Deliveries[i] + for _, prod := range item.Products { + detailID, ok := detailMap[uint64(prod.ProductID)] + if !ok { + return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) } + deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ + StockTransferDeliveryId: delivery.Id, + StockTransferDetailId: detailID, + Quantity: prod.ProductQty, + }) } } if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { @@ -250,6 +264,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) } + // Update stok di gudang tujuan destPW.Quantity += product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { s.Log.Errorf("Failed to update destination product warehouse: %+v", err) @@ -257,9 +272,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) - } + return nil }) @@ -268,5 +283,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") } - return entityTransfer, nil + // Ambil data lengkap hasil create dengan GetOne (agar preload relasi sama dengan GetOne) + result, err := s.GetOne(c, uint(entityTransfer.Id)) + if err != nil { + return nil, err + } + return result, nil } diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go index 778bdef4..c64077ff 100644 --- a/internal/modules/inventory/transfers/validations/transfer.validation.go +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -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"` - Document string `json:"document"` + DocumentIndex int `json:"document_index" validate:"min=0"` DriverName string `json:"driver_name" validate:"required"` VehiclePlate string `json:"vehicle_plate" validate:"required"` SupplierID uint `json:"supplier_id" validate:"required"` From 0ffb8a44f2c0fc6794d0cd65e76ae9065b324284 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 15 Oct 2025 22:26:06 +0700 Subject: [PATCH 04/26] feat(BE-59,60,61): build stock transfer API with validation and audit log - Implement CreateOne for stock transfer with multi-delivery and validation - Preload warehouse, location, and area relations in transfer response - Add audit log for transfer - Improve transaction handling and error management --- .../modules/inventory/transfers/module.go | 4 +++- .../transfers/services/transfer.service.go | 20 +++++++++++++++++-- .../repositories/supplier.repository.go | 1 + 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index fa0047d4..21f0ec89 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -8,6 +8,7 @@ import ( 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" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -21,10 +22,11 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate stockTransferDeliveryRepo := rStockTransfer.NewStockTransferDeliveryRepository(db) StockTransferDeliveryItemRepo := rStockTransfer.NewStockTransferDeliveryItemRepository(db) stockLogsRepo := rStockLogs.NewStockLogRepository(db) + supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo) + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 5e3b778e..5b802e5d 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -9,6 +9,7 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -33,9 +34,10 @@ type transferService struct { StockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository StockLogsRepository rStockLogs.StockLogRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + SupplierRepo rSupplier.SupplierRepository } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository) 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) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -45,6 +47,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr StockTransferDeliveryItemRepo: stockTransferDeliveryItemRepo, StockLogsRepository: stockLogsRepo, ProductWarehouseRepo: productWarehouseRepo, + SupplierRepo: supplierRepo, } } func (s transferService) withRelations(db *gorm.DB) *gorm.DB { @@ -134,6 +137,20 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } + // cek suplier id caegory BOP cek by id + for _, delivery := range req.Deliveries { + supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier") + } + if supplier.Category != "BOP" { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) + } + } + // Generate movement number // Format: PND-MBU-00001 seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) @@ -274,7 +291,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } - return nil }) diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index ea4e43bf..46fb2983 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -11,6 +11,7 @@ import ( type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + } type SupplierRepositoryImpl struct { From f6f62246c6fff60b2026e176e3bc625721bd80bf Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 16 Oct 2025 07:37:10 +0700 Subject: [PATCH 05/26] feat(BE-59,60,61): build stock transfer API with validation and audit log --- .../inventory/transfers/dto/transfer.dto.go | 9 +++------ .../transfers/services/transfer.service.go | 15 ++++++++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index e7f50781..217e5038 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -29,9 +29,8 @@ type AreaDTO struct { } type LocationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Area *AreaDTO `json:"area"` + Id uint `json:"id"` + Name string `json:"name"` } type WarehouseDetailDTO struct { @@ -119,11 +118,9 @@ func toLocationDTO(l *entity.Location) *LocationDTO { if l == nil { return nil } - // Area selalu diisi jika l.Area ada return &LocationDTO{ Id: l.Id, Name: l.Name, - Area: toAreaDTO(&l.Area), } } @@ -135,7 +132,7 @@ func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { Id: w.Id, Name: w.Name, Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), + Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) } } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 5b802e5d..7f18d257 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -91,16 +91,21 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { var transfer entity.StockTransfer - db := s.StockTransferRepo.DB().WithContext(c.Context()) - db = s.withRelations(db) - - if err := db.First(&transfer, id).Error; err != nil { + // gunakan repo secara langsung + transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return s.withRelations(db) + }) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") } + s.Log.Errorf("Failed to get transfer by ID: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") } - return &transfer, nil + + s.Log.Infof("Retrieved transfer: %+v", transfer) + + return transferPtr, nil } func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { From 6c7ab8a0f8217046b7eb7322912f4fbf8c9dde21 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 16 Oct 2025 10:06:18 +0700 Subject: [PATCH 06/26] feat/BE/US-74/pengajuan-flock --- Makefile | 2 +- go.mod | 4 +- go.sum | 8 +- ...5063107_add_status_kandangs_table.down.sql | 2 + ...015063107_add_status_kandangs_table.up.sql | 9 + .../20251015065815_add_flocs_table.down.sql | 6 + .../20251015065815_add_flocs_table.up.sql | 29 ++ internal/database/seed/seeder.go | 219 +++++++++-- internal/entities/flock.go | 17 + internal/entities/kandang.go | 26 +- internal/entities/projectfloc.go | 28 ++ internal/middleware/auth.go | 120 ++++-- .../flocks/controllers/flock.controller.go | 140 +++++++ .../modules/master/flocks/dto/flock.dto.go | 64 +++ internal/modules/master/flocks/module.go | 25 ++ .../flocks/repositories/flock.repository.go | 21 + internal/modules/master/flocks/route.go | 28 ++ .../master/flocks/services/flock.service.go | 130 ++++++ .../flocks/validations/floc.validation.go | 15 + .../repositories/kandang.repository.go | 31 ++ .../kandangs/services/kandang.service.go | 78 +++- .../validations/kandang.validation.go | 16 +- internal/modules/master/route.go | 2 + internal/modules/production/module.go | 13 + .../controllers/projectflock.controller.go | 164 ++++++++ .../project_flocks/dto/projectflock.dto.go | 183 +++++++++ .../production/project_flocks/module.go | 29 ++ .../repositories/projectflock.repository.go | 67 ++++ .../production/project_flocks/route.go | 29 ++ .../services/projectflock.service.go | 372 ++++++++++++++++++ .../validations/projectflock.validation.go | 27 ++ internal/modules/production/route.go | 25 ++ internal/route/route.go | 2 + internal/utils/constant.go | 24 ++ test/integration/master_data/kandang_test.go | 49 ++- test/integration/master_data/master_data.go | 14 + .../master_data/project_flock_test.go | 119 ++++++ 37 files changed, 2038 insertions(+), 99 deletions(-) create mode 100644 internal/database/migrations/20251015063107_add_status_kandangs_table.down.sql create mode 100644 internal/database/migrations/20251015063107_add_status_kandangs_table.up.sql create mode 100644 internal/database/migrations/20251015065815_add_flocs_table.down.sql create mode 100644 internal/database/migrations/20251015065815_add_flocs_table.up.sql create mode 100644 internal/entities/flock.go create mode 100644 internal/entities/projectfloc.go create mode 100644 internal/modules/master/flocks/controllers/flock.controller.go create mode 100644 internal/modules/master/flocks/dto/flock.dto.go create mode 100644 internal/modules/master/flocks/module.go create mode 100644 internal/modules/master/flocks/repositories/flock.repository.go create mode 100644 internal/modules/master/flocks/route.go create mode 100644 internal/modules/master/flocks/services/flock.service.go create mode 100644 internal/modules/master/flocks/validations/floc.validation.go create mode 100644 internal/modules/production/module.go create mode 100644 internal/modules/production/project_flocks/controllers/projectflock.controller.go create mode 100644 internal/modules/production/project_flocks/dto/projectflock.dto.go create mode 100644 internal/modules/production/project_flocks/module.go create mode 100644 internal/modules/production/project_flocks/repositories/projectflock.repository.go create mode 100644 internal/modules/production/project_flocks/route.go create mode 100644 internal/modules/production/project_flocks/services/projectflock.service.go create mode 100644 internal/modules/production/project_flocks/validations/projectflock.validation.go create mode 100644 internal/modules/production/route.go create mode 100644 test/integration/master_data/project_flock_test.go diff --git a/Makefile b/Makefile index a18b33ec..5533dc7f 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,7 @@ wait-db: # Contoh: make migration-create_users_table # ":" akan diubah ke "_" (biar aman untuk nama file) migration-%: - @migrate create -ext sql -dir internal/database/migrations $(subst :,_,$*) + @migrate create -ext sql -dir $(MIGRATIONS_DIR) $(subst :,_,$*) # --- Migration (apply via docker image 'migrate') --- migrate-up: db-up wait-db diff --git a/go.mod b/go.mod index a4ad7610..3d7b91ba 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/bytedance/sonic v1.12.1 + github.com/glebarez/sqlite v1.11.0 github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/contrib/jwt v1.0.10 github.com/gofiber/fiber/v2 v2.52.5 @@ -28,7 +29,6 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -47,7 +47,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect @@ -76,7 +75,6 @@ require ( golang.org/x/text v0.22.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/sqlite v1.5.5 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 62bd157a..448287fc 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -69,8 +71,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -88,8 +90,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= -github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -211,8 +211,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= -gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= diff --git a/internal/database/migrations/20251015063107_add_status_kandangs_table.down.sql b/internal/database/migrations/20251015063107_add_status_kandangs_table.down.sql new file mode 100644 index 00000000..bfd2f5dc --- /dev/null +++ b/internal/database/migrations/20251015063107_add_status_kandangs_table.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE kandangs + DROP COLUMN IF EXISTS status; diff --git a/internal/database/migrations/20251015063107_add_status_kandangs_table.up.sql b/internal/database/migrations/20251015063107_add_status_kandangs_table.up.sql new file mode 100644 index 00000000..87c3ab62 --- /dev/null +++ b/internal/database/migrations/20251015063107_add_status_kandangs_table.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE kandangs + ADD COLUMN status VARCHAR(20); + +UPDATE kandangs +SET status = 'NON_ACTIVE' +WHERE status IS NULL; + +ALTER TABLE kandangs + ALTER COLUMN status SET NOT NULL; \ No newline at end of file diff --git a/internal/database/migrations/20251015065815_add_flocs_table.down.sql b/internal/database/migrations/20251015065815_add_flocs_table.down.sql new file mode 100644 index 00000000..8a9ee93e --- /dev/null +++ b/internal/database/migrations/20251015065815_add_flocs_table.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE kandangs + DROP COLUMN IF EXISTS project_flock_id; + +DROP TABLE IF EXISTS project_flocks; + +DROP TABLE IF EXISTS flocks; diff --git a/internal/database/migrations/20251015065815_add_flocs_table.up.sql b/internal/database/migrations/20251015065815_add_flocs_table.up.sql new file mode 100644 index 00000000..4752a3bd --- /dev/null +++ b/internal/database/migrations/20251015065815_add_flocs_table.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE flocks ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX flocks_name_unique ON flocks (name) +WHERE + deleted_at IS NULL; + +CREATE TABLE project_flocks ( + id BIGSERIAL PRIMARY KEY, + flock_id BIGINT NOT NULL REFERENCES flocks (id) ON DELETE RESTRICT ON UPDATE CASCADE, + area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE, + product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE, + fcr_id BIGINT NOT NULL REFERENCES fcrs (id) ON DELETE RESTRICT ON UPDATE CASCADE, + location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE, + period INT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +ALTER TABLE kandangs + ADD COLUMN project_flock_id BIGINT REFERENCES project_flocks (id) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index b321a784..839854cc 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -35,7 +35,27 @@ func Run(db *gorm.DB) error { return err } - kandangs, err := seedKandangs(tx, adminID, locations, users) + productCategories, err := seedProductCategories(tx, adminID) + if err != nil { + return err + } + + flocks, err := seedFlocks(tx, adminID) + if err != nil { + return err + } + + fcrs, err := seedFcr(tx, adminID) + if err != nil { + return err + } + + projectFlocks, err := seedProjectFlocks(tx, adminID, flocks, areas, productCategories, fcrs, locations) + if err != nil { + return err + } + + kandangs, err := seedKandangs(tx, adminID, locations, users, projectFlocks) if err != nil { return err } @@ -44,11 +64,6 @@ func Run(db *gorm.DB) error { return err } - productCategories, err := seedProductCategories(tx, adminID) - if err != nil { - return err - } - suppliers, err := seedSuppliers(tx, adminID) if err != nil { return err @@ -58,10 +73,6 @@ func Run(db *gorm.DB) error { return err } - if err := seedFcr(tx, adminID); err != nil { - return err - } - if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { return err } @@ -194,16 +205,138 @@ func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[stri return result, nil } -func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { +func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { + names := []string{"Flock Priangan", "Flock Banten"} + result := make(map[string]uint, len(names)) + + for _, name := range names { + var flock entity.Flock + err := tx.Where("name = ?", name).First(&flock).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + flock = entity.Flock{ + Name: name, + CreatedBy: createdBy, + } + if err := tx.Create(&flock).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + if err := tx.Model(&entity.Flock{}).Where("id = ?", flock.Id).Updates(map[string]any{ + "created_by": createdBy, + }).Error; err != nil { + return nil, err + } + } + result[name] = flock.Id + } + + return result, nil +} + +func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCategories, fcrs, locations map[string]uint) (map[string]uint, error) { seeds := []struct { - Name string - Location string - PicKey string + Key string + Flock string + Area string + ProductCategory string + Fcr string + Location string + Period int }{ - {"Singaparna 1", "Singaparna", "admin"}, - {"Singaparna 2", "Singaparna", "admin"}, - {"Cikaum 1", "Cikaum", "admin"}, - {"Cikaum 2", "Cikaum", "admin"}, + { + Key: "Singaparna Period 1", + Flock: "Flock Priangan", + Area: "Priangan", + ProductCategory: "Day Old Chick", + Fcr: "FCR Layer", + Location: "Singaparna", + Period: 1, + }, + { + Key: "Cikaum Period 1", + Flock: "Flock Banten", + Area: "Banten", + ProductCategory: "Day Old Chick", + Fcr: "FCR Layer", + Location: "Cikaum", + Period: 1, + }, + } + + result := make(map[string]uint, len(seeds)) + + for _, seed := range seeds { + flockID, ok := flocks[seed.Flock] + if !ok { + return nil, fmt.Errorf("floc %s not seeded", seed.Flock) + } + areaID, ok := areas[seed.Area] + if !ok { + return nil, fmt.Errorf("area %s not seeded", seed.Area) + } + categoryID, ok := productCategories[seed.ProductCategory] + if !ok { + return nil, fmt.Errorf("product category %s not seeded", seed.ProductCategory) + } + fcrID, ok := fcrs[seed.Fcr] + if !ok { + return nil, fmt.Errorf("fcr %s not seeded", seed.Fcr) + } + locationID, ok := locations[seed.Location] + if !ok { + return nil, fmt.Errorf("location %s not seeded", seed.Location) + } + + var projectFlock entity.ProjectFlock + err := tx.Where("flock_id = ? AND area_id = ? AND product_category_id = ? AND fcr_id = ? AND location_id = ? AND period = ?", + flockID, areaID, categoryID, fcrID, locationID, seed.Period).First(&projectFlock).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + projectFlock = entity.ProjectFlock{ + FlockId: flockID, + AreaId: areaID, + ProductCategoryId: categoryID, + FcrId: fcrID, + LocationId: locationID, + Period: seed.Period, + CreatedBy: createdBy, + } + if err := tx.Create(&projectFlock).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + if err := tx.Model(&entity.ProjectFlock{}).Where("id = ?", projectFlock.Id).Updates(map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationID, + "period": seed.Period, + }).Error; err != nil { + return nil, err + } + } + result[seed.Key] = projectFlock.Id + } + + return result, nil +} + +func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint, projectFlocks map[string]uint) (map[string]uint, error) { + seeds := []struct { + Name string + Status utils.KandangStatus + Location string + PicKey string + ProjectFlockKey *string + }{ + {Name: "Singaparna 1", Status: utils.KandangStatusActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, + {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, + {Name: "Cikaum 1", Status: utils.KandangStatusActive, Location: "Cikaum", PicKey: "admin", ProjectFlockKey: strPtr("Cikaum Period 1")}, + {Name: "Cikaum 2", Status: utils.KandangStatusPengajuan, Location: "Cikaum", PicKey: "admin"}, } result := make(map[string]uint, len(seeds)) @@ -218,20 +351,45 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return nil, fmt.Errorf("user %s not seeded", seed.PicKey) } + var projectFlockID *uint + if seed.ProjectFlockKey != nil { + pfID, ok := projectFlocks[*seed.ProjectFlockKey] + if !ok { + return nil, fmt.Errorf("project flock %s not seeded", *seed.ProjectFlockKey) + } + projectFlockID = uintPtr(pfID) + } + var kandang entity.Kandang err := tx.Where("name = ?", seed.Name).First(&kandang).Error if errors.Is(err, gorm.ErrRecordNotFound) { kandang = entity.Kandang{ - Name: seed.Name, - LocationId: locID, - PicId: picID, - CreatedBy: createdBy, + Name: seed.Name, + Status: string(seed.Status), + LocationId: locID, + PicId: picID, + ProjectFlockId: projectFlockID, + CreatedBy: createdBy, } if err := tx.Create(&kandang).Error; err != nil { return nil, err } } else if err != nil { return nil, err + } else { + updates := map[string]any{ + "location_id": locID, + "pic_id": picID, + "status": string(seed.Status), + } + if projectFlockID != nil { + updates["project_flock_id"] = *projectFlockID + } else { + updates["project_flock_id"] = nil + } + if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { + return nil, err + } } result[seed.Name] = kandang.Id } @@ -430,7 +588,7 @@ func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { return nil } -func seedFcr(tx *gorm.DB, createdBy uint) error { +func seedFcr(tx *gorm.DB, createdBy uint) (map[string]uint, error) { seeds := []struct { Name string Standards []struct { @@ -452,17 +610,20 @@ func seedFcr(tx *gorm.DB, createdBy uint) error { }, } + result := make(map[string]uint, len(seeds)) + for _, seed := range seeds { var fcr entity.Fcr err := tx.Where("name = ?", seed.Name).First(&fcr).Error if errors.Is(err, gorm.ErrRecordNotFound) { fcr = entity.Fcr{Name: seed.Name, CreatedBy: createdBy} if err := tx.Create(&fcr).Error; err != nil { - return err + return nil, err } } else if err != nil { - return err + return nil, err } + result[seed.Name] = fcr.Id for _, std := range seed.Standards { var standard entity.FcrStandard @@ -475,22 +636,22 @@ func seedFcr(tx *gorm.DB, createdBy uint) error { Mortality: std.Mortality, } if err := tx.Create(&standard).Error; err != nil { - return err + return nil, err } } else if err != nil { - return err + return nil, err } else { if err := tx.Model(&entity.FcrStandard{}).Where("id = ?", standard.Id).Updates(map[string]any{ "fcr_number": std.FcrNumber, "mortality": std.Mortality, }).Error; err != nil { - return err + return nil, err } } } } - return nil + return result, nil } func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { diff --git a/internal/entities/flock.go b/internal/entities/flock.go new file mode 100644 index 00000000..dad9ba81 --- /dev/null +++ b/internal/entities/flock.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Flock struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:flocks_name_unique,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 862f40fc..c71382da 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -7,16 +7,18 @@ import ( ) type Kandang struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` - LocationId uint `gorm:"not null"` - PicId uint `gorm:"not null"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - Pic User `gorm:"foreignKey:PicId;references:Id"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` + Status string `gorm:"type:varchar(50);not null"` + LocationId uint `gorm:"not null"` + PicId uint `gorm:"not null"` + ProjectFlockId *uint `gorm:"column:project_flock_id"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + Pic User `gorm:"foreignKey:PicId;references:Id"` + ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` } diff --git a/internal/entities/projectfloc.go b/internal/entities/projectfloc.go new file mode 100644 index 00000000..5332e336 --- /dev/null +++ b/internal/entities/projectfloc.go @@ -0,0 +1,28 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectFlock struct { + Id uint `gorm:"primaryKey"` + FlockId uint `gorm:"not null"` + AreaId uint `gorm:"not null"` + ProductCategoryId uint `gorm:"not null"` + FcrId uint `gorm:"not null"` + LocationId uint `gorm:"not null"` + Period int `gorm:"not null"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Flock Flock `gorm:"foreignKey:FlockId;references:Id"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` + ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` + Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 14a64337..d89dcb31 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,55 +1,99 @@ package middleware -import ( - "strings" +// import ( +// "strings" - "gitlab.com/mbugroup/lti-api.git/internal/config" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" +// "gitlab.com/mbugroup/lti-api.git/internal/config" +// service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +// "gitlab.com/mbugroup/lti-api.git/internal/utils" - "github.com/gofiber/fiber/v2" -) +// "github.com/gofiber/fiber/v2" +// ) -func Auth(userService service.UserService, requiredRights ...string) fiber.Handler { - return func(c *fiber.Ctx) error { - authHeader := c.Get("Authorization") - token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) +// func Auth(userService service.UserService, requiredRights ...string) fiber.Handler { +// return func(c *fiber.Ctx) error { +// authHeader := c.Get("Authorization") +// token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) - if token == "" { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } +// if token == "" { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } - userID, err := utils.VerifyToken(token, config.JWTSecret, config.TokenTypeAccess) - if err != nil { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } +// userID, err := utils.VerifyToken(token, config.JWTSecret, config.TokenTypeAccess) +// if err != nil { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } - user, err := userService.GetOne(c, userID) - if err != nil || user == nil { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } +// // Only end-user subjects are allowed by this middleware. Service tokens +// if verification.UserID == 0 { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } - c.Locals("user", user) +// // Fail-closed on revocation check errors for stricter security posture. +// if revoker := session.GetRevocationStore(); revoker != nil { +// if fingerprint := session.TokenFingerprint(token); fingerprint != "" { +// revoked, err := revoker.IsRevoked(c.Context(), fingerprint) +// if err != nil { +// utils.Log.WithError(err).Warn("failed to check token revocation") +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } +// if revoked { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } +// } +// } - // if len(requiredRights) > 0 { - // userRights, hasRights := config.RoleRights[user.Role] - // if (!hasRights || !hasAllRights(userRights, requiredRights)) && c.Params("userId") != userID { - // return fiber.NewError(fiber.StatusForbidden, "You don't have permission to access this resource") - // } - // } +// user, err := userService.GetBySSOUserID(c, verification.UserID) +// if err != nil || user == nil { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } - return c.Next() - } -} +// if len(requiredRights) > 0 && verification.Claims != nil { +// if !hasAllScopes(verification.Claims.Scopes(), requiredRights) { +// return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") +// } +// } -// func hasAllRights(userRights, requiredRights []string) bool { -// rightSet := make(map[string]struct{}, len(userRights)) -// for _, right := range userRights { -// rightSet[right] = struct{}{} +// c.Locals("user", user) + +// // if len(requiredRights) > 0 { +// // userRights, hasRights := config.RoleRights[user.Role] +// // if (!hasRights || !hasAllRights(userRights, requiredRights)) && c.Params("userId") != userID { +// // return fiber.NewError(fiber.StatusForbidden, "You don't have permission to access this resource") +// // } +// // } + +// return c.Next() // } +// } -// for _, right := range requiredRights { -// if _, exists := rightSet[right]; !exists { +// // bearerToken extracts a Bearer token from the Authorization header using +// // case-insensitive scheme matching and tolerant whitespace handling. +// func bearerToken(c *fiber.Ctx) string { +// parts := strings.Fields(c.Get("Authorization")) +// if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { +// return strings.TrimSpace(parts[1]) +// } +// return "" +// } + +// func hasAllScopes(have, required []string) bool { +// if len(required) == 0 { +// return true +// } +// set := make(map[string]struct{}, len(have)) +// for _, s := range have { +// s = strings.ToLower(strings.TrimSpace(s)) +// if s != "" { +// set[s] = struct{}{} +// } +// } +// for _, r := range required { +// r = strings.ToLower(strings.TrimSpace(r)) +// if r == "" { +// continue +// } +// if _, ok := set[r]; !ok { // return false // } // } diff --git a/internal/modules/master/flocks/controllers/flock.controller.go b/internal/modules/master/flocks/controllers/flock.controller.go new file mode 100644 index 00000000..8265f3e4 --- /dev/null +++ b/internal/modules/master/flocks/controllers/flock.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type FlockController struct { + FlockService service.FlockService +} + +func NewFlockController(flockService service.FlockService) *FlockController { + return &FlockController{ + FlockService: flockService, + } +} + +func (u *FlockController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.FlockService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.FlockListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all flocks successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToFlockListDTOs(result), + }) +} + +func (u *FlockController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.FlockService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get flock successfully", + Data: dto.ToFlockListDTO(*result), + }) +} + +func (u *FlockController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.FlockService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create flock successfully", + Data: dto.ToFlockListDTO(*result), + }) +} + +func (u *FlockController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.FlockService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update flock successfully", + Data: dto.ToFlockListDTO(*result), + }) +} + +func (u *FlockController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.FlockService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete flock successfully", + }) +} diff --git a/internal/modules/master/flocks/dto/flock.dto.go b/internal/modules/master/flocks/dto/flock.dto.go new file mode 100644 index 00000000..10e6f555 --- /dev/null +++ b/internal/modules/master/flocks/dto/flock.dto.go @@ -0,0 +1,64 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type FlockBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type FlockListDTO struct { + FlockBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FlockDetailDTO struct { + FlockListDTO +} + +// === Mapper Functions === + +func ToFlockBaseDTO(e entity.Flock) FlockBaseDTO { + return FlockBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToFlockListDTO(e entity.Flock) FlockListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return FlockListDTO{ + FlockBaseDTO: ToFlockBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToFlockListDTOs(e []entity.Flock) []FlockListDTO { + result := make([]FlockListDTO, len(e)) + for i, r := range e { + result[i] = ToFlockListDTO(r) + } + return result +} + +func ToFlockDetailDTO(e entity.Flock) FlockDetailDTO { + return FlockDetailDTO{ + FlockListDTO: ToFlockListDTO(e), + } +} diff --git a/internal/modules/master/flocks/module.go b/internal/modules/master/flocks/module.go new file mode 100644 index 00000000..545e2583 --- /dev/null +++ b/internal/modules/master/flocks/module.go @@ -0,0 +1,25 @@ +package flocks + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + sFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type FlockModule struct{} + +func (FlockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + flockRepo := rFlock.NewFlockRepository(db) + userRepo := rUser.NewUserRepository(db) + + flockService := sFlock.NewFlockService(flockRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + FlockRoutes(router, userService, flockService) +} diff --git a/internal/modules/master/flocks/repositories/flock.repository.go b/internal/modules/master/flocks/repositories/flock.repository.go new file mode 100644 index 00000000..12f269fc --- /dev/null +++ b/internal/modules/master/flocks/repositories/flock.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type FlockRepository interface { + repository.BaseRepository[entity.Flock] +} + +type FlockRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Flock] +} + +func NewFlockRepository(db *gorm.DB) FlockRepository { + return &FlockRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Flock](db), + } +} diff --git a/internal/modules/master/flocks/route.go b/internal/modules/master/flocks/route.go new file mode 100644 index 00000000..6d93827d --- /dev/null +++ b/internal/modules/master/flocks/route.go @@ -0,0 +1,28 @@ +package flocks + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/controllers" + flock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func FlockRoutes(v1 fiber.Router, u user.UserService, s flock.FlockService) { + ctrl := controller.NewFlockController(s) + + route := v1.Group("/flocks") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/flocks/services/flock.service.go b/internal/modules/master/flocks/services/flock.service.go new file mode 100644 index 00000000..4c3c9b26 --- /dev/null +++ b/internal/modules/master/flocks/services/flock.service.go @@ -0,0 +1,130 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type FlockService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Flock, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Flock, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Flock, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Flock, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type flockService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.FlockRepository +} + +func NewFlockService(repo repository.FlockRepository, validate *validator.Validate) FlockService { + return &flockService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s flockService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s flockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Flock, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + flocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get flocks: %+v", err) + return nil, 0, err + } + return flocks, total, nil +} + +func (s flockService) GetOne(c *fiber.Ctx, id uint) (*entity.Flock, error) { + flock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found") + } + if err != nil { + s.Log.Errorf("Failed get flock by id: %+v", err) + return nil, err + } + return flock, nil +} + +func (s *flockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Flock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + createBody := &entity.Flock{ + Name: req.Name, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create flock: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s flockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Flock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + updateBody["name"] = *req.Name + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found") + } + s.Log.Errorf("Failed to update flock: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s flockService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Flock not found") + } + s.Log.Errorf("Failed to delete flock: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/flocks/validations/floc.validation.go b/internal/modules/master/flocks/validations/floc.validation.go new file mode 100644 index 00000000..95505746 --- /dev/null +++ b/internal/modules/master/flocks/validations/floc.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index c72eb87f..b253fade 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -5,6 +5,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -13,6 +14,8 @@ type KandangRepository interface { LocationExists(ctx context.Context, areaId uint) (bool, error) PicExists(ctx context.Context, areaId uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error) + HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) } type KandangRepositoryImpl struct { @@ -38,3 +41,31 @@ func (r *KandangRepositoryImpl) PicExists(ctx context.Context, picId uint) (bool func (r *KandangRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Kandang](ctx, r.db, name, excludeID) } + +func (r *KandangRepositoryImpl) ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error) { + var count int64 + if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlock{}). + Where("id = ?", projectFlockID). + Where("deleted_at IS NULL"). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) { + var count int64 + q := r.db.WithContext(ctx). + Model(&entity.Kandang{}). + Where("project_flock_id = ?", projectFlockID). + Where("status = ?", utils.KandangStatusActive). + Where("deleted_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index d856f736..9ece8898 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "strings" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -100,13 +101,41 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit ); err != nil { return nil, err } + status := strings.ToUpper(req.Status) + if !utils.IsValidKandangStatus(status) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang status") + } + + var projectFlockID *uint + if req.ProjectFlockId != nil { + if exists, err := s.Repository.ProjectFlockExists(c.Context(), *req.ProjectFlockId); err != nil { + s.Log.Errorf("Failed to check project flock existence: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check project flock") + } else if !exists { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Project flock with id %d not found", *req.ProjectFlockId)) + } + + if status == string(utils.KandangStatusActive) { + if active, err := s.Repository.HasActiveKandangForProjectFlock(c.Context(), *req.ProjectFlockId, nil); err != nil { + s.Log.Errorf("Failed to check kandang activity for project flock %d: %+v", *req.ProjectFlockId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check active kandang for project flock") + } else if active { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock already has an active kandang") + } + } + + idCopy := *req.ProjectFlockId + projectFlockID = &idCopy + } //TODO: created by dummy createBody := &entity.Kandang{ - Name: req.Name, - LocationId: req.LocationId, - PicId: req.PicId, - CreatedBy: 1, + Name: req.Name, + LocationId: req.LocationId, + Status: status, + PicId: req.PicId, + ProjectFlockId: projectFlockID, + CreatedBy: 1, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { @@ -122,6 +151,15 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + if err != nil { + s.Log.Errorf("Failed to fetch kandang %d before update: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandang") + } + updateBody := make(map[string]any) if req.Name != nil { @@ -149,6 +187,38 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["pic_id"] = *req.PicId } + finalStatus := strings.ToUpper(existing.Status) + if req.Status != nil { + status := strings.ToUpper(*req.Status) + if !utils.IsValidKandangStatus(status) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang status") + } + updateBody["status"] = status + finalStatus = status + } + + projectFlockIDToUse := existing.ProjectFlockId + if req.ProjectFlockId != nil { + if exists, err := s.Repository.ProjectFlockExists(c.Context(), *req.ProjectFlockId); err != nil { + s.Log.Errorf("Failed to check project flock existence: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check project flock") + } else if !exists { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Project flock with id %d not found", *req.ProjectFlockId)) + } + idCopy := *req.ProjectFlockId + projectFlockIDToUse = &idCopy + updateBody["project_flock_id"] = idCopy + } + + if projectFlockIDToUse != nil && finalStatus == string(utils.KandangStatusActive) { + if active, err := s.Repository.HasActiveKandangForProjectFlock(c.Context(), *projectFlockIDToUse, &id); err != nil { + s.Log.Errorf("Failed to check kandang activity for project flock %d: %+v", *projectFlockIDToUse, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check active kandang for project flock") + } else if active { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock already has an active kandang") + } + } + if len(updateBody) == 0 { return s.GetOne(c, id) } diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index 8b986ca6..0dc89212 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -1,15 +1,19 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` + Name string `json:"name" validate:"required_strict,min=3"` + Status string `json:"status" validate:"required_strict,min=3"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` + ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,number,gt=0"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` + Name *string `json:"name,omitempty" validate:"omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,min=3"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` + ProjectFlockId *uint `json:"project_flock_id,omitempty" validate:"omitempty,number,gt=0"` } type Query struct { diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 88584c13..88e17a98 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -19,6 +19,7 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" + flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" // MODULE IMPORTS ) @@ -38,6 +39,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida productcategories.ProductCategoryModule{}, products.ProductModule{}, banks.BankModule{}, + flocks.FlockModule{}, // MODULE REGISTRY } diff --git a/internal/modules/production/module.go b/internal/modules/production/module.go new file mode 100644 index 00000000..d10cf983 --- /dev/null +++ b/internal/modules/production/module.go @@ -0,0 +1,13 @@ +package production + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type ProductionModule struct{} + +func (ProductionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go new file mode 100644 index 00000000..48134164 --- /dev/null +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -0,0 +1,164 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProjectflockController struct { + ProjectflockService service.ProjectflockService +} + +func NewProjectflockController(projectflockService service.ProjectflockService) *ProjectflockController { + return &ProjectflockController{ + ProjectflockService: projectflockService, + } +} + +func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.ProjectflockService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProjectFlockListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all projectflocks successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProjectFlockListDTOs(result), + }) +} + +func (u *ProjectflockController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ProjectflockService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} + +func (u *ProjectflockController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProjectflockService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} + +func (u *ProjectflockController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProjectflockService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} + +func (u *ProjectflockController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ProjectflockService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete projectflock successfully", + }) +} + +func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { + param := c.Params("flock_id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Flock Id") + } + + summary, err := u.ProjectflockService.GetFlockPeriodSummary(c, uint(id)) + if err != nil { + return err + } + + responseBody := dto.ToFlockPeriodSummaryDTO(summary.Flock, summary.NextPeriod) + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get flock period summary successfully", + Data: responseBody, + }) +} diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go new file mode 100644 index 00000000..227d0fe9 --- /dev/null +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -0,0 +1,183 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +type ProjectFlockBaseDTO struct { + Id uint `json:"id"` + // FlockId uint `json:"flock_id"` + // AreaId uint `json:"area_id"` + // ProductCategoryId uint `json:"product_category_id"` + // FcrId uint `json:"fcr_id"` + // LocationId uint `json:"location_id"` + Period int `json:"period"` +} + +func ToProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { + return ProjectFlockBaseDTO{ + Id: e.Id, + // FlockId: e.FlockId, + // AreaId: e.AreaId, + // ProductCategoryId: e.ProductCategoryId, + // FcrId: e.FcrId, + // LocationId: e.LocationId, + Period: e.Period, + } +} + +type FlockSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type AreaSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ProductCategorySummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` +} + +type FcrSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Address string `json:"address"` +} + +type KandangSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +type ProjectFlockListDTO struct { + ProjectFlockBaseDTO + Flock *FlockSummaryDTO `json:"flock,omitempty"` + Area *AreaSummaryDTO `json:"area,omitempty"` + ProductCategory *ProductCategorySummaryDTO `json:"product_category,omitempty"` + Fcr *FcrSummaryDTO `json:"fcr,omitempty"` + Location *LocationSummaryDTO `json:"location,omitempty"` + Kandangs []KandangSummaryDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProjectFlockDetailDTO struct { + ProjectFlockListDTO +} + +type FlockPeriodSummaryDTO struct { + Flock FlockSummaryDTO `json:"flock"` + NextPeriod int `json:"next_period"` +} + +func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + var flockSummary *FlockSummaryDTO + if e.Flock.Id != 0 { + summary := ToFlockSummaryDTO(e.Flock) + flockSummary = &summary + } + + var areaSummary *AreaSummaryDTO + if e.Area.Id != 0 { + areaSummary = &AreaSummaryDTO{ + Id: e.Area.Id, + Name: e.Area.Name, + } + } + + var categorySummary *ProductCategorySummaryDTO + if e.ProductCategory.Id != 0 { + categorySummary = &ProductCategorySummaryDTO{ + Id: e.ProductCategory.Id, + Name: e.ProductCategory.Name, + Code: e.ProductCategory.Code, + } + } + + var fcrSummary *FcrSummaryDTO + if e.Fcr.Id != 0 { + fcrSummary = &FcrSummaryDTO{ + Id: e.Fcr.Id, + Name: e.Fcr.Name, + } + } + + var locationSummary *LocationSummaryDTO + if e.Location.Id != 0 { + locationSummary = &LocationSummaryDTO{ + Id: e.Location.Id, + Name: e.Location.Name, + Address: e.Location.Address, + } + } + + kandangSummaries := make([]KandangSummaryDTO, len(e.Kandangs)) + for i, kandang := range e.Kandangs { + kandangSummaries[i] = KandangSummaryDTO{ + Id: kandang.Id, + Name: kandang.Name, + Status: kandang.Status, + } + } + + return ProjectFlockListDTO{ + ProjectFlockBaseDTO: ToProjectFlockBaseDTO(e), + Flock: flockSummary, + Area: areaSummary, + ProductCategory: categorySummary, + Fcr: fcrSummary, + Location: locationSummary, + Kandangs: kandangSummaries, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToProjectFlockListDTOs(items []entity.ProjectFlock) []ProjectFlockListDTO { + result := make([]ProjectFlockListDTO, len(items)) + for i, item := range items { + result[i] = ToProjectFlockListDTO(item) + } + return result +} + +func ToProjectFlockDetailDTO(e entity.ProjectFlock) ProjectFlockDetailDTO { + return ProjectFlockDetailDTO{ + ProjectFlockListDTO: ToProjectFlockListDTO(e), + } +} + +func ToFlockSummaryDTO(e entity.Flock) FlockSummaryDTO { + return FlockSummaryDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToFlockPeriodSummaryDTO(flock entity.Flock, next int) FlockPeriodSummaryDTO { + return FlockPeriodSummaryDTO{ + Flock: ToFlockSummaryDTO(flock), + NextPeriod: next, + } +} diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go new file mode 100644 index 00000000..4f3167bc --- /dev/null +++ b/internal/modules/production/project_flocks/module.go @@ -0,0 +1,29 @@ +package project_flocks + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProjectflockModule struct{} + +func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + flockRepo := rFlock.NewFlockRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) + projectflockRepo := rProjectflock.NewProjectflockRepository(db) + userRepo := rUser.NewUserRepository(db) + + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ProjectflockRoutes(router, userService, projectflockService) +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go new file mode 100644 index 00000000..2d5abe0c --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -0,0 +1,67 @@ +package repository + +import ( + "context" + "errors" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectflockRepository interface { + repository.BaseRepository[entity.ProjectFlock] + GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) + GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) + GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) +} + +type ProjectflockRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlock] +} + +func NewProjectflockRepository(db *gorm.DB) ProjectflockRepository { + return &ProjectflockRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlock](db), + } +} + +func (r *ProjectflockRepositoryImpl) GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) { + var records []entity.ProjectFlock + if err := r.DB().WithContext(ctx). + Unscoped(). + Where("flock_id = ?", flockID). + Order("period ASC"). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +func (r *ProjectflockRepositoryImpl) GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) { + var record entity.ProjectFlock + err := r.DB().WithContext(ctx). + Where("flock_id = ?", flockID). + Order("period DESC"). + First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) { + var max int + if err := r.DB().WithContext(ctx). + Model(&entity.ProjectFlock{}). + Unscoped(). + Where("flock_id = ?", flockID). + Select("COALESCE(MAX(period), 0)"). + Scan(&max).Error; err != nil { + return 0, err + } + return max, nil +} diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go new file mode 100644 index 00000000..e5dbb48a --- /dev/null +++ b/internal/modules/production/project_flocks/route.go @@ -0,0 +1,29 @@ +package project_flocks + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/controllers" + projectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.ProjectflockService) { + ctrl := controller.NewProjectflockController(s) + + route := v1.Group("/project_flocks") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) + route.Get("/flocks/:flock_id/periods", ctrl.GetFlockPeriodSummary) +} diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go new file mode 100644 index 00000000..d8a98a32 --- /dev/null +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -0,0 +1,372 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ProjectflockService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) +} + +type projectflockService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository +} + +type FlockPeriodSummary struct { + Flock entity.Flock + NextPeriod int +} + +func NewProjectflockService( + repo repository.ProjectflockRepository, + flockRepo flockRepository.FlockRepository, + kandangRepo kandangRepository.KandangRepository, + validate *validator.Validate, +) ProjectflockService { + return &projectflockService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + } +} + +func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Flock"). + Preload("Area"). + Preload("ProductCategory"). + Preload("Fcr"). + Preload("Location"). + Preload("Kandangs") +} + +func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + projectflocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get projectflocks: %+v", err) + return nil, 0, err + } + return projectflocks, total, nil +} + +func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { + projectflock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + if err != nil { + s.Log.Errorf("Failed get projectflock by id: %+v", err) + return nil, err + } + return projectflock, nil +} + +func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if len(req.KandangIds) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") + } + + kandangIDs := uniqueUintSlice(req.KandangIds) + kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") + } + if len(kandangs) != len(kandangIDs) { + return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") + } + for _, kandang := range kandangs { + if kandang.ProjectFlockId != nil { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang %s sudah memiliki project flock", kandang.Name)) + } + } + + tx := s.Repository.DB().Begin() + if tx.Error != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + + projectRepo := s.Repository.WithTx(tx) + createBody := &entity.ProjectFlock{ + FlockId: req.FlockId, + AreaId: req.AreaId, + ProductCategoryId: req.ProductCategoryId, + FcrId: req.FcrId, + LocationId: req.LocationId, + Period: req.Period, + CreatedBy: 1, + } + + if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { + tx.Rollback() + s.Log.Errorf("Failed to create projectflock: %+v", err) + return nil, err + } + + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Updates(map[string]any{"project_flock_id": createBody.Id}).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to assign kandangs to projectflock: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to assign kandangs") + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + + return s.GetOne(c, createBody.Id) +} + +func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + if err != nil { + s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + updateBody := make(map[string]any) + + if req.FlockId != nil { + updateBody["flock_id"] = *req.FlockId + } + if req.AreaId != nil { + updateBody["area_id"] = *req.AreaId + } + if req.ProductCategoryId != nil { + updateBody["product_category_id"] = *req.ProductCategoryId + } + if req.FcrId != nil { + updateBody["fcr_id"] = *req.FcrId + } + if req.LocationId != nil { + updateBody["location_id"] = *req.LocationId + } + if req.Period != nil { + updateBody["period"] = *req.Period + } + + var newKandangIDs []uint + if req.KandangIds != nil { + if len(req.KandangIds) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids cannot be empty") + } + newKandangIDs = uniqueUintSlice(req.KandangIds) + kandangs, err := s.KandangRepo.GetByIDs(c.Context(), newKandangIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") + } + if len(kandangs) != len(newKandangIDs) { + return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") + } + for _, k := range kandangs { + if k.ProjectFlockId != nil && *k.ProjectFlockId != id { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang %s sudah terikat dengan project flock lain", k.Name)) + } + } + } + + tx := s.Repository.DB().Begin() + if tx.Error != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + + projectRepo := s.Repository.WithTx(tx) + if len(updateBody) > 0 { + if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to update projectflock: %+v", err) + return nil, err + } + } + + if req.KandangIds != nil { + existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) + for _, k := range existing.Kandangs { + existingIDs[k.Id] = struct{}{} + } + newSet := make(map[uint]struct{}, len(newKandangIDs)) + for _, id := range newKandangIDs { + newSet[id] = struct{}{} + } + + var toDetach []uint + for id := range existingIDs { + if _, ok := newSet[id]; !ok { + toDetach = append(toDetach, id) + } + } + + var toAttach []uint + for id := range newSet { + if _, ok := existingIDs[id]; !ok { + toAttach = append(toAttach, id) + } + } + + if len(toDetach) > 0 { + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", toDetach). + Updates(map[string]any{"project_flock_id": nil}).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to detach kandangs: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + } + + if len(toAttach) > 0 { + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", toAttach). + Updates(map[string]any{"project_flock_id": id}).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to attach kandangs: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + } + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + + return s.GetOne(c, id) +} + +func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { + existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + if err != nil { + s.Log.Errorf("Failed to fetch projectflock %d before delete: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + tx := s.Repository.DB().Begin() + if tx.Error != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + + if len(existing.Kandangs) > 0 { + ids := make([]uint, len(existing.Kandangs)) + for i, k := range existing.Kandangs { + ids[i] = k.Id + } + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", ids). + Updates(map[string]any{"project_flock_id": nil}).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to detach kandangs before delete: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + } + + if err := s.Repository.WithTx(tx).DeleteOne(c.Context(), id); err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to delete projectflock: %+v", err) + return err + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + + return nil +} + +func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { + flock, err := s.FlockRepo.GetByID(c.Context(), flockID, func(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") + }) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found") + } + if err != nil { + s.Log.Errorf("Failed get flock %d for period summary: %+v", flockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") + } + + maxPeriod, err := s.Repository.GetMaxPeriodByFlock(c.Context(), flockID) + if err != nil { + s.Log.Errorf("Failed to compute next period for flock %d: %+v", flockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to compute next period") + } + + return &FlockPeriodSummary{ + Flock: *flock, + NextPeriod: maxPeriod + 1, + }, nil +} + +func uniqueUintSlice(values []uint) []uint { + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, v := range values { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go new file mode 100644 index 00000000..cab30918 --- /dev/null +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -0,0 +1,27 @@ +package validation + +type Create struct { + FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + ProductCategoryId uint `json:"product_category_id" validate:"required_strict,number,gt=0"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + Period int `json:"period" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` +} + +type Update struct { + FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` + AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` + ProductCategoryId *uint `json:"product_category_id,omitempty" validate:"omitempty,number,gt=0"` + FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + Period *int `json:"period,omitempty" validate:"omitempty,number,gt=0"` + KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go new file mode 100644 index 00000000..f93bc877 --- /dev/null +++ b/internal/modules/production/route.go @@ -0,0 +1,25 @@ +package production + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/modules" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" + // MODULE IMPORTS +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/production") + + allModules := []modules.Module{ + projectflocks.ProjectflockModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/route/route.go b/internal/route/route.go index 82b48166..b1cd62a4 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -12,6 +12,7 @@ import ( inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" // MODULE IMPORTS ) @@ -26,6 +27,7 @@ func Routes(app *fiber.App, db *gorm.DB) { master.MasterModule{}, constants.ConstantModule{}, inventory.InventoryModule{}, + production.ProductionModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 941c8a5e..dbc06660 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -59,6 +59,7 @@ var allFlagTypes = func() map[FlagType]struct{} { return m }() + func AllFlagTypes() map[FlagType]struct{} { return allFlagTypes } @@ -75,6 +76,8 @@ const ( WarehouseTypeKandang WarehouseType = "KANDANG" ) + + // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- @@ -97,6 +100,19 @@ const ( SupplierCategorySapronak SupplierCategory = "SAPRONAK" ) + + +// ------------------------------------------------------------------- +// Kandang Status +// ------------------------------------------------------------------- + +type KandangStatus string + +const ( + KandangStatusNonActive KandangStatus = "NON_ACTIVE" + KandangStatusPengajuan KandangStatus = "PENGAJUAN" + KandangStatusActive KandangStatus = "ACTIVE" +) // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -191,6 +207,14 @@ func IsValidWarehouseType(v string) bool { return false } +func IsValidKandangStatus(v string) bool { + switch KandangStatus(v) { + case KandangStatusNonActive, KandangStatusPengajuan, KandangStatusActive: + return true + } + return false +} + func IsValidCustomerSupplierType(v string) bool { switch CustomerSupplierType(v) { case CustomerSupplierTypeBisnis, CustomerSupplierTypeIndividual: diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go index e17c8ad5..2ca436d7 100644 --- a/test/integration/master_data/kandang_test.go +++ b/test/integration/master_data/kandang_test.go @@ -5,16 +5,19 @@ import ( "testing" "github.com/gofiber/fiber/v2" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" ) func TestKandangIntegration(t *testing.T) { - app, _ := setupIntegrationApp(t) + app, db := setupIntegrationApp(t) areaID := createArea(t, app, "Area Kandang") locationID := createLocation(t, app, "Location For Kandang", "Address", areaID) t.Run("create kandang success", func(t *testing.T) { resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ "name": "Kandang OK", + "status": "ACTIVE", "location_id": locationID, "pic_id": 1, }) @@ -26,6 +29,7 @@ func TestKandangIntegration(t *testing.T) { t.Run("create kandang with unknown location fails", func(t *testing.T) { resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ "name": "Kandang Fail", + "status": "ACTIVE", "location_id": 999, "pic_id": 1, }) @@ -33,4 +37,47 @@ func TestKandangIntegration(t *testing.T) { t.Fatalf("expected 404, got %d: %s", resp.StatusCode, string(body)) } }) + + t.Run("cannot assign project floc with existing active kandang", func(t *testing.T) { + categoryID := createProductCategory(t, app, "DOC Category", "DOC1") + fcrID := createFcr(t, app, "FCR For Floc", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + flocID := createFlock(t, app, "Floc Test") + + projectFloc := entities.ProjectFlock{ + FlockId: flocID, + AreaId: areaID, + ProductCategoryId: categoryID, + FcrId: fcrID, + LocationId: locationID, + Period: 1, + CreatedBy: 1, + } + if err := db.Create(&projectFloc).Error; err != nil { + t.Fatalf("failed to seed project floc: %v", err) + } + + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ + "name": "Kandang Active 1", + "status": "ACTIVE", + "location_id": locationID, + "pic_id": 1, + "project_flock_id": projectFloc.Id, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating first kandang, got %d: %s", resp.StatusCode, string(body)) + } + + resp, body = doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ + "name": "Kandang Active 2", + "status": "ACTIVE", + "location_id": locationID, + "pic_id": 1, + "project_flock_id": projectFloc.Id, + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating second active kandang, got %d: %s", resp.StatusCode, string(body)) + } + }) } diff --git a/test/integration/master_data/master_data.go b/test/integration/master_data/master_data.go index 1ccc4fea..f206808f 100644 --- a/test/integration/master_data/master_data.go +++ b/test/integration/master_data/master_data.go @@ -40,6 +40,8 @@ func setupIntegrationApp(t *testing.T) (*fiber.App, *gorm.DB) { &entities.User{}, &entities.Area{}, &entities.Location{}, + &entities.Flock{}, + &entities.ProjectFlock{}, &entities.Kandang{}, &entities.Warehouse{}, &entities.Uom{}, @@ -152,6 +154,7 @@ func createKandang(t *testing.T, app *fiber.App, name string, locationID, picID t.Helper() resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ "name": name, + "status": "ACTIVE", "location_id": locationID, "pic_id": picID, }) @@ -291,6 +294,17 @@ func createFcr(t *testing.T, app *fiber.App, name string, standards []map[string return parseID(t, body) } +func createFlock(t *testing.T, app *fiber.App, name string) uint { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/flocks", map[string]any{ + "name": name, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating flock, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + func fetchFcr(t *testing.T, db *gorm.DB, id uint) entities.Fcr { t.Helper() var fcr entities.Fcr diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go new file mode 100644 index 00000000..5ba6d2fe --- /dev/null +++ b/test/integration/master_data/project_flock_test.go @@ -0,0 +1,119 @@ +package test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestProjectFlockSummary(t *testing.T) { + app, _ := setupIntegrationApp(t) + + areaID := createArea(t, app, "Area Project") + locationID := createLocation(t, app, "Location Project", "Address", areaID) + flockID := createFlock(t, app, "Flock Summary") + categoryID := createProductCategory(t, app, "DOC Summary", "DOCS") + fcrID := createFcr(t, app, "FCR Summary", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) + + createPayload := map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationID, + "period": 1, + "kandang_ids": []uint{kandangID}, + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) + } + + var createResp struct { + Data struct { + Id uint `json:"id"` + FlockId uint `json:"flock_id"` + AreaId uint `json:"area_id"` + ProductCategoryId uint `json:"product_category_id"` + FcrId uint `json:"fcr_id"` + LocationId uint `json:"location_id"` + Period int `json:"period"` + Flock struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"flock"` + Area struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"area"` + ProductCategory struct { + Id uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + } `json:"product_category"` + Fcr struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"fcr"` + Location struct { + Id uint `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + } `json:"location"` + Kandangs []struct { + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"kandangs"` + CreatedUser struct { + Id uint `json:"id"` + IdUser uint `json:"id_user"` + Email string `json:"email"` + Name string `json:"name"` + } `json:"created_user"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + if createResp.Data.FlockId != flockID || createResp.Data.Flock.Name == "" { + t.Fatalf("expected flock detail to be present, got %+v", createResp.Data.Flock) + } + if createResp.Data.AreaId != areaID || createResp.Data.Area.Name == "" { + t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area) + } + if createResp.Data.LocationId != locationID || createResp.Data.Location.Name == "" { + t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location) + } + if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { + t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) + } + + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) + } + + var summary struct { + Data struct { + NextPeriod int `json:"next_period"` + } `json:"data"` + } + if err := json.Unmarshal(body, &summary); err != nil { + t.Fatalf("failed to parse summary response: %v", err) + } + + if summary.Data.NextPeriod != 2 { + t.Fatalf("expected next_period 2, got %d", summary.Data.NextPeriod) + } +} + +func uintToString(v uint) string { + return fmt.Sprintf("%d", v) +} From 9f26d5c7841a6142194a1a7f7f37f581c8e6b968 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 16 Oct 2025 12:51:41 +0700 Subject: [PATCH 07/26] feat(BE): add product flags to product warehouse response --- .../dto/product_warehouse.dto.go | 21 ++++++++++++------- .../services/product_warehouse.service.go | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 2260e834..fdebb519 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -10,10 +10,10 @@ import ( // === DTO Structs === type ProductWarehouseBaseDTO struct { - Id uint `json:"id"` - ProductId uint `json:"product_id"` - WarehouseId uint `json:"warehouse_id"` - Quantity float64 `json:"quantity"` + Id uint `json:"id"` + ProductId uint `json:"product_id"` + WarehouseId uint `json:"warehouse_id"` + Quantity float64 `json:"quantity"` } type ProductWarehouseListDTO struct { @@ -31,9 +31,10 @@ type ProductWarehouseDetailDTO struct { // Nested DTOs for relations type ProductBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Sku string `json:"sku"` + Id uint `json:"id"` + Name string `json:"name"` + Sku string `json:"sku"` + Flags []string `json:"flags"` } type WarehouseBaseDTO struct { @@ -68,6 +69,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT if e.Product.Sku != nil { product.Sku = *e.Product.Sku } + // Map flags from Product relation + if len(e.Product.Flags) > 0 { + for _, f := range e.Product.Flags { + product.Flags = append(product.Flags, f.Name) + } + } dto.Product = &product } diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 7a1ff00e..9afe5707 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -34,7 +34,7 @@ func NewProductWarehouseService(repo repository.ProductWarehouseRepository, vali } func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("Product").Preload("Warehouse").Preload("CreatedUser") + return db.Preload("Product.Flags").Preload("Product").Preload("Warehouse").Preload("CreatedUser") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { From 3ec05eb76f9716890b63199c11d7d09ba797c084 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 16 Oct 2025 15:30:36 +0700 Subject: [PATCH 08/26] FIX[BE]: period without autoincrement --- ...0_add_project_flock_period_unique.down.sql | 1 + ...000_add_project_flock_period_unique.up.sql | 3 + internal/entities/projectfloc.go | 4 +- internal/entities/recording.go | 18 +++ .../repositories/projectflock.repository.go | 1 - .../services/projectflock.service.go | 27 +++- .../validations/projectflock.validation.go | 5 +- .../controllers/recording.controller.go | 140 ++++++++++++++++++ .../recordings/dto/recording.dto.go | 64 ++++++++ .../modules/production/recordings/module.go | 26 ++++ .../repositories/recording.repository.go | 21 +++ .../modules/production/recordings/route.go | 28 ++++ .../recordings/services/recording.service.go | 129 ++++++++++++++++ .../validations/recording.validation.go | 15 ++ internal/modules/production/route.go | 2 + .../master_data/project_flock_test.go | 76 ++++++++-- 16 files changed, 533 insertions(+), 27 deletions(-) create mode 100644 internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql create mode 100644 internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql create mode 100644 internal/entities/recording.go create mode 100644 internal/modules/production/recordings/controllers/recording.controller.go create mode 100644 internal/modules/production/recordings/dto/recording.dto.go create mode 100644 internal/modules/production/recordings/module.go create mode 100644 internal/modules/production/recordings/repositories/recording.repository.go create mode 100644 internal/modules/production/recordings/route.go create mode 100644 internal/modules/production/recordings/services/recording.service.go create mode 100644 internal/modules/production/recordings/validations/recording.validation.go diff --git a/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql b/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql new file mode 100644 index 00000000..f3cb3ddf --- /dev/null +++ b/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS project_flocks_flock_period_unique; diff --git a/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql b/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql new file mode 100644 index 00000000..40cebe2d --- /dev/null +++ b/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX project_flocks_flock_period_unique +ON project_flocks (flock_id, period) +WHERE deleted_at IS NULL; diff --git a/internal/entities/projectfloc.go b/internal/entities/projectfloc.go index 5332e336..eee7392a 100644 --- a/internal/entities/projectfloc.go +++ b/internal/entities/projectfloc.go @@ -8,12 +8,12 @@ import ( type ProjectFlock struct { Id uint `gorm:"primaryKey"` - FlockId uint `gorm:"not null"` + FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"` AreaId uint `gorm:"not null"` ProductCategoryId uint `gorm:"not null"` FcrId uint `gorm:"not null"` LocationId uint `gorm:"not null"` - Period int `gorm:"not null"` + Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/recording.go b/internal/entities/recording.go new file mode 100644 index 00000000..a6cf61b0 --- /dev/null +++ b/internal/entities/recording.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Recording struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 2d5abe0c..dde9ed35 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -57,7 +57,6 @@ func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, fl var max int if err := r.DB().WithContext(ctx). Model(&entity.ProjectFlock{}). - Unscoped(). Where("flock_id = ?", flockID). Select("COALESCE(MAX(period), 0)"). Scan(&max).Error; err != nil { diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index d8a98a32..4ad9d21d 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -15,6 +15,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type ProjectflockService interface { @@ -30,12 +31,12 @@ type projectflockService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ProjectflockRepository - FlockRepo flockRepository.FlockRepository + FlockRepo flockRepository.FlockRepository KandangRepo kandangRepository.KandangRepository } type FlockPeriodSummary struct { - Flock entity.Flock + Flock entity.Flock NextPeriod int } @@ -49,7 +50,7 @@ func NewProjectflockService( Log: utils.Log, Validate: validate, Repository: repo, - FlockRepo: flockRepo, + FlockRepo: flockRepo, KandangRepo: kandangRepo, } } @@ -127,19 +128,33 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } + var nextPeriod int + periodQuery := tx.Model(&entity.ProjectFlock{}). + Where("flock_id = ?", req.FlockId). + Clauses(clause.Locking{Strength: "UPDATE"}) + if err := periodQuery.Select("COALESCE(MAX(period), 0)").Scan(&nextPeriod).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to determine next period for flock %d: %+v", req.FlockId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine next period") + } + nextPeriod++ + projectRepo := s.Repository.WithTx(tx) createBody := &entity.ProjectFlock{ - FlockId: req.FlockId, + FlockId: req.FlockId, AreaId: req.AreaId, ProductCategoryId: req.ProductCategoryId, FcrId: req.FcrId, LocationId: req.LocationId, - Period: req.Period, + Period: nextPeriod, CreatedBy: 1, } if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { tx.Rollback() + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") + } s.Log.Errorf("Failed to create projectflock: %+v", err) return nil, err } @@ -353,7 +368,7 @@ func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) ( } return &FlockPeriodSummary{ - Flock: *flock, + Flock: *flock, NextPeriod: maxPeriod + 1, }, nil } diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index cab30918..8c1f7d06 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,17 +1,16 @@ package validation type Create struct { - FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` + FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` ProductCategoryId uint `json:"product_category_id" validate:"required_strict,number,gt=0"` FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - Period int `json:"period" validate:"required_strict,number,gt=0"` KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` } type Update struct { - FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` + FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` ProductCategoryId *uint `json:"product_category_id,omitempty" validate:"omitempty,number,gt=0"` FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go new file mode 100644 index 00000000..1215e8fc --- /dev/null +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type RecordingController struct { + RecordingService service.RecordingService +} + +func NewRecordingController(recordingService service.RecordingService) *RecordingController { + return &RecordingController{ + RecordingService: recordingService, + } +} + +func (u *RecordingController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.RecordingService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.RecordingListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all recordings successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToRecordingListDTOs(result), + }) +} + +func (u *RecordingController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.RecordingService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get recording successfully", + Data: dto.ToRecordingListDTO(*result), + }) +} + +func (u *RecordingController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.RecordingService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create recording successfully", + Data: dto.ToRecordingListDTO(*result), + }) +} + +func (u *RecordingController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.RecordingService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update recording successfully", + Data: dto.ToRecordingListDTO(*result), + }) +} + +func (u *RecordingController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.RecordingService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete recording successfully", + }) +} diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go new file mode 100644 index 00000000..7dbdec98 --- /dev/null +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -0,0 +1,64 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type RecordingBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type RecordingListDTO struct { + RecordingBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RecordingDetailDTO struct { + RecordingListDTO +} + +// === Mapper Functions === + +func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { + return RecordingBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToRecordingListDTO(e entity.Recording) RecordingListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return RecordingListDTO{ + RecordingBaseDTO: ToRecordingBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO { + result := make([]RecordingListDTO, len(e)) + for i, r := range e { + result[i] = ToRecordingListDTO(r) + } + return result +} + +func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { + return RecordingDetailDTO{ + RecordingListDTO: ToRecordingListDTO(e), + } +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go new file mode 100644 index 00000000..36ae8dd7 --- /dev/null +++ b/internal/modules/production/recordings/module.go @@ -0,0 +1,26 @@ +package recordings + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type RecordingModule struct{} + +func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + recordingRepo := rRecording.NewRecordingRepository(db) + userRepo := rUser.NewUserRepository(db) + + recordingService := sRecording.NewRecordingService(recordingRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + RecordingRoutes(router, userService, recordingService) +} + diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go new file mode 100644 index 00000000..8dd114d1 --- /dev/null +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type RecordingRepository interface { + repository.BaseRepository[entity.Recording] +} + +type RecordingRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Recording] +} + +func NewRecordingRepository(db *gorm.DB) RecordingRepository { + return &RecordingRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Recording](db), + } +} diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go new file mode 100644 index 00000000..6852a1ba --- /dev/null +++ b/internal/modules/production/recordings/route.go @@ -0,0 +1,28 @@ +package recordings + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/controllers" + recording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingService) { + ctrl := controller.NewRecordingController(s) + + route := v1.Group("/recordings") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go new file mode 100644 index 00000000..84220bd2 --- /dev/null +++ b/internal/modules/production/recordings/services/recording.service.go @@ -0,0 +1,129 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type RecordingService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type recordingService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.RecordingRepository +} + +func NewRecordingService(repo repository.RecordingRepository, validate *validator.Validate) RecordingService { + return &recordingService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s recordingService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + recordings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get recordings: %+v", err) + return nil, 0, err + } + return recordings, total, nil +} + +func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { + recording, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + if err != nil { + s.Log.Errorf("Failed get recording by id: %+v", err) + return nil, err + } + return recording, nil +} + +func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + createBody := &entity.Recording{ + Name: req.Name, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create recording: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + updateBody["name"] = *req.Name + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + s.Log.Errorf("Failed to update recording: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + s.Log.Errorf("Failed to delete recording: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go new file mode 100644 index 00000000..95505746 --- /dev/null +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go index f93bc877..73bbe8da 100644 --- a/internal/modules/production/route.go +++ b/internal/modules/production/route.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" + recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" // MODULE IMPORTS ) @@ -16,6 +17,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida allModules := []modules.Module{ projectflocks.ProjectflockModule{}, + recordings.RecordingModule{}, // MODULE REGISTRY } diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index 5ba6d2fe..22c73a5d 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -22,12 +22,11 @@ func TestProjectFlockSummary(t *testing.T) { kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) createPayload := map[string]any{ - "flock_id": flockID, + "flock_id": flockID, "area_id": areaID, "product_category_id": categoryID, "fcr_id": fcrID, "location_id": locationID, - "period": 1, "kandang_ids": []uint{kandangID}, } resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) @@ -37,14 +36,9 @@ func TestProjectFlockSummary(t *testing.T) { var createResp struct { Data struct { - Id uint `json:"id"` - FlockId uint `json:"flock_id"` - AreaId uint `json:"area_id"` - ProductCategoryId uint `json:"product_category_id"` - FcrId uint `json:"fcr_id"` - LocationId uint `json:"location_id"` - Period int `json:"period"` - Flock struct { + Id uint `json:"id"` + Period int `json:"period"` + Flock struct { Id uint `json:"id"` Name string `json:"name"` } `json:"flock"` @@ -82,18 +76,47 @@ func TestProjectFlockSummary(t *testing.T) { if err := json.Unmarshal(body, &createResp); err != nil { t.Fatalf("failed to parse create response: %v", err) } - if createResp.Data.FlockId != flockID || createResp.Data.Flock.Name == "" { + if createResp.Data.Flock.Id != flockID || createResp.Data.Flock.Name == "" { t.Fatalf("expected flock detail to be present, got %+v", createResp.Data.Flock) } - if createResp.Data.AreaId != areaID || createResp.Data.Area.Name == "" { + if createResp.Data.Area.Id != areaID || createResp.Data.Area.Name == "" { t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area) } - if createResp.Data.LocationId != locationID || createResp.Data.Location.Name == "" { + if createResp.Data.Location.Id != locationID || createResp.Data.Location.Name == "" { t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location) } if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) } + if createResp.Data.Period != 1 { + t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) + } + + secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) + secondPayload := map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationID, + "kandang_ids": []uint{secondKandangID}, + } + resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", secondPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating second project flock, got %d: %s", resp.StatusCode, string(body)) + } + var createRespSecond struct { + Data struct { + Id uint `json:"id"` + Period int `json:"period"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createRespSecond); err != nil { + t.Fatalf("failed to parse second create response: %v", err) + } + if createRespSecond.Data.Period != 2 { + t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) + } resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) if resp.StatusCode != fiber.StatusOK { @@ -109,8 +132,31 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("failed to parse summary response: %v", err) } - if summary.Data.NextPeriod != 2 { - t.Fatalf("expected next_period 2, got %d", summary.Data.NextPeriod) + if summary.Data.NextPeriod != 3 { + t.Fatalf("expected next_period 3, got %d", summary.Data.NextPeriod) + } + + resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createResp.Data.Id), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body)) + } + + resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) + } + + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body)) + } + + if err := json.Unmarshal(body, &summary); err != nil { + t.Fatalf("failed to parse summary response after delete: %v", err) + } + + if summary.Data.NextPeriod != 1 { + t.Fatalf("expected next_period 1 after soft deletes, got %d", summary.Data.NextPeriod) } } From 62a1011a4bdb371eeb6097c19759706be68ade64 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 16 Oct 2025 16:35:01 +0700 Subject: [PATCH 09/26] FIX[BE]: period and adjustment helper to function --- .../kandangs/services/kandang.service.go | 6 +- .../validations/kandang.validation.go | 2 +- .../repositories/projectflock.repository.go | 22 +++++++ .../services/projectflock.service.go | 66 ++++++++++++++++--- 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 9ece8898..6e836170 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -101,7 +101,11 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit ); err != nil { return nil, err } - status := strings.ToUpper(req.Status) + + status := strings.ToUpper(strings.TrimSpace(req.Status)) + if status == "" { + status = string(utils.KandangStatusNonActive) + } if !utils.IsValidKandangStatus(status) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang status") } diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index 0dc89212..f6886991 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -2,7 +2,7 @@ package validation type Create struct { Name string `json:"name" validate:"required_strict,min=3"` - Status string `json:"status" validate:"required_strict,min=3"` + Status string `json:"status,omitempty" validate:"omitempty,min=3"` LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index dde9ed35..476b061b 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -7,6 +7,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type ProjectflockRepository interface { @@ -14,6 +15,7 @@ type ProjectflockRepository interface { GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) + GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) } type ProjectflockRepositoryImpl struct { @@ -64,3 +66,23 @@ func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, fl } return max, nil } + +func (r *ProjectflockRepositoryImpl) GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) { + var payload struct { + Period int + } + if err := r.DB().WithContext(ctx). + Model(&entity.ProjectFlock{}). + Where("flock_id = ?", flockID). + Clauses(clause.Locking{Strength: "UPDATE"}). + Order("period DESC"). + Limit(1). + Select("period"). + Scan(&payload).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 1, nil + } + return 0, err + } + return payload.Period + 1, nil +} diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 4ad9d21d..e9ad3ddb 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -1,9 +1,12 @@ package service import ( + "context" "errors" "fmt" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" @@ -15,7 +18,6 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type ProjectflockService interface { @@ -106,6 +108,16 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") } + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, + common.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, + common.RelationCheck{Name: "Product category", ID: &req.ProductCategoryId, Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB())}, + common.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, + common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, + ); err != nil { + return nil, err + } + kandangIDs := uniqueUintSlice(req.KandangIds) kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) if err != nil { @@ -128,18 +140,14 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } - var nextPeriod int - periodQuery := tx.Model(&entity.ProjectFlock{}). - Where("flock_id = ?", req.FlockId). - Clauses(clause.Locking{Strength: "UPDATE"}) - if err := periodQuery.Select("COALESCE(MAX(period), 0)").Scan(&nextPeriod).Error; err != nil { + projectRepo := repository.NewProjectflockRepository(tx) + nextPeriod, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) + if err != nil { tx.Rollback() s.Log.Errorf("Failed to determine next period for flock %d: %+v", req.FlockId, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine next period") } - nextPeriod++ - projectRepo := s.Repository.WithTx(tx) createBody := &entity.ProjectFlock{ FlockId: req.FlockId, AreaId: req.AreaId, @@ -190,26 +198,58 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } updateBody := make(map[string]any) + var relationChecks []common.RelationCheck if req.FlockId != nil { updateBody["flock_id"] = *req.FlockId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "Flock", + ID: req.FlockId, + Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), + }) } if req.AreaId != nil { updateBody["area_id"] = *req.AreaId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "Area", + ID: req.AreaId, + Exists: relationExistsChecker[entity.Area](s.Repository.DB()), + }) } if req.ProductCategoryId != nil { updateBody["product_category_id"] = *req.ProductCategoryId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "Product category", + ID: req.ProductCategoryId, + Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB()), + }) } if req.FcrId != nil { updateBody["fcr_id"] = *req.FcrId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "FCR", + ID: req.FcrId, + Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), + }) } if req.LocationId != nil { updateBody["location_id"] = *req.LocationId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "Location", + ID: req.LocationId, + Exists: relationExistsChecker[entity.Location](s.Repository.DB()), + }) } if req.Period != nil { updateBody["period"] = *req.Period } + if len(relationChecks) > 0 { + if err := common.EnsureRelations(c.Context(), relationChecks...); err != nil { + return nil, err + } + } + var newKandangIDs []uint if req.KandangIds != nil { if len(req.KandangIds) == 0 { @@ -238,7 +278,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } - projectRepo := s.Repository.WithTx(tx) + projectRepo := repository.NewProjectflockRepository(tx) if len(updateBody) > 0 { if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { tx.Rollback() @@ -332,7 +372,7 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { } } - if err := s.Repository.WithTx(tx).DeleteOne(c.Context(), id); err != nil { + if err := repository.NewProjectflockRepository(tx).DeleteOne(c.Context(), id); err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") @@ -385,3 +425,9 @@ func uniqueUintSlice(values []uint) []uint { } return result } + +func relationExistsChecker[T any](db *gorm.DB) func(context.Context, uint) (bool, error) { + return func(ctx context.Context, id uint) (bool, error) { + return commonRepo.Exists[T](ctx, db, id) + } +} From 8c0790627a5f38aeda42a826b29f11bc421dc44e Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 16 Oct 2025 16:44:26 +0700 Subject: [PATCH 10/26] FIX[BE]: period and adjustment helper to function --- .../modules/master/kandangs/dto/kandang.dto.go | 2 ++ test/integration/master_data/kandang_test.go | 14 +++++++++++++- test/integration/master_data/project_flock_test.go | 3 +++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index d40498af..deed483c 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -13,6 +13,7 @@ import ( type KandangBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` + Status string `json:"status"` Location *locationDTO.LocationBaseDTO `json:"location"` Pic *userDTO.UserBaseDTO `json:"pic"` } @@ -46,6 +47,7 @@ func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { return KandangBaseDTO{ Id: e.Id, Name: e.Name, + Status: e.Status, Location: location, Pic: pic, } diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go index 2ca436d7..580196d4 100644 --- a/test/integration/master_data/kandang_test.go +++ b/test/integration/master_data/kandang_test.go @@ -1,6 +1,7 @@ package test import ( + "encoding/json" "net/http" "testing" @@ -17,13 +18,24 @@ func TestKandangIntegration(t *testing.T) { t.Run("create kandang success", func(t *testing.T) { resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ "name": "Kandang OK", - "status": "ACTIVE", "location_id": locationID, "pic_id": 1, }) if resp.StatusCode != fiber.StatusCreated { t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(body)) } + + var createResp struct { + Data struct { + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + if createResp.Data.Status == "" { + t.Fatalf("expected default status to be returned, got empty") + } }) t.Run("create kandang with unknown location fails", func(t *testing.T) { diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index 22c73a5d..59698ae9 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -88,6 +88,9 @@ func TestProjectFlockSummary(t *testing.T) { if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) } + if createResp.Data.Kandangs[0].Status == "" { + t.Fatalf("expected kandang status to be present, got %+v", createResp.Data.Kandangs[0]) + } if createResp.Data.Period != 1 { t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) } From 79700420d49945438747148411d5e4308bd857bf Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 17 Oct 2025 12:04:19 +0700 Subject: [PATCH 11/26] fix(BE): add missing product json in transfer get all & support flag param filter in product warehouses --- .../product_warehouse.controller.go | 3 +- .../services/product_warehouse.service.go | 12 +++- .../product_warehouse.validation.go | 9 +-- .../inventory/transfers/dto/transfer.dto.go | 64 ++++++++++++++----- .../transfers/services/transfer.service.go | 2 + 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index a0b72a4d..f21eef96 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -28,6 +28,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + Flag: c.Query("flag"), } result, totalResults, err := u.ProductWarehouseService.GetAll(c, query) @@ -71,5 +72,3 @@ func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error { Data: dto.ToProductWarehouseListDTO(*result), }) } - - diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 9afe5707..4871dfe1 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -34,7 +34,11 @@ func NewProductWarehouseService(repo repository.ProductWarehouseRepository, vali } func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("Product.Flags").Preload("Product").Preload("Warehouse").Preload("CreatedUser") + return db. + Preload("Product.Flags"). + Preload("Product"). + Preload("Warehouse"). + Preload("CreatedUser") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { @@ -55,6 +59,12 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } + if params.Flag != "" { + db = db.Joins("JOIN products ON products.id = product_warehouses.product_id") + db = db.Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products") + db = db.Where("flags.name = ?", params.Flag) + } + return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 02648300..30a5bed1 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -13,8 +13,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` - WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` + WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Flag string `query:"flag" validate:"omitempty"` } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 217e5038..1b08ecbb 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -57,17 +57,17 @@ type TransferDetailDTO struct { // Detail produk type TransferDetailItemDTO struct { - Id uint64 `json:"id"` - ProductId uint64 `json:"product_id"` - Quantity float64 `json:"quantity"` - BeforeQuantity float64 `json:"before_quantity"` - AfterQuantity float64 `json:"after_quantity"` + Id uint64 `json:"id"` + Product *ProductDTO `json:"product,omitempty"` + Quantity float64 `json:"quantity"` + BeforeQuantity float64 `json:"before_quantity"` + AfterQuantity float64 `json:"after_quantity"` } // Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` - SupplierId uint64 `json:"supplier_id"` + Supplier *SupplierDTO `json:"supplier,omitempty"` VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` @@ -83,6 +83,16 @@ type TransferDeliveryItemDTO struct { Quantity float64 `json:"quantity"` } +type ProductDTO struct { + Id uint64 `json:"id"` + Name string `json:"name"` +} + +type SupplierDTO struct { + Id uint64 `json:"id"` + Name string `json:"name"` +} + // === Mapper Functions === func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { @@ -114,6 +124,26 @@ func toAreaDTO(a *entity.Area) *AreaDTO { } } +func toProductDTO(p *entity.Product) *ProductDTO { + if p == nil { + return nil + } + return &ProductDTO{ + Id: uint64(p.Id), + Name: p.Name, + } +} + +func toSupplierDTO(s *entity.Supplier) *SupplierDTO { + if s == nil { + return nil + } + return &SupplierDTO{ + Id: uint64(s.Id), + Name: s.Name, + } +} + func toLocationDTO(l *entity.Location) *LocationDTO { if l == nil { return nil @@ -142,19 +172,19 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) createdUser = &mapped } - // Map details + var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ - Id: d.Id, - ProductId: d.ProductId, - Quantity: d.Quantity, + Id: d.Id, + Product: toProductDTO(d.Product), + Quantity: d.Quantity, }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - // Map delivery items + var items []TransferDeliveryItemDTO for _, item := range del.Items { items = append(items, TransferDeliveryItemDTO{ @@ -165,8 +195,8 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { } deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, - SupplierId: del.SupplierId, VehiclePlate: del.VehiclePlate, + Supplier: toSupplierDTO(del.Supplier), DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, DocumentPath: del.DocumentPath, @@ -198,9 +228,9 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ - Id: d.Id, - ProductId: d.ProductId, - Quantity: d.Quantity, + Id: d.Id, + Product: toProductDTO(d.Product), + Quantity: d.Quantity, }) } // Map deliveries @@ -208,7 +238,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { for _, del := range e.Deliveries { deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, - SupplierId: del.SupplierId, + Supplier: toSupplierDTO(del.Supplier), VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 7f18d257..4579d4c0 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -60,6 +60,8 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("ToWarehouse.Location"). Preload("ToWarehouse.Area"). Preload("Details"). + Preload("Details.Product"). + Preload("Deliveries.Supplier"). Preload("Deliveries.Items") } From a45c20d2ff14ccd3d610e5ed41911965c112e22d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 17 Oct 2025 20:43:31 +0700 Subject: [PATCH 12/26] fix(BE): improve product and warehouse existence check in adjustment service --- .../services/adjustment.service.go | 22 +++++++++++++++++-- .../services/product_warehouse.service.go | 5 +++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 929a5c8a..af89f442 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -83,7 +83,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") } if !isProductExist { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product not found") + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") } isWarehouseExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(req.WarehouseID)) @@ -92,7 +92,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") } if !isWarehouseExist { - return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not found") + return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } if req.Quantity <= 0 { @@ -187,6 +187,24 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu offset := (query.Page - 1) * query.Limit + isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) + if err != nil { + s.Log.Errorf("Failed to check warehouse existence: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") + } + if query.WarehouseID > 0 && !isWarehousesExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + + isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + if err != nil { + s.Log.Errorf("Failed to check product existence: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") + } + if query.ProductID > 0 && !isProductsExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 4871dfe1..0d86e073 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -72,6 +72,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) s.Log.Errorf("Failed to get productWarehouses: %+v", err) return nil, 0, err } + + if len(productWarehouses) == 0 { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProductWarehouses not found") + } + return productWarehouses, total, nil } From 68a670a2bd59a8919172c8a9b19e137fe3006f2a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 18 Oct 2025 16:30:13 +0700 Subject: [PATCH 13/26] feat(BE-116): add project chick in database schema --- ...53_create_project_chick_ins_table.down.sql | 18 +++ ...5953_create_project_chick_ins_table.up.sql | 1 + internal/entities/project_chickin.go | 24 +++ .../dto/product_warehouse.dto.go | 83 ++++++++++- .../services/product_warehouse.service.go | 6 +- .../controllers/chickin.controller.go | 140 ++++++++++++++++++ .../production/chickins/dto/chickin.dto.go | 84 +++++++++++ .../modules/production/chickins/module.go | 26 ++++ .../repositories/chickin.repository.go | 21 +++ internal/modules/production/chickins/route.go | 28 ++++ .../chickins/services/chickin.service.go | 129 ++++++++++++++++ .../validations/chickin.validation.go | 15 ++ internal/modules/production/route.go | 2 + 13 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 internal/database/migrations/20251017135953_create_project_chick_ins_table.down.sql create mode 100644 internal/database/migrations/20251017135953_create_project_chick_ins_table.up.sql create mode 100644 internal/entities/project_chickin.go create mode 100644 internal/modules/production/chickins/controllers/chickin.controller.go create mode 100644 internal/modules/production/chickins/dto/chickin.dto.go create mode 100644 internal/modules/production/chickins/module.go create mode 100644 internal/modules/production/chickins/repositories/chickin.repository.go create mode 100644 internal/modules/production/chickins/route.go create mode 100644 internal/modules/production/chickins/services/chickin.service.go create mode 100644 internal/modules/production/chickins/validations/chickin.validation.go diff --git a/internal/database/migrations/20251017135953_create_project_chick_ins_table.down.sql b/internal/database/migrations/20251017135953_create_project_chick_ins_table.down.sql new file mode 100644 index 00000000..ac9a0f59 --- /dev/null +++ b/internal/database/migrations/20251017135953_create_project_chick_ins_table.down.sql @@ -0,0 +1,18 @@ +CREATE TABLE project_chick_ins ( + id BIGSERIAL PRIMARY KEY, + project_floc_id BIGINT NOT NULL REFERENCES project_flocs (id), + product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id), + chick_in_date DATE NOT NULL, + quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0), + note TEXT, + created_by BIGINT NOT NULL REFERENCES users (id), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_project_chick_ins_project_floc_id ON project_chick_ins (project_floc_id); + +CREATE INDEX idx_project_chick_ins_product_warehouse_id ON project_chick_ins (product_warehouse_id); + +CREATE INDEX idx_project_chick_ins_created_by ON project_chick_ins (created_by); \ No newline at end of file diff --git a/internal/database/migrations/20251017135953_create_project_chick_ins_table.up.sql b/internal/database/migrations/20251017135953_create_project_chick_ins_table.up.sql new file mode 100644 index 00000000..b1435759 --- /dev/null +++ b/internal/database/migrations/20251017135953_create_project_chick_ins_table.up.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_chick_ins; \ No newline at end of file diff --git a/internal/entities/project_chickin.go b/internal/entities/project_chickin.go new file mode 100644 index 00000000..a4a00596 --- /dev/null +++ b/internal/entities/project_chickin.go @@ -0,0 +1,24 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectChickin struct { + Id uint `gorm:"primaryKey"` + ProjectFlocId uint `gorm:"not null"` + ProductWarehouseId uint `gorm:"not null"` + ChickInDate time.Time `gorm:"not null"` + Quantity float64 `gorm:"not null"` + Note string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + ProjectFloc ProjectFlock `gorm:"foreignKey:ProjectFlocId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index fdebb519..8c9f3846 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -4,7 +4,6 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) // === DTO Structs === @@ -18,11 +17,16 @@ type ProductWarehouseBaseDTO struct { type ProductWarehouseListDTO struct { ProductWarehouseBaseDTO - Product *ProductBaseDTO `json:"product,omitempty"` - Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` - CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Product *ProductBaseDTO `json:"product,omitempty"` + Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` + CreatedUser *UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserBaseDTO struct { + Id uint `json:"id"` + Username string `json:"username"` } type ProductWarehouseDetailDTO struct { @@ -38,6 +42,24 @@ type ProductBaseDTO struct { } type WarehouseBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Kandang *KandangBaseDTO `json:"kandang,omitempty"` + Location *LocationBaseDTO `json:"location,omitempty"` + Area *AreaBaseDTO `json:"area,omitempty"` +} + +type KandangBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type AreaBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` } @@ -69,7 +91,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT if e.Product.Sku != nil { product.Sku = *e.Product.Sku } - // Map flags from Product relation if len(e.Product.Flags) > 0 { for _, f := range e.Product.Flags { product.Flags = append(product.Flags, f.Name) @@ -84,12 +105,37 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT Id: e.Warehouse.Id, Name: e.Warehouse.Name, } + // Map Kandang jika ada + if e.Warehouse.Kandang != nil && e.Warehouse.Kandang.Id != 0 { + warehouse.Kandang = &KandangBaseDTO{ + Id: e.Warehouse.Kandang.Id, + Name: e.Warehouse.Kandang.Name, + } + } + // Map Location jika ada + if e.Warehouse.Location != nil && e.Warehouse.Location.Id != 0 { + warehouse.Location = &LocationBaseDTO{ + Id: e.Warehouse.Location.Id, + Name: e.Warehouse.Location.Name, + } + } + + if &e.Warehouse.Area != nil && e.Warehouse.Area.Id != 0 { + warehouse.Area = &AreaBaseDTO{ + Id: e.Warehouse.Area.Id, + Name: e.Warehouse.Area.Name, + } + } + dto.Warehouse = &warehouse } // Map CreatedUser relation jika ada if e.CreatedUser.Id != 0 { - user := userDTO.ToUserBaseDTO(e.CreatedUser) + user := UserBaseDTO{ + Id: e.CreatedUser.Id, + Username: e.CreatedUser.Name, + } dto.CreatedUser = &user } @@ -109,3 +155,24 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta ProductWarehouseListDTO: ToProductWarehouseListDTO(e), } } + +func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { + return KandangBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToLocationBaseDTO(e entity.Location) LocationBaseDTO { + return LocationBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToAreaBaseDTO(e entity.Area) AreaBaseDTO { + return AreaBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 0d86e073..a36e3621 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -35,9 +35,12 @@ func NewProductWarehouseService(repo repository.ProductWarehouseRepository, vali func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { return db. - Preload("Product.Flags"). Preload("Product"). + Preload("Product.Flags"). Preload("Warehouse"). + Preload("Warehouse.Location"). + Preload("Warehouse.Kandang"). + Preload("Warehouse.Area"). Preload("CreatedUser") } @@ -85,6 +88,7 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") } + if err != nil { s.Log.Errorf("Failed get productWarehouse by id: %+v", err) return nil, err diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go new file mode 100644 index 00000000..aae59ff2 --- /dev/null +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ChickinController struct { + ChickinService service.ChickinService +} + +func NewChickinController(chickinService service.ChickinService) *ChickinController { + return &ChickinController{ + ChickinService: chickinService, + } +} + +func (u *ChickinController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.ChickinService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all chickins successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToChickinListDTOs(result), + }) +} + +func (u *ChickinController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ChickinService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get chickin successfully", + Data: dto.ToChickinListDTO(*result), + }) +} + +func (u *ChickinController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ChickinService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create chickin successfully", + Data: dto.ToChickinListDTO(*result), + }) +} + +func (u *ChickinController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ChickinService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update chickin successfully", + Data: dto.ToChickinListDTO(*result), + }) +} + +func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete chickin successfully", + }) +} diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go new file mode 100644 index 00000000..6e317e79 --- /dev/null +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -0,0 +1,84 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ChickinBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ChickinSimpleDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ChickinListDTO struct { + ChickinBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ChickinDetailDTO struct { + ChickinListDTO +} + +// === Mapper Functions === + +func ToChickinBaseDTO(e entity.ProjectChickin) ChickinBaseDTO { + return ChickinBaseDTO{ + Id: e.Id, + + } +} + +func ToChickinSimpleDTO(e entity.ProjectChickin) ChickinSimpleDTO { + return ChickinSimpleDTO{ + Id: e.Id, + + } +} + +func ToChickinListDTO(e entity.ProjectChickin) ChickinListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return ChickinListDTO{ + ChickinBaseDTO: ToChickinBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToChickinListDTOs(e []entity.ProjectChickin) []ChickinListDTO { + result := make([]ChickinListDTO, len(e)) + for i, r := range e { + result[i] = ToChickinListDTO(r) + } + return result +} + +func ToChickinSimpleDTOs(e []entity.ProjectChickin) []ChickinSimpleDTO { + result := make([]ChickinSimpleDTO, len(e)) + for i, r := range e { + result[i] = ToChickinSimpleDTO(r) + } + return result +} + +func ToChickinDetailDTO(e entity.ProjectChickin) ChickinDetailDTO { + return ChickinDetailDTO{ + ChickinListDTO: ToChickinListDTO(e), + } +} \ No newline at end of file diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go new file mode 100644 index 00000000..330bf698 --- /dev/null +++ b/internal/modules/production/chickins/module.go @@ -0,0 +1,26 @@ +package chickins + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ChickinModule struct{} + +func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + chickinRepo := rChickin.NewChickinRepository(db) + userRepo := rUser.NewUserRepository(db) + + chickinService := sChickin.NewChickinService(chickinRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ChickinRoutes(router, userService, chickinService) +} + diff --git a/internal/modules/production/chickins/repositories/chickin.repository.go b/internal/modules/production/chickins/repositories/chickin.repository.go new file mode 100644 index 00000000..e8c3fccf --- /dev/null +++ b/internal/modules/production/chickins/repositories/chickin.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ChickinRepository interface { + repository.BaseRepository[entity.ProjectChickin] +} + +type ChickinRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectChickin] +} + +func NewChickinRepository(db *gorm.DB) ChickinRepository { + return &ChickinRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectChickin](db), + } +} diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go new file mode 100644 index 00000000..8948459e --- /dev/null +++ b/internal/modules/production/chickins/route.go @@ -0,0 +1,28 @@ +package chickins + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/controllers" + chickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService) { + ctrl := controller.NewChickinController(s) + + route := v1.Group("/chickins") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go new file mode 100644 index 00000000..00a3012e --- /dev/null +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -0,0 +1,129 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ChickinService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectChickin, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type chickinService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ChickinRepository +} + +func NewChickinService(repo repository.ChickinRepository, validate *validator.Validate) ChickinService { + return &chickinService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + chickins, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get chickins: %+v", err) + return nil, 0, err + } + return chickins, total, nil +} + +func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, error) { + chickin, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + if err != nil { + s.Log.Errorf("Failed get chickin by id: %+v", err) + return nil, err + } + return chickin, nil +} + +func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProjectChickin, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + createBody := &entity.ProjectChickin{ + ProjectFlocId: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create chickin: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + updateBody["name"] = *req.Name + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + s.Log.Errorf("Failed to update chickin: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + s.Log.Errorf("Failed to delete chickin: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go new file mode 100644 index 00000000..95505746 --- /dev/null +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go index 73bbe8da..597fbc62 100644 --- a/internal/modules/production/route.go +++ b/internal/modules/production/route.go @@ -9,6 +9,7 @@ import ( projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" + chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins" // MODULE IMPORTS ) @@ -18,6 +19,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida allModules := []modules.Module{ projectflocks.ProjectflockModule{}, recordings.RecordingModule{}, + chickins.ChickinModule{}, // MODULE REGISTRY } From f15e0d62e39baed9e82be69ad84ebdfdb70ff307 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 19 Oct 2025 23:24:56 +0700 Subject: [PATCH 14/26] FIX[BE]: if project flocs deleted kandangs reset to non_active and add filter get all project_flock by area,kandangs,period and location --- ...1018072532_project_flock_kandangs.down.sql | 1 + ...251018072532_project_flock_kandangs.up.sql | 17 ++ internal/database/seed/seeder.go | 40 +++ internal/entities/projectfloc.go | 37 +-- internal/entities/projectflock_kandang.go | 17 ++ .../controllers/projectflock.controller.go | 57 +++- .../project_flocks/dto/projectflock.dto.go | 8 +- .../production/project_flocks/module.go | 3 +- .../projectflock_kandang.repository.go | 64 +++++ .../services/projectflock.service.go | 199 ++++++++++++-- .../validations/projectflock.validation.go | 12 +- test/integration/master_data/master_data.go | 10 + .../master_data/project_flock_test.go | 249 +++++++++++++++++- 13 files changed, 663 insertions(+), 51 deletions(-) create mode 100644 internal/database/migrations/20251018072532_project_flock_kandangs.down.sql create mode 100644 internal/database/migrations/20251018072532_project_flock_kandangs.up.sql create mode 100644 internal/entities/projectflock_kandang.go create mode 100644 internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go diff --git a/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql b/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql new file mode 100644 index 00000000..fe912389 --- /dev/null +++ b/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_flock_kandangs; diff --git a/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql b/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql new file mode 100644 index 00000000..aba14be3 --- /dev/null +++ b/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE project_flock_kandangs ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL REFERENCES project_flocks (id) ON DELETE CASCADE ON UPDATE CASCADE, + kandang_id BIGINT NOT NULL REFERENCES kandangs (id) ON DELETE CASCADE ON UPDATE CASCADE, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + detached_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_project_flock_kandangs_project ON project_flock_kandangs (project_flock_id); +CREATE INDEX idx_project_flock_kandangs_kandang ON project_flock_kandangs (kandang_id); + +CREATE UNIQUE INDEX idx_project_flock_kandangs_active ON project_flock_kandangs (project_flock_id, kandang_id) +WHERE + detached_at IS NULL; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 21ce1a76..afa2a308 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -378,6 +378,9 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users if err := tx.Create(&kandang).Error; err != nil { return nil, err } + if err := syncPivotRelation(tx, projectFlockID, kandang.Id, createdBy); err != nil { + return nil, err + } } else if err != nil { return nil, err } else { @@ -394,6 +397,9 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { return nil, err } + if err := syncPivotRelation(tx, projectFlockID, kandang.Id, createdBy); err != nil { + return nil, err + } } result[seed.Name] = kandang.Id } @@ -401,6 +407,40 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return result, nil } +func syncPivotRelation(tx *gorm.DB, projectFlockID *uint, kandangID uint, createdBy uint) error { + if err := detachActivePivot(tx, kandangID); err != nil { + return err + } + if projectFlockID == nil { + return nil + } + return ensureActivePivot(tx, *projectFlockID, kandangID, createdBy) +} + +func detachActivePivot(tx *gorm.DB, kandangID uint) error { + return tx.Model(&entity.ProjectFlockKandang{}). + Where("kandang_id = ? AND detached_at IS NULL", kandangID). + Updates(map[string]any{"detached_at": time.Now()}).Error +} + +func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID, createdBy uint) error { + var pivot entity.ProjectFlockKandang + err := tx.Where("project_flock_id = ? AND kandang_id = ? AND detached_at IS NULL", projectFlockID, kandangID). + First(&pivot).Error + if err == nil { + return nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + newRecord := entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: kandangID, + CreatedBy: createdBy, + } + return tx.Create(&newRecord).Error +} + func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { seeds := []struct { Name string diff --git a/internal/entities/projectfloc.go b/internal/entities/projectfloc.go index eee7392a..2d581e84 100644 --- a/internal/entities/projectfloc.go +++ b/internal/entities/projectfloc.go @@ -7,22 +7,23 @@ import ( ) type ProjectFlock struct { - Id uint `gorm:"primaryKey"` - FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"` - AreaId uint `gorm:"not null"` - ProductCategoryId uint `gorm:"not null"` - FcrId uint `gorm:"not null"` - LocationId uint `gorm:"not null"` - Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Flock Flock `gorm:"foreignKey:FlockId;references:Id"` - Area Area `gorm:"foreignKey:AreaId;references:Id"` - ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` - Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + Id uint `gorm:"primaryKey"` + FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"` + AreaId uint `gorm:"not null"` + ProductCategoryId uint `gorm:"not null"` + FcrId uint `gorm:"not null"` + LocationId uint `gorm:"not null"` + Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Flock Flock `gorm:"foreignKey:FlockId;references:Id"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` + ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` + Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` } diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go new file mode 100644 index 00000000..0014a815 --- /dev/null +++ b/internal/entities/projectflock_kandang.go @@ -0,0 +1,17 @@ +package entities + +import "time" + +type ProjectFlockKandang struct { + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_active,priority:1,where:detached_at IS NULL"` + KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_active,priority:2,where:detached_at IS NULL"` + CreatedBy uint `gorm:"not null"` + AssignedAt time.Time `gorm:"autoCreateTime"` + DetachedAt *time.Time `gorm:"index"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 48134164..a1f2e263 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -1,8 +1,11 @@ package controller import ( + "encoding/json" + "fmt" "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -23,10 +26,58 @@ func NewProjectflockController(projectflockService service.ProjectflockService) } func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { + parseUintList := func(raw string) ([]uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + var ids []uint + if strings.HasPrefix(raw, "[") { + if err := json.Unmarshal([]byte(raw), &ids); err == nil { + return ids, nil + } + } + + parts := strings.Split(raw, ",") + for _, part := range parts { + part = strings.Trim(part, " \"[]") + if part == "" { + continue + } + v, err := strconv.Atoi(part) + if err != nil || v <= 0 { + return nil, fmt.Errorf("invalid kandang id: %s", part) + } + ids = append(ids, uint(v)) + } + return ids, nil + } + query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + SortBy: c.Query("sort_by", ""), + SortOrder: c.Query("sort_order", ""), + } + + if area := c.QueryInt("area_id", 0); area > 0 { + query.AreaId = uint(area) + } + if location := c.QueryInt("location_id", 0); location > 0 { + query.LocationId = uint(location) + } + if period := c.QueryInt("period", 0); period > 0 { + query.Period = period + } + + if kandangRaw := c.Query("kandang_id", c.Query("kandang_ids", "")); kandangRaw != "" { + ids, err := parseUintList(kandangRaw) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + query.KandangIds = ids } result, totalResults, err := u.ProjectflockService.GetAll(c, query) diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 227d0fe9..a42caebf 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -8,24 +8,24 @@ import ( ) type ProjectFlockBaseDTO struct { - Id uint `json:"id"` + Id uint `json:"id"` // FlockId uint `json:"flock_id"` // AreaId uint `json:"area_id"` // ProductCategoryId uint `json:"product_category_id"` // FcrId uint `json:"fcr_id"` // LocationId uint `json:"location_id"` - Period int `json:"period"` + Period int `json:"period"` } func ToProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { return ProjectFlockBaseDTO{ - Id: e.Id, + Id: e.Id, // FlockId: e.FlockId, // AreaId: e.AreaId, // ProductCategoryId: e.ProductCategoryId, // FcrId: e.FcrId, // LocationId: e.LocationId, - Period: e.Period, + Period: e.Period, } } diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 4f3167bc..5f1afbe3 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -20,9 +20,10 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid flockRepo := rFlock.NewFlockRepository(db) kandangRepo := rKandang.NewKandangRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) + projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go new file mode 100644 index 00000000..9b89a399 --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectFlockKandangRepository interface { + CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error + MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) error + GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) + WithTx(tx *gorm.DB) ProjectFlockKandangRepository + DB() *gorm.DB +} + +type projectFlockKandangRepositoryImpl struct { + db *gorm.DB +} + +func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository { + return &projectFlockKandangRepositoryImpl{db: db} +} + +func (r *projectFlockKandangRepositoryImpl) CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error { + if len(records) == 0 { + return nil + } + return r.db.WithContext(ctx).Create(&records).Error +} + +func (r *projectFlockKandangRepositoryImpl) MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) error { + if len(kandangIDs) == 0 { + return nil + } + return r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ? AND kandang_id IN ? AND detached_at IS NULL", projectFlockID, kandangIDs). + Updates(map[string]any{"detached_at": detachedAt}).Error +} + +func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) { + var records []entity.ProjectFlockKandang + if err := r.db.WithContext(ctx). + Preload("ProjectFlock"). + Preload("ProjectFlock.Flock"). + Preload("Kandang"). + Preload("CreatedUser"). + Order("project_flock_id ASC, assigned_at ASC"). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { + return &projectFlockKandangRepositoryImpl{db: tx} +} + +func (r *projectFlockKandangRepositoryImpl) DB() *gorm.DB { + return r.db +} diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index e9ad3ddb..8af6e452 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "strings" + "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -35,6 +37,7 @@ type projectflockService struct { Repository repository.ProjectflockRepository FlockRepo flockRepository.FlockRepository KandangRepo kandangRepository.KandangRepository + PivotRepo repository.ProjectFlockKandangRepository } type FlockPeriodSummary struct { @@ -46,6 +49,7 @@ func NewProjectflockService( repo repository.ProjectflockRepository, flockRepo flockRepository.FlockRepository, kandangRepo kandangRepository.KandangRepository, + pivotRepo repository.ProjectFlockKandangRepository, validate *validator.Validate, ) ProjectflockService { return &projectflockService{ @@ -54,6 +58,7 @@ func NewProjectflockService( Repository: repo, FlockRepo: flockRepo, KandangRepo: kandangRepo, + PivotRepo: pivotRepo, } } @@ -73,11 +78,81 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, err } + if params.Page <= 0 { + params.Page = 1 + } + if params.Limit <= 0 { + params.Limit = 10 + } + offset := (params.Page - 1) * params.Limit projectflocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - return db.Order("created_at DESC").Order("updated_at DESC") + + if params.AreaId > 0 { + db = db.Where("project_flocks.area_id = ?", params.AreaId) + } + if params.LocationId > 0 { + db = db.Where("project_flocks.location_id = ?", params.LocationId) + } + if params.Period > 0 { + db = db.Where("project_flocks.period = ?", params.Period) + } + if len(params.KandangIds) > 0 { + db = db.Where("EXISTS (SELECT 1 FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id AND kandangs.id IN ?)", params.KandangIds) + } + + if params.Search != "" { + normalizedSearch := strings.ToLower(strings.TrimSpace(params.Search)) + if normalizedSearch == "" { + for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { + db = db.Order(expr) + } + return db + } + likeQuery := "%" + normalizedSearch + "%" + db = db. + Joins("LEFT JOIN flocks ON flocks.id = project_flocks.flock_id"). + Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). + Joins("LEFT JOIN product_categories ON product_categories.id = project_flocks.product_category_id"). + Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). + Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). + Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). + Where(` + LOWER(flocks.name) LIKE ? + OR LOWER(areas.name) LIKE ? + OR LOWER(product_categories.name) LIKE ? + OR LOWER(product_categories.code) LIKE ? + OR LOWER(fcrs.name) LIKE ? + OR LOWER(locations.name) LIKE ? + OR LOWER(locations.address) LIKE ? + OR LOWER(created_users.name) LIKE ? + OR LOWER(created_users.email) LIKE ? + OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ? + OR EXISTS ( + SELECT 1 FROM kandangs + WHERE kandangs.project_flock_id = project_flocks.id + AND LOWER(kandangs.name) LIKE ? + ) + `, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + ) + } + for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { + db = db.Order(expr) + } + return db }) if err != nil { @@ -167,12 +242,10 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", kandangIDs). - Updates(map[string]any{"project_flock_id": createBody.Id}).Error; err != nil { + if err := s.attachKandangs(c.Context(), tx, createBody.Id, kandangIDs, createBody.CreatedBy); err != nil { tx.Rollback() - s.Log.Errorf("Failed to assign kandangs to projectflock: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to assign kandangs") + s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", createBody.Id, err) + return nil, err } if err := tx.Commit().Error; err != nil { @@ -315,22 +388,18 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if len(toDetach) > 0 { - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", toDetach). - Updates(map[string]any{"project_flock_id": nil}).Error; err != nil { + if err := s.detachKandangs(c.Context(), tx, id, toDetach, false); err != nil { tx.Rollback() - s.Log.Errorf("Failed to detach kandangs: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + s.Log.Errorf("Failed to detach kandangs from projectflock %d: %+v", id, err) + return nil, err } } if len(toAttach) > 0 { - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", toAttach). - Updates(map[string]any{"project_flock_id": id}).Error; err != nil { + if err := s.attachKandangs(c.Context(), tx, id, toAttach, existing.CreatedBy); err != nil { tx.Rollback() - s.Log.Errorf("Failed to attach kandangs: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", id, err) + return nil, err } } } @@ -363,12 +432,10 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { for i, k := range existing.Kandangs { ids[i] = k.Id } - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", ids). - Updates(map[string]any{"project_flock_id": nil}).Error; err != nil { + if err := s.detachKandangs(c.Context(), tx, id, ids, true); err != nil { tx.Rollback() - s.Log.Errorf("Failed to detach kandangs before delete: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + s.Log.Errorf("Failed to detach kandangs before deleting projectflock %d: %+v", id, err) + return err } } @@ -431,3 +498,93 @@ func relationExistsChecker[T any](db *gorm.DB) func(context.Context, uint) (bool return commonRepo.Exists[T](ctx, db, id) } } + +func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []string { + direction := "ASC" + if strings.ToLower(sortOrder) == "desc" { + direction = "DESC" + } + + switch sortBy { + case "area": + return []string{ + fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "location": + return []string{ + fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "kandangs": + return []string{ + fmt.Sprintf("(SELECT COUNT(*) FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "period": + return []string{ + fmt.Sprintf("project_flocks.period %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + default: + return []string{ + "project_flocks.created_at DESC", + "project_flocks.updated_at DESC", + } + } +} + +func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint, createdBy uint) error { + if len(kandangIDs) == 0 { + return nil + } + + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Updates(map[string]any{"project_flock_id": projectFlockID}).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + + pivotRepo := s.pivotRepoWithTx(tx) + records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) + for i, id := range kandangIDs { + records[i] = &entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: id, + CreatedBy: createdBy, + } + } + if err := pivotRepo.CreateMany(ctx, records); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") + } + return nil +} + +func (s projectflockService) detachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint, resetStatus bool) error { + if len(kandangIDs) == 0 { + return nil + } + + updates := map[string]any{"project_flock_id": nil} + if resetStatus { + updates["status"] = string(utils.KandangStatusNonActive) + } + + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Updates(updates).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + + if err := s.pivotRepoWithTx(tx).MarkDetached(ctx, projectFlockID, kandangIDs, time.Now()); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") + } + return nil +} + +func (s projectflockService) pivotRepoWithTx(tx *gorm.DB) repository.ProjectFlockKandangRepository { + if s.PivotRepo == nil { + return repository.NewProjectFlockKandangRepository(tx) + } + return s.PivotRepo.WithTx(tx) +} diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 8c1f7d06..0d8d3a80 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -20,7 +20,13 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=area location kandangs period"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` + LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` + Period int `query:"period" validate:"omitempty,number,gt=0"` + KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` } diff --git a/test/integration/master_data/master_data.go b/test/integration/master_data/master_data.go index f206808f..d43ddf15 100644 --- a/test/integration/master_data/master_data.go +++ b/test/integration/master_data/master_data.go @@ -42,6 +42,7 @@ func setupIntegrationApp(t *testing.T) (*fiber.App, *gorm.DB) { &entities.Location{}, &entities.Flock{}, &entities.ProjectFlock{}, + &entities.ProjectFlockKandang{}, &entities.Kandang{}, &entities.Warehouse{}, &entities.Uom{}, @@ -191,6 +192,15 @@ func fetchCustomer(t *testing.T, db *gorm.DB, id uint) entities.Customer { return customer } +func fetchKandang(t *testing.T, db *gorm.DB, id uint) entities.Kandang { + t.Helper() + var kandang entities.Kandang + if err := db.Preload("ProjectFlock").First(&kandang, id).Error; err != nil { + t.Fatalf("failed to fetch kandang: %v", err) + } + return kandang +} + func createSupplier(t *testing.T, app *fiber.App, name, alias, category string) uint { t.Helper() identifier := strings.ToLower(strings.ReplaceAll(name, " ", "_")) diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index 59698ae9..c5e0442c 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -4,13 +4,17 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "testing" "github.com/gofiber/fiber/v2" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) func TestProjectFlockSummary(t *testing.T) { - app, _ := setupIntegrationApp(t) + app, db := setupIntegrationApp(t) areaID := createArea(t, app, "Area Project") locationID := createLocation(t, app, "Location Project", "Address", areaID) @@ -95,6 +99,21 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) } + var pivotRecords []entities.ProjectFlockKandang + if err := db.Where("project_flock_id = ?", createResp.Data.Id).Find(&pivotRecords).Error; err != nil { + t.Fatalf("failed to fetch pivot records: %v", err) + } + if len(pivotRecords) != 1 { + t.Fatalf("expected 1 pivot record, got %d", len(pivotRecords)) + } + firstPivotRecord := pivotRecords[0] + if firstPivotRecord.KandangId != kandangID { + t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) + } + if firstPivotRecord.DetachedAt != nil { + t.Fatalf("expected pivot DetachedAt to be nil for active assignment, got %v", firstPivotRecord.DetachedAt) + } + secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) secondPayload := map[string]any{ "flock_id": flockID, @@ -121,6 +140,21 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) } + pivotRecords = nil + if err := db.Where("project_flock_id = ?", createRespSecond.Data.Id).Find(&pivotRecords).Error; err != nil { + t.Fatalf("failed to fetch second pivot records: %v", err) + } + if len(pivotRecords) != 1 { + t.Fatalf("expected 1 pivot record for second project, got %d", len(pivotRecords)) + } + secondPivotRecord := pivotRecords[0] + if secondPivotRecord.KandangId != secondKandangID { + t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) + } + if secondPivotRecord.DetachedAt != nil { + t.Fatalf("expected second pivot DetachedAt to be nil, got %v", secondPivotRecord.DetachedAt) + } + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) if resp.StatusCode != fiber.StatusOK { t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) @@ -144,11 +178,49 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body)) } + firstKandang := fetchKandang(t, db, kandangID) + if firstKandang.ProjectFlockId != nil { + t.Fatalf("expected project_flock_id to be nil after delete, got %v", *firstKandang.ProjectFlockId) + } + if firstKandang.Status != string(utils.KandangStatusNonActive) { + t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) + } + + var firstPivot entities.ProjectFlockKandang + if err := db.First(&firstPivot, firstPivotRecord.Id).Error; err != nil { + t.Fatalf("failed to reload first pivot record: %v", err) + } + if firstPivot.DetachedAt == nil { + t.Fatalf("expected first pivot DetachedAt to be set after delete") + } + if firstPivot.ProjectFlockId != createResp.Data.Id { + t.Fatalf("expected first pivot project_flock_id %d, got %d", createResp.Data.Id, firstPivot.ProjectFlockId) + } + resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) if resp.StatusCode != fiber.StatusOK { t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) } + secondKandang := fetchKandang(t, db, secondKandangID) + if secondKandang.ProjectFlockId != nil { + t.Fatalf("expected second project_flock_id to be nil after delete, got %v", *secondKandang.ProjectFlockId) + } + if secondKandang.Status != string(utils.KandangStatusNonActive) { + t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) + } + + var secondPivot entities.ProjectFlockKandang + if err := db.First(&secondPivot, secondPivotRecord.Id).Error; err != nil { + t.Fatalf("failed to reload second pivot record: %v", err) + } + if secondPivot.DetachedAt == nil { + t.Fatalf("expected second pivot DetachedAt to be set after delete") + } + if secondPivot.ProjectFlockId != createRespSecond.Data.Id { + t.Fatalf("expected second pivot project_flock_id %d, got %d", createRespSecond.Data.Id, secondPivot.ProjectFlockId) + } + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) if resp.StatusCode != fiber.StatusOK { t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body)) @@ -166,3 +238,178 @@ func TestProjectFlockSummary(t *testing.T) { func uintToString(v uint) string { return fmt.Sprintf("%d", v) } + +func TestProjectFlockSearchByRelatedFields(t *testing.T) { + app, _ := setupIntegrationApp(t) + + areaID := createArea(t, app, "Area Search Target") + locationID := createLocation(t, app, "Location Search Target", "Location Address Target", areaID) + flockID := createFlock(t, app, "Flock Search Target") + categoryID := createProductCategory(t, app, "Category Search Target", "CATGT") + fcrID := createFcr(t, app, "FCR Search Target", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + kandangID := createKandang(t, app, "Kandang Search Target", locationID, 1) + + createPayload := map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationID, + "kandang_ids": []uint{kandangID}, + } + + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) + } + + var createResp struct { + Data struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + + searchTerms := []string{ + "Flock Search Target", + "Area Search Target", + "Category Search Target", + "CATGT", + "FCR Search Target", + "Kandang Search Target", + "Location Search Target", + "Location Address Target", + "Tester", + "1", + } + + for _, term := range searchTerms { + path := "/api/production/project_flocks?search=" + url.QueryEscape(term) + resp, body := doJSONRequest(t, app, http.MethodGet, path, nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when searching for %q, got %d: %s", term, resp.StatusCode, string(body)) + } + + var listResp struct { + Data []struct { + Id uint `json:"id"` + } `json:"data"` + Meta struct { + TotalResults int64 `json:"total_results"` + } `json:"meta"` + } + if err := json.Unmarshal(body, &listResp); err != nil { + t.Fatalf("failed to parse list response for %q: %v", term, err) + } + if listResp.Meta.TotalResults == 0 { + t.Fatalf("expected at least one result when searching for %q", term) + } + if len(listResp.Data) == 0 { + t.Fatalf("expected data when searching for %q", term) + } + if listResp.Data[0].Id != createResp.Data.Id { + t.Fatalf("expected project flock id %d for search term %q, got %d", createResp.Data.Id, term, listResp.Data[0].Id) + } + } +} + +func TestProjectFlockSorting(t *testing.T) { + app, _ := setupIntegrationApp(t) + + areaA := createArea(t, app, "Area Alpha") + areaB := createArea(t, app, "Area Beta") + + locationA := createLocation(t, app, "Location Alpha", "Address Alpha", areaA) + locationB := createLocation(t, app, "Location Beta", "Address Beta", areaB) + + flockOne := createFlock(t, app, "Flock Sort One") + flockTwo := createFlock(t, app, "Flock Sort Two") + + categoryID := createProductCategory(t, app, "Category Sort", "CSORT") + fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + + kandangOne := createKandang(t, app, "Kandang Sort One", locationA, 1) + kandangTwo := createKandang(t, app, "Kandang Sort Two", locationB, 1) + kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) + + projectOnePayload := map[string]any{ + "flock_id": flockOne, + "area_id": areaA, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationA, + "kandang_ids": []uint{kandangOne}, + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectOnePayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 for project one, got %d: %s", resp.StatusCode, string(body)) + } + projectOneID := parseProjectFlockID(t, body) + + projectTwoPayload := map[string]any{ + "flock_id": flockTwo, + "area_id": areaB, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationB, + "kandang_ids": []uint{kandangTwo, kandangThree}, + } + resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectTwoPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 for project two, got %d: %s", resp.StatusCode, string(body)) + } + projectTwoID := parseProjectFlockID(t, body) + + updatePeriodPayload := map[string]any{"period": 5} + resp, body = doJSONRequest(t, app, http.MethodPatch, "/api/production/project_flocks/"+uintToString(projectTwoID), updatePeriodPayload) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating period, got %d: %s", resp.StatusCode, string(body)) + } + + assertOrder := func(t *testing.T, app *fiber.App, query string, expectedFirst uint) { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks?"+query, nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 for query %q, got %d: %s", query, resp.StatusCode, string(body)) + } + var listResp struct { + Data []struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &listResp); err != nil { + t.Fatalf("failed to parse list response for %q: %v", query, err) + } + if len(listResp.Data) == 0 { + t.Fatalf("expected data for query %q", query) + } + if listResp.Data[0].Id != expectedFirst { + t.Fatalf("expected first id %d for query %q, got %d", expectedFirst, query, listResp.Data[0].Id) + } + } + + assertOrder(t, app, "sort_by=area&sort_order=asc", projectOneID) + assertOrder(t, app, "sort_by=location&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=period&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=kandangs&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=kandangs&sort_order=asc", projectOneID) +} + +func parseProjectFlockID(t *testing.T, body []byte) uint { + t.Helper() + var resp struct { + Data struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to parse project flock response: %v", err) + } + return resp.Data.Id +} From 83c3e611136617ea63751d05b282182abd576f66 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 20 Oct 2025 06:01:16 +0700 Subject: [PATCH 15/26] feat(BE-115,116,117): implement chickin CRUD, approve logic, and stock availabilit --- ...53_create_project_chick_ins_table.down.sql | 18 -- ...5953_create_project_chick_ins_table.up.sql | 1 - ...49_create_project_chick_ins_table.down.sql | 1 + ...0649_create_project_chick_ins_table.up.sql | 21 ++ ...create_stock_availabilities_table.down.sql | 1 + ...6_create_stock_availabilities_table.up.sql | 15 ++ ...019141014_create_audit_logs_table.down.sql | 1 + ...51019141014_create_audit_logs_table.up.sql | 13 ++ internal/entities/audit_log.go | 18 ++ internal/entities/project_chickin.go | 28 +-- internal/entities/stock_availabilites.go | 26 +++ internal/entities/stock_log.go | 1 + .../modules/inventory/adjustments/module.go | 2 +- .../services/adjustment.service.go | 2 +- .../product_warehouse.controller.go | 3 +- .../services/product_warehouse.service.go | 21 +- .../product_warehouse.validation.go | 9 +- .../inventory/transfers/dto/transfer.dto.go | 64 ++---- .../modules/inventory/transfers/module.go | 2 +- .../transfers/services/transfer.service.go | 4 +- .../repositories/kandang.repository.go | 12 ++ .../repositories/warehouse.repository.go | 13 ++ .../controllers/chickin.controller.go | 23 ++- .../production/chickins/dto/chickin.dto.go | 141 +++++++++---- .../modules/production/chickins/module.go | 15 +- .../repositories/chickin.repository.go | 21 -- .../project_chickin.repository.go | 36 ++++ internal/modules/production/chickins/route.go | 1 + .../chickins/services/chickin.service.go | 195 ++++++++++++++++-- .../validations/chickin.validation.go | 5 +- internal/modules/production/route.go | 2 +- .../repositories/audit-logs.repository.go | 21 ++ .../stock-availabilites.repository.go | 21 ++ .../repositories/stock-logs.repository.go | 0 34 files changed, 558 insertions(+), 199 deletions(-) delete mode 100644 internal/database/migrations/20251017135953_create_project_chick_ins_table.down.sql delete mode 100644 internal/database/migrations/20251017135953_create_project_chick_ins_table.up.sql create mode 100644 internal/database/migrations/20251018120649_create_project_chick_ins_table.down.sql create mode 100644 internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql create mode 100644 internal/database/migrations/20251019040246_create_stock_availabilities_table.down.sql create mode 100644 internal/database/migrations/20251019040246_create_stock_availabilities_table.up.sql create mode 100644 internal/database/migrations/20251019141014_create_audit_logs_table.down.sql create mode 100644 internal/database/migrations/20251019141014_create_audit_logs_table.up.sql create mode 100644 internal/entities/audit_log.go create mode 100644 internal/entities/stock_availabilites.go delete mode 100644 internal/modules/production/chickins/repositories/chickin.repository.go create mode 100644 internal/modules/production/chickins/repositories/project_chickin.repository.go create mode 100644 internal/modules/shared/repositories/audit-logs.repository.go create mode 100644 internal/modules/shared/repositories/stock-availabilites.repository.go rename internal/modules/shared/{stock-logs => }/repositories/stock-logs.repository.go (100%) diff --git a/internal/database/migrations/20251017135953_create_project_chick_ins_table.down.sql b/internal/database/migrations/20251017135953_create_project_chick_ins_table.down.sql deleted file mode 100644 index ac9a0f59..00000000 --- a/internal/database/migrations/20251017135953_create_project_chick_ins_table.down.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE project_chick_ins ( - id BIGSERIAL PRIMARY KEY, - project_floc_id BIGINT NOT NULL REFERENCES project_flocs (id), - product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id), - chick_in_date DATE NOT NULL, - quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0), - note TEXT, - created_by BIGINT NOT NULL REFERENCES users (id), - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - deleted_at TIMESTAMPTZ -); - -CREATE INDEX idx_project_chick_ins_project_floc_id ON project_chick_ins (project_floc_id); - -CREATE INDEX idx_project_chick_ins_product_warehouse_id ON project_chick_ins (product_warehouse_id); - -CREATE INDEX idx_project_chick_ins_created_by ON project_chick_ins (created_by); \ No newline at end of file diff --git a/internal/database/migrations/20251017135953_create_project_chick_ins_table.up.sql b/internal/database/migrations/20251017135953_create_project_chick_ins_table.up.sql deleted file mode 100644 index b1435759..00000000 --- a/internal/database/migrations/20251017135953_create_project_chick_ins_table.up.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS project_chick_ins; \ No newline at end of file diff --git a/internal/database/migrations/20251018120649_create_project_chick_ins_table.down.sql b/internal/database/migrations/20251018120649_create_project_chick_ins_table.down.sql new file mode 100644 index 00000000..bb8f8a2d --- /dev/null +++ b/internal/database/migrations/20251018120649_create_project_chick_ins_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_chickins; \ No newline at end of file diff --git a/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql new file mode 100644 index 00000000..a3b7dfb3 --- /dev/null +++ b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE project_chickins ( + id BIGSERIAL PRIMARY KEY, + project_floc_id BIGINT NOT NULL, + chick_in_date DATE NOT NULL, + quantity NUMERIC(15, 3) NOT NULL, + note TEXT, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_project_chickins_project_floc_id ON project_chickins (project_floc_id); + +CREATE INDEX idx_project_chickins_created_by ON project_chickins (created_by); + +ALTER TABLE project_chickins +ADD CONSTRAINT fk_project_floc_id FOREIGN KEY (project_floc_id) REFERENCES project_flocks (id); + +ALTER TABLE project_chickins +ADD CONSTRAINT fk_created_by FOREIGN KEY (created_by) REFERENCES users (id); \ No newline at end of file diff --git a/internal/database/migrations/20251019040246_create_stock_availabilities_table.down.sql b/internal/database/migrations/20251019040246_create_stock_availabilities_table.down.sql new file mode 100644 index 00000000..1d50d98b --- /dev/null +++ b/internal/database/migrations/20251019040246_create_stock_availabilities_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS stock_availabilities; \ No newline at end of file diff --git a/internal/database/migrations/20251019040246_create_stock_availabilities_table.up.sql b/internal/database/migrations/20251019040246_create_stock_availabilities_table.up.sql new file mode 100644 index 00000000..bce6f7e6 --- /dev/null +++ b/internal/database/migrations/20251019040246_create_stock_availabilities_table.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE stock_availabilities ( + id BIGSERIAL PRIMARY KEY, + entity_type VARCHAR(50) NOT NULL, + entity_id BIGINT NOT NULL, + product_id BIGINT, + quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, + reserved_quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, + unit VARCHAR(20), + last_updated TIMESTAMPTZ DEFAULT now(), + created_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +ALTER TABLE stock_availabilities +ADD CONSTRAINT fk_product_id FOREIGN KEY (product_id) REFERENCES products (id); \ No newline at end of file diff --git a/internal/database/migrations/20251019141014_create_audit_logs_table.down.sql b/internal/database/migrations/20251019141014_create_audit_logs_table.down.sql new file mode 100644 index 00000000..4cf6b411 --- /dev/null +++ b/internal/database/migrations/20251019141014_create_audit_logs_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audit_logs; \ No newline at end of file diff --git a/internal/database/migrations/20251019141014_create_audit_logs_table.up.sql b/internal/database/migrations/20251019141014_create_audit_logs_table.up.sql new file mode 100644 index 00000000..13731dcc --- /dev/null +++ b/internal/database/migrations/20251019141014_create_audit_logs_table.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE audit_logs ( + id BIGSERIAL PRIMARY KEY, + table_name VARCHAR(100) NOT NULL, + record_id BIGINT NOT NULL, + action VARCHAR(30) NOT NULL, + before_data JSONB, + after_data JSONB, + changed_by BIGINT, + created_at TIMESTAMPTZ DEFAULT now() +); + +ALTER TABLE audit_logs +ADD CONSTRAINT fk_changed_by FOREIGN KEY (changed_by) REFERENCES users (id); \ No newline at end of file diff --git a/internal/entities/audit_log.go b/internal/entities/audit_log.go new file mode 100644 index 00000000..3b770125 --- /dev/null +++ b/internal/entities/audit_log.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" +) + +type AuditLog struct { + Id uint `gorm:"primaryKey"` + TableName string `gorm:"size:100;not null"` + RecordId uint `gorm:"not null"` + Action string `gorm:"size:30;not null"` + BeforeData string `gorm:"type:jsonb"` + AfterData string `gorm:"type:jsonb"` + ChangedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + User *User `gorm:"foreignKey:ChangedBy;references:Id"` +} diff --git a/internal/entities/project_chickin.go b/internal/entities/project_chickin.go index a4a00596..631c8ff3 100644 --- a/internal/entities/project_chickin.go +++ b/internal/entities/project_chickin.go @@ -6,19 +6,19 @@ import ( "gorm.io/gorm" ) -type ProjectChickin struct { - Id uint `gorm:"primaryKey"` - ProjectFlocId uint `gorm:"not null"` - ProductWarehouseId uint `gorm:"not null"` - ChickInDate time.Time `gorm:"not null"` - Quantity float64 `gorm:"not null"` - Note string `gorm:"type:text"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +const () - ProjectFloc ProjectFlock `gorm:"foreignKey:ProjectFlocId;references:Id"` - ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +type ProjectChickin struct { + Id uint `gorm:"primaryKey"` + ProjectFlocId uint `gorm:"not null"` + ChickInDate time.Time `gorm:"not null"` + Quantity float64 `gorm:"not null"` + Note string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlocId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/stock_availabilites.go b/internal/entities/stock_availabilites.go new file mode 100644 index 00000000..ec24d36b --- /dev/null +++ b/internal/entities/stock_availabilites.go @@ -0,0 +1,26 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + EntityTypeProjectFlockKandang = "PROJECT_FLOCK_KANDANG" +) + +type StockAvailability struct { + Id uint `gorm:"primaryKey"` + EntityType string `gorm:"size:50;not null"` + EntityId uint `gorm:"not null"` + ProductId uint `gorm:"not null"` + Quantity float64 `gorm:"not null;default:0"` + ReservedQuantity float64 `gorm:"not null;default:0"` + Unit string `gorm:"size:20"` + LastUpdated time.Time `gorm:"autoUpdateTime"` + CreatedAt time.Time `gorm:"autoCreateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Product *Product `gorm:"foreignKey:ProductId;references:Id"` +} diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 21e86bd4..6546e790 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -8,6 +8,7 @@ import ( const ( LogTypeAdjustment = "ADJUSTMENT" + LogTypeTransfer = "TRANSFER" ) const ( diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index cfe01118..b3e12676 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -9,7 +9,7 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index af89f442..69654b85 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -9,7 +9,7 @@ import ( ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" - stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index f21eef96..a0b72a4d 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -28,7 +28,6 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), - Flag: c.Query("flag"), } result, totalResults, err := u.ProductWarehouseService.GetAll(c, query) @@ -72,3 +71,5 @@ func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error { Data: dto.ToProductWarehouseListDTO(*result), }) } + + diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index a36e3621..9afe5707 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -34,14 +34,7 @@ func NewProductWarehouseService(repo repository.ProductWarehouseRepository, vali } func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("Product"). - Preload("Product.Flags"). - Preload("Warehouse"). - Preload("Warehouse.Location"). - Preload("Warehouse.Kandang"). - Preload("Warehouse.Area"). - Preload("CreatedUser") + return db.Preload("Product.Flags").Preload("Product").Preload("Warehouse").Preload("CreatedUser") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { @@ -62,12 +55,6 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } - if params.Flag != "" { - db = db.Joins("JOIN products ON products.id = product_warehouses.product_id") - db = db.Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products") - db = db.Where("flags.name = ?", params.Flag) - } - return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -75,11 +62,6 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) s.Log.Errorf("Failed to get productWarehouses: %+v", err) return nil, 0, err } - - if len(productWarehouses) == 0 { - return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProductWarehouses not found") - } - return productWarehouses, total, nil } @@ -88,7 +70,6 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") } - if err != nil { s.Log.Errorf("Failed get productWarehouse by id: %+v", err) return nil, err diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 30a5bed1..02648300 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -13,9 +13,8 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` - WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` - Flag string `query:"flag" validate:"omitempty"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` + WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 1b08ecbb..217e5038 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -57,17 +57,17 @@ type TransferDetailDTO struct { // Detail produk type TransferDetailItemDTO struct { - Id uint64 `json:"id"` - Product *ProductDTO `json:"product,omitempty"` - Quantity float64 `json:"quantity"` - BeforeQuantity float64 `json:"before_quantity"` - AfterQuantity float64 `json:"after_quantity"` + Id uint64 `json:"id"` + ProductId uint64 `json:"product_id"` + Quantity float64 `json:"quantity"` + BeforeQuantity float64 `json:"before_quantity"` + AfterQuantity float64 `json:"after_quantity"` } // Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` - Supplier *SupplierDTO `json:"supplier,omitempty"` + SupplierId uint64 `json:"supplier_id"` VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` @@ -83,16 +83,6 @@ type TransferDeliveryItemDTO struct { Quantity float64 `json:"quantity"` } -type ProductDTO struct { - Id uint64 `json:"id"` - Name string `json:"name"` -} - -type SupplierDTO struct { - Id uint64 `json:"id"` - Name string `json:"name"` -} - // === Mapper Functions === func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { @@ -124,26 +114,6 @@ func toAreaDTO(a *entity.Area) *AreaDTO { } } -func toProductDTO(p *entity.Product) *ProductDTO { - if p == nil { - return nil - } - return &ProductDTO{ - Id: uint64(p.Id), - Name: p.Name, - } -} - -func toSupplierDTO(s *entity.Supplier) *SupplierDTO { - if s == nil { - return nil - } - return &SupplierDTO{ - Id: uint64(s.Id), - Name: s.Name, - } -} - func toLocationDTO(l *entity.Location) *LocationDTO { if l == nil { return nil @@ -172,19 +142,19 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) createdUser = &mapped } - + // Map details var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ - Id: d.Id, - Product: toProductDTO(d.Product), - Quantity: d.Quantity, + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, }) } - + // Map deliveries var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - + // Map delivery items var items []TransferDeliveryItemDTO for _, item := range del.Items { items = append(items, TransferDeliveryItemDTO{ @@ -195,8 +165,8 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { } deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, + SupplierId: del.SupplierId, VehiclePlate: del.VehiclePlate, - Supplier: toSupplierDTO(del.Supplier), DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, DocumentPath: del.DocumentPath, @@ -228,9 +198,9 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ - Id: d.Id, - Product: toProductDTO(d.Product), - Quantity: d.Quantity, + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, }) } // Map deliveries @@ -238,7 +208,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { for _, del := range e.Deliveries { deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, - Supplier: toSupplierDTO(del.Supplier), + SupplierId: del.SupplierId, VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 21f0ec89..734f0f03 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -9,7 +9,7 @@ import ( rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" ) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 4579d4c0..bdc8abf6 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -10,7 +10,7 @@ import ( rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/go-playground/validator/v10" @@ -60,8 +60,6 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("ToWarehouse.Location"). Preload("ToWarehouse.Area"). Preload("Details"). - Preload("Details.Product"). - Preload("Deliveries.Supplier"). Preload("Deliveries.Items") } diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index b253fade..bcb03854 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -15,6 +15,7 @@ type KandangRepository interface { PicExists(ctx context.Context, areaId uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error) + GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) } @@ -69,3 +70,14 @@ func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Cont } return count > 0, nil } + +func (r *KandangRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) { + kandang := new(entity.Kandang) + err := r.db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + First(kandang).Error + if err != nil { + return nil, err + } + return kandang, nil +} diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go index 5c791e01..956c30ef 100644 --- a/internal/modules/master/warehouses/repositories/warehouse.repository.go +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -15,6 +15,7 @@ type WarehouseRepository interface { KandangExists(ctx context.Context, kandangId uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error) + GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) } type WarehouseRepositoryImpl struct { @@ -47,3 +48,15 @@ func (r *WarehouseRepositoryImpl) NameExists(ctx context.Context, name string, e func (r *WarehouseRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { return repository.Exists[entity.Warehouse](ctx, r.db, id) } + +func (r *WarehouseRepositoryImpl) GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) { + var warehouse entity.Warehouse + err := r.db.WithContext(ctx). + Where("kandang_id = ?", kandangId). + Where("deleted_at IS NULL"). + First(&warehouse).Error + if err != nil { + return nil, err + } + return &warehouse, nil +} diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go index aae59ff2..6514f8c8 100644 --- a/internal/modules/production/chickins/controllers/chickin.controller.go +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -88,7 +88,7 @@ func (u *ChickinController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create chickin successfully", - Data: dto.ToChickinListDTO(*result), + Data: result, }) } @@ -138,3 +138,24 @@ func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { Message: "Delete chickin successfully", }) } + +func (u *ChickinController) Approve(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ChickinService.Approve(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Approve chickin successfully", + Data: nil, + }) +} diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 6e317e79..7a8b6773 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -1,84 +1,135 @@ package dto import ( - "time" + "time" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) // === DTO Structs === type ChickinBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + ProjectFlocId uint `json:"project_floc_id"` + ChickInDate time.Time `json:"chick_in_date"` + Quantity float64 `json:"quantity"` + Note string `json:"note"` } type ChickinSimpleDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + ProjectFlocId uint `json:"project_floc_id"` + ChickInDate time.Time `json:"chick_in_date"` + Quantity float64 `json:"quantity"` + Note string `json:"note"` + CreatedBy uint `json:"created_by"` +} + +type UserBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` } type ChickinListDTO struct { - ChickinBaseDTO - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ChickinBaseDTO + ProjectFlock *ProjectFlockDTO `json:"project_flock"` + CreatedUser *UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProjectFlockDTO struct { + Id uint `json:"id"` + Period int `json:"period"` + FlockId uint `json:"flock_id"` + FlockName string `json:"flock_name"` +} + +// === Mapper Functions === + +func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { + return ProjectFlockDTO{ + Id: e.Id, + Period: e.Period, + FlockId: e.FlockId, + FlockName: e.Flock.Name, + } } type ChickinDetailDTO struct { - ChickinListDTO + ChickinListDTO } // === Mapper Functions === func ToChickinBaseDTO(e entity.ProjectChickin) ChickinBaseDTO { - return ChickinBaseDTO{ - Id: e.Id, - - } + return ChickinBaseDTO{ + Id: e.Id, + ProjectFlocId: e.ProjectFlocId, + ChickInDate: e.ChickInDate, + Quantity: e.Quantity, + Note: e.Note, + } } func ToChickinSimpleDTO(e entity.ProjectChickin) ChickinSimpleDTO { - return ChickinSimpleDTO{ - Id: e.Id, - - } + return ChickinSimpleDTO{ + Id: e.Id, + ProjectFlocId: e.ProjectFlocId, + ChickInDate: e.ChickInDate, + Quantity: e.Quantity, + Note: e.Note, + CreatedBy: e.CreatedBy, + } } func ToChickinListDTO(e entity.ProjectChickin) ChickinListDTO { - var createdUser *userDTO.UserBaseDTO - if e.CreatedUser.Id != 0 { - mapped := userDTO.ToUserBaseDTO(e.CreatedUser) - createdUser = &mapped - } + var createdUser *UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } - return ChickinListDTO{ - ChickinBaseDTO: ToChickinBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, - } + var projectFlock *ProjectFlockDTO + if e.ProjectFlock.Id != 0 { + mapped := ToProjectFlockDTO(e.ProjectFlock) + projectFlock = &mapped + } + + return ChickinListDTO{ + ChickinBaseDTO: ToChickinBaseDTO(e), + ProjectFlock: projectFlock, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } } func ToChickinListDTOs(e []entity.ProjectChickin) []ChickinListDTO { - result := make([]ChickinListDTO, len(e)) - for i, r := range e { - result[i] = ToChickinListDTO(r) - } - return result + result := make([]ChickinListDTO, len(e)) + for i, r := range e { + result[i] = ToChickinListDTO(r) + } + return result } func ToChickinSimpleDTOs(e []entity.ProjectChickin) []ChickinSimpleDTO { - result := make([]ChickinSimpleDTO, len(e)) - for i, r := range e { - result[i] = ToChickinSimpleDTO(r) - } - return result + result := make([]ChickinSimpleDTO, len(e)) + for i, r := range e { + result[i] = ToChickinSimpleDTO(r) + } + return result } func ToChickinDetailDTO(e entity.ProjectChickin) ChickinDetailDTO { - return ChickinDetailDTO{ - ChickinListDTO: ToChickinListDTO(e), - } -} \ No newline at end of file + return ChickinDetailDTO{ + ChickinListDTO: ToChickinListDTO(e), + } +} + +func ToUserBaseDTO(e entity.User) UserBaseDTO { + return UserBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 330bf698..30146724 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -5,8 +5,14 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" + rAuditLog "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -16,11 +22,16 @@ type ChickinModule struct{} func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { chickinRepo := rChickin.NewChickinRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) + auditlogrepo := rAuditLog.NewAuditLogRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + userRepo := rUser.NewUserRepository(db) - chickinService := sChickin.NewChickinService(chickinRepo, validate) + chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, auditlogrepo, validate) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) } - diff --git a/internal/modules/production/chickins/repositories/chickin.repository.go b/internal/modules/production/chickins/repositories/chickin.repository.go deleted file mode 100644 index e8c3fccf..00000000 --- a/internal/modules/production/chickins/repositories/chickin.repository.go +++ /dev/null @@ -1,21 +0,0 @@ -package repository - -import ( - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gorm.io/gorm" -) - -type ChickinRepository interface { - repository.BaseRepository[entity.ProjectChickin] -} - -type ChickinRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.ProjectChickin] -} - -func NewChickinRepository(db *gorm.DB) ChickinRepository { - return &ChickinRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectChickin](db), - } -} diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go new file mode 100644 index 00000000..64e2e4b4 --- /dev/null +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -0,0 +1,36 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectChickinRepository interface { + repository.BaseRepository[entity.ProjectChickin] + GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) +} + +type ChickinRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectChickin] +} + +func NewChickinRepository(db *gorm.DB) ProjectChickinRepository { + return &ChickinRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectChickin](db), + } +} + +func (r *ChickinRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) { + var chickin entity.ProjectChickin + err := r.DB().WithContext(ctx). + Where("project_floc_id = ?", projectFlockID). + Where("deleted_at IS NULL"). + First(&chickin).Error + if err != nil { + return nil, err + } + return &chickin, nil +} diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index 8948459e..5fa5237a 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -25,4 +25,5 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) + route.Post("/:id/approve", ctrl.Approve) } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 00a3012e..75dc0242 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -4,8 +4,14 @@ import ( "errors" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + AuditLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/go-playground/validator/v10" @@ -20,24 +26,39 @@ type ChickinService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectChickin, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) DeleteOne(ctx *fiber.Ctx, id uint) error + Approve(ctx *fiber.Ctx, id uint) error } type chickinService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ChickinRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectChickinRepository + KandangRepo KandangRepo.KandangRepository + WarehouseRepo rWarehouse.WarehouseRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockRepo rProjectFlock.ProjectflockRepository + AuditLogRepo AuditLogRepo.AuditLogRepository } -func NewChickinService(repo repository.ChickinRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, auditLogRepo AuditLogRepo.AuditLogRepository, validate *validator.Validate) ChickinService { return &chickinService{ - Log: utils.Log, - Validate: validate, - Repository: repo, + Log: utils.Log, + Validate: validate, + Repository: repo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockRepo: projectFlockRepo, + AuditLogRepo: auditLogRepo, } } func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser") + return db. + Preload("CreatedUser"). + Preload("ProjectFlock"). + Preload("ProjectFlock.ProductCategory") + } func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) { @@ -79,16 +100,125 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - createBody := &entity.ProjectChickin{ - ProjectFlocId: 1, + // ambil salah satu kandang dari project_floc_id dari kandang repository + kandang, err := s.KandangRepo.GetFirstByProjectFlockID(c.Context(), 1) + if err != nil { + s.Log.Errorf("Failed to get kandang: %+v", err) + return nil, err + } + // ambil warehouse dari kandangid + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), kandang.Id) + if err != nil { + s.Log.Errorf("Failed to get warehouse: %+v", err) + return nil, err + } + // getprojectflock id with relation + projectFlock, err := s.ProjectFlockRepo.GetByID( + c.Context(), + req.ProjectFlockId, + func(db *gorm.DB) *gorm.DB { + return db.Preload("ProductCategory") + }, + ) + if err != nil { + s.Log.Errorf("Failed to get project flock: %+v", err) + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") + } + // ambil quantity + var productWarehouse entity.ProductWarehouse + err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.ProductCategory.Code, warehouse.Id). + Order("created_at DESC"). + First(&productWarehouse).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") + } + s.Log.Errorf("Failed to get product warehouse: %+v", err) + return nil, err } - if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + if productWarehouse.Quantity < 1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient product quantity in warehouse") + } + + // masukan ke chic in + chickinDate, err := utils.ParseDateString(req.ChickInDate) + if err != nil { + s.Log.Errorf("Failed to parse chickin date: %+v", err) + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid ChickInDate format") + } + + newChickin := &entity.ProjectChickin{ + ProjectFlocId: req.ProjectFlockId, + ChickInDate: chickinDate, + Quantity: productWarehouse.Quantity, + Note: "", + CreatedBy: 1, //todo: ganti dengan + } + + err = s.Repository.CreateOne(c.Context(), newChickin, nil) + if err != nil { s.Log.Errorf("Failed to create chickin: %+v", err) return nil, err } - return s.GetOne(c, createBody.Id) + // Kurangi quantity di product warehouse + updatedQuantity := productWarehouse.Quantity - newChickin.Quantity + if updatedQuantity < 0 { + updatedQuantity = 0 + } + err = s.ProductWarehouseRepo.PatchOne(c.Context(), productWarehouse.Id, map[string]any{ + "quantity": updatedQuantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) + return nil, err + } + + // masukan check apakah stock availability ada, jika ada update, jika tidak buat baru + stockAvailability := &entity.StockAvailability{ + EntityType: entity.EntityTypeProjectFlockKandang, + ReservedQuantity: productWarehouse.Quantity, + EntityId: req.ProjectFlockId, //todo: nanti pakek projct flock kandang id + ProductId: productWarehouse.ProductId, + } + + var existingStockAvailability entity.StockAvailability + err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). + Where("entity_type = ? AND entity_id = ? AND product_id = ?", stockAvailability.EntityType, stockAvailability.EntityId, stockAvailability.ProductId). + First(&existingStockAvailability).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // buat baru + stockAvailability.ReservedQuantity = newChickin.Quantity + stockAvailability.Quantity = 0 + err = s.ProductWarehouseRepo.DB().WithContext(c.Context()).Create(stockAvailability).Error + if err != nil { + s.Log.Errorf("Failed to create stock availability: %+v", err) + return nil, err + } + } else { + s.Log.Errorf("Failed to get stock availability: %+v", err) + return nil, err + } + } else { + // update existing + newQuantity := existingStockAvailability.ReservedQuantity + newChickin.Quantity + err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). + Model(&existingStockAvailability). + Update("reserved_quantity", newQuantity).Error + if err != nil { + s.Log.Errorf("Failed to update stock availability: %+v", err) + return nil, err + } + + } + return nil, nil } func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) { @@ -98,10 +228,9 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody := make(map[string]any) - if req.Name != nil { - updateBody["name"] = *req.Name + if req.ChickInDate != "" { + updateBody["chick_in_date"] = req.ChickInDate } - if len(updateBody) == 0 { return s.GetOne(c, id) } @@ -125,5 +254,41 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to delete chickin: %+v", err) return err } + return nil +} + +func (s *chickinService) Approve(c *fiber.Ctx, id uint) error { + + chickin, err := s.Repository.GetByID( + c.Context(), + id, + nil, + ) + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + if err != nil { + s.Log.Errorf("Failed get chickin by id: %+v", err) + return err + } + + //pindahkan stock dari reserved ke actual stock + // get stock avaibility untuk di update + var stockAvailability entity.StockAvailability + err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). + Where("entity_type = ? AND entity_id = ? ", entity.EntityTypeProjectFlockKandang, chickin.ProjectFlocId). + First(&stockAvailability).Error + if err != nil { + s.Log.Errorf("Failed to get stock availability: %+v", err) + return err + } + + newReservedQuantity := stockAvailability.ReservedQuantity - chickin.Quantity + if newReservedQuantity < 0 { + newReservedQuantity = 0 + } + + + return nil } diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index 95505746..152b3f22 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -1,11 +1,12 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + ProjectFlockId uint `json:"project_flock_id" validate:"required,number,min=1"` + ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` } type Query struct { diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go index 597fbc62..b41ef1e7 100644 --- a/internal/modules/production/route.go +++ b/internal/modules/production/route.go @@ -7,9 +7,9 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins" projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" - chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins" // MODULE IMPORTS ) diff --git a/internal/modules/shared/repositories/audit-logs.repository.go b/internal/modules/shared/repositories/audit-logs.repository.go new file mode 100644 index 00000000..b247f3f2 --- /dev/null +++ b/internal/modules/shared/repositories/audit-logs.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type AuditLogRepository interface { + repository.BaseRepository[entity.AuditLog] +} + +type AuditLogRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.AuditLog] +} + +func NewAuditLogRepository(db *gorm.DB) AuditLogRepository { + return &AuditLogRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.AuditLog](db), + } +} diff --git a/internal/modules/shared/repositories/stock-availabilites.repository.go b/internal/modules/shared/repositories/stock-availabilites.repository.go new file mode 100644 index 00000000..9d3ae632 --- /dev/null +++ b/internal/modules/shared/repositories/stock-availabilites.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockAvailabilityRepository interface { + repository.BaseRepository[entity.StockAvailability] +} + +type StockAvailabilityRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockAvailability] +} + +func NewStockAvailabilityRepository(db *gorm.DB) StockAvailabilityRepository { + return &StockAvailabilityRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockAvailability](db), + } +} diff --git a/internal/modules/shared/stock-logs/repositories/stock-logs.repository.go b/internal/modules/shared/repositories/stock-logs.repository.go similarity index 100% rename from internal/modules/shared/stock-logs/repositories/stock-logs.repository.go rename to internal/modules/shared/repositories/stock-logs.repository.go From 5c3787886b38b066cb992fda08cc61b6c6c913fb Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 20 Oct 2025 08:45:31 +0700 Subject: [PATCH 16/26] FIX[BE]: adjust response on proudctwarehouses --- ...0649_create_project_chick_ins_table.up.sql | 31 ++- internal/entities/project_chickin.go | 22 +- .../services/product_warehouse.service.go | 9 +- .../production/chickins/dto/chickin.dto.go | 255 +++++++++++++----- .../modules/production/chickins/module.go | 3 +- .../chickins/services/chickin.service.go | 75 +++--- .../validations/chickin.validation.go | 4 +- .../projectflock_kandang.repository.go | 14 + 8 files changed, 291 insertions(+), 122 deletions(-) diff --git a/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql index a3b7dfb3..04475e21 100644 --- a/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql +++ b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql @@ -1,6 +1,6 @@ -CREATE TABLE project_chickins ( +CREATE TABLE IF NOT EXISTS project_chickins ( id BIGSERIAL PRIMARY KEY, - project_floc_id BIGINT NOT NULL, + project_floc_kandang_id BIGINT NOT NULL, chick_in_date DATE NOT NULL, quantity NUMERIC(15, 3) NOT NULL, note TEXT, @@ -10,12 +10,27 @@ CREATE TABLE project_chickins ( deleted_at TIMESTAMPTZ ); -CREATE INDEX idx_project_chickins_project_floc_id ON project_chickins (project_floc_id); +-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + ALTER TABLE project_chickins + ADD CONSTRAINT fk_project_floc_kandang_id + FOREIGN KEY (project_floc_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; -CREATE INDEX idx_project_chickins_created_by ON project_chickins (created_by); + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE project_chickins + ADD CONSTRAINT fk_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; -ALTER TABLE project_chickins -ADD CONSTRAINT fk_project_floc_id FOREIGN KEY (project_floc_id) REFERENCES project_flocks (id); +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_project_chickins_project_floc_kandang_id ON project_chickins (project_floc_kandang_id); -ALTER TABLE project_chickins -ADD CONSTRAINT fk_created_by FOREIGN KEY (created_by) REFERENCES users (id); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_project_chickins_created_by ON project_chickins (created_by); \ No newline at end of file diff --git a/internal/entities/project_chickin.go b/internal/entities/project_chickin.go index 631c8ff3..07536187 100644 --- a/internal/entities/project_chickin.go +++ b/internal/entities/project_chickin.go @@ -9,16 +9,16 @@ import ( const () type ProjectChickin struct { - Id uint `gorm:"primaryKey"` - ProjectFlocId uint `gorm:"not null"` - ChickInDate time.Time `gorm:"not null"` - Quantity float64 `gorm:"not null"` - Note string `gorm:"type:text"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Id uint `gorm:"primaryKey"` + ProjectFlocKandangId uint `gorm:"not null"` + ChickInDate time.Time `gorm:"not null"` + Quantity float64 `gorm:"not null"` + Note string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlocId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlocKandangId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 9afe5707..4fad5dc5 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -34,7 +34,14 @@ func NewProductWarehouseService(repo repository.ProductWarehouseRepository, vali } func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("Product.Flags").Preload("Product").Preload("Warehouse").Preload("CreatedUser") + return db. + Preload("Product.Flags"). + Preload("Product"). + Preload("Warehouse"). + Preload("Warehouse.Location"). + Preload("Warehouse.Area"). + Preload("Warehouse.Kandang"). + Preload("CreatedUser") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 7a8b6773..c89caa0e 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -6,23 +6,52 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) -// === DTO Structs === +// === DTO Structs (ordered) === -type ChickinBaseDTO struct { - Id uint `json:"id"` - ProjectFlocId uint `json:"project_floc_id"` - ChickInDate time.Time `json:"chick_in_date"` - Quantity float64 `json:"quantity"` - Note string `json:"note"` +type FlockDTO struct { + Id uint `json:"id"` + Name string `json:"name"` } -type ChickinSimpleDTO struct { - Id uint `json:"id"` - ProjectFlocId uint `json:"project_floc_id"` - ChickInDate time.Time `json:"chick_in_date"` - Quantity float64 `json:"quantity"` - Note string `json:"note"` - CreatedBy uint `json:"created_by"` +type KandangDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ProductCategoryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type AreaDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type FcrDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ProjectFlockDTO struct { + Id uint `json:"id"` + Period int `json:"period"` + Flock *FlockDTO `json:"flock"` + ProductCategory *ProductCategoryDTO `json:"product_category"` + Area *AreaDTO `json:"area"` + Fcr *FcrDTO `json:"fcr"` + Location *LocationDTO `json:"location"` +} + +type ProjectFlockKandangDTO struct { + Id uint `json:"id"` + ProjectFlock *ProjectFlockDTO `json:"project_flock"` + Kandang *KandangDTO `json:"kandang"` } type UserBaseDTO struct { @@ -30,56 +59,159 @@ type UserBaseDTO struct { Name string `json:"name"` } +type ChickinBaseDTO struct { + Id uint `json:"id"` + ProjectFlocKandangId uint `json:"project_floc_kandang_id"` + ChickInDate time.Time `json:"chick_in_date"` + Quantity float64 `json:"quantity"` + Note string `json:"note"` +} + +type ChickinSimpleDTO struct { + Id uint `json:"id"` + ProjectFlocKandangId uint `json:"project_floc_kandang_id"` + ChickInDate time.Time `json:"chick_in_date"` + Quantity float64 `json:"quantity"` + Note string `json:"note"` + CreatedBy uint `json:"created_by"` +} + type ChickinListDTO struct { ChickinBaseDTO - ProjectFlock *ProjectFlockDTO `json:"project_flock"` - CreatedUser *UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ProjectFlockDTO struct { - Id uint `json:"id"` - Period int `json:"period"` - FlockId uint `json:"flock_id"` - FlockName string `json:"flock_name"` -} - -// === Mapper Functions === - -func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { - return ProjectFlockDTO{ - Id: e.Id, - Period: e.Period, - FlockId: e.FlockId, - FlockName: e.Flock.Name, - } + ProjectFlockKandang *ProjectFlockKandangDTO `json:"project_flock_kandang"` + CreatedUser *UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ChickinDetailDTO struct { ChickinListDTO } -// === Mapper Functions === +// === Mapper Functions (ordered) === + +func ToFlockDTO(e entity.Flock) FlockDTO { + return FlockDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToKandangDTO(e entity.Kandang) KandangDTO { + return KandangDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToProductCategoryDTO(e entity.ProductCategory) ProductCategoryDTO { + return ProductCategoryDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToAreaDTO(e entity.Area) AreaDTO { + return AreaDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToFcrDTO(e entity.Fcr) FcrDTO { + return FcrDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToLocationDTO(e entity.Location) LocationDTO { + return LocationDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { + var flock *FlockDTO + if e.Flock.Id != 0 { + mapped := ToFlockDTO(e.Flock) + flock = &mapped + } + var productCategory *ProductCategoryDTO + if e.ProductCategory.Id != 0 { + mapped := ToProductCategoryDTO(e.ProductCategory) + productCategory = &mapped + } + var area *AreaDTO + if e.Area.Id != 0 { + mapped := ToAreaDTO(e.Area) + area = &mapped + } + var fcr *FcrDTO + if e.Fcr.Id != 0 { + mapped := ToFcrDTO(e.Fcr) + fcr = &mapped + } + var location *LocationDTO + if e.Location.Id != 0 { + mapped := ToLocationDTO(e.Location) + location = &mapped + } + return ProjectFlockDTO{ + Id: e.Id, + Period: e.Period, + Flock: flock, + ProductCategory: productCategory, + Area: area, + Fcr: fcr, + Location: location, + } +} + +func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { + var pf *ProjectFlockDTO + if e.ProjectFlock.Id != 0 { + mapped := ToProjectFlockDTO(e.ProjectFlock) + pf = &mapped + } + var kandang *KandangDTO + if e.Kandang.Id != 0 { + mapped := ToKandangDTO(e.Kandang) + kandang = &mapped + } + return ProjectFlockKandangDTO{ + Id: e.Id, + ProjectFlock: pf, + Kandang: kandang, + } +} + +func ToUserBaseDTO(e entity.User) UserBaseDTO { + return UserBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} func ToChickinBaseDTO(e entity.ProjectChickin) ChickinBaseDTO { return ChickinBaseDTO{ - Id: e.Id, - ProjectFlocId: e.ProjectFlocId, - ChickInDate: e.ChickInDate, - Quantity: e.Quantity, - Note: e.Note, + Id: e.Id, + ProjectFlocKandangId: e.ProjectFlocKandangId, + ChickInDate: e.ChickInDate, + Quantity: e.Quantity, + Note: e.Note, } } func ToChickinSimpleDTO(e entity.ProjectChickin) ChickinSimpleDTO { return ChickinSimpleDTO{ - Id: e.Id, - ProjectFlocId: e.ProjectFlocId, - ChickInDate: e.ChickInDate, - Quantity: e.Quantity, - Note: e.Note, - CreatedBy: e.CreatedBy, + Id: e.Id, + ProjectFlocKandangId: e.ProjectFlocKandangId, + ChickInDate: e.ChickInDate, + Quantity: e.Quantity, + Note: e.Note, + CreatedBy: e.CreatedBy, } } @@ -89,19 +221,17 @@ func ToChickinListDTO(e entity.ProjectChickin) ChickinListDTO { mapped := ToUserBaseDTO(e.CreatedUser) createdUser = &mapped } - - var projectFlock *ProjectFlockDTO - if e.ProjectFlock.Id != 0 { - mapped := ToProjectFlockDTO(e.ProjectFlock) - projectFlock = &mapped + var pfk *ProjectFlockKandangDTO + if e.ProjectFlockKandang.Id != 0 { + mapped := ToProjectFlockKandangDTO(e.ProjectFlockKandang) + pfk = &mapped } - return ChickinListDTO{ - ChickinBaseDTO: ToChickinBaseDTO(e), - ProjectFlock: projectFlock, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + ChickinBaseDTO: ToChickinBaseDTO(e), + ProjectFlockKandang: pfk, + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, } } @@ -126,10 +256,3 @@ func ToChickinDetailDTO(e entity.ProjectChickin) ChickinDetailDTO { ChickinListDTO: ToChickinListDTO(e), } } - -func ToUserBaseDTO(e entity.User) UserBaseDTO { - return UserBaseDTO{ - Id: e.Id, - Name: e.Name, - } -} diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 30146724..abfc56ca 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -25,12 +25,13 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * kandangRepo := rKandang.NewKandangRepository(db) auditlogrepo := rAuditLog.NewAuditLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, auditlogrepo, validate) + chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, auditlogrepo, projectflockkandangrepo, validate) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 75dc0242..fbb692fa 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -8,6 +8,7 @@ import ( KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" @@ -30,34 +31,41 @@ type ChickinService interface { } type chickinService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectChickinRepository - KandangRepo KandangRepo.KandangRepository - WarehouseRepo rWarehouse.WarehouseRepository - ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository - ProjectFlockRepo rProjectFlock.ProjectflockRepository - AuditLogRepo AuditLogRepo.AuditLogRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectChickinRepository + KandangRepo KandangRepo.KandangRepository + WarehouseRepo rWarehouse.WarehouseRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockRepo rProjectFlock.ProjectflockRepository + AuditLogRepo AuditLogRepo.AuditLogRepository + ProjectflockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, auditLogRepo AuditLogRepo.AuditLogRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, auditLogRepo AuditLogRepo.AuditLogRepository, projectflockkandangRepo rProjectFlockKandang.ProjectFlockKandangRepository, validate *validator.Validate) ChickinService { return &chickinService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - KandangRepo: kandangRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProjectFlockRepo: projectFlockRepo, - AuditLogRepo: auditLogRepo, + Log: utils.Log, + Validate: validate, + Repository: repo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockRepo: projectFlockRepo, + AuditLogRepo: auditLogRepo, + ProjectflockKandangRepo: projectflockkandangRepo, } } func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). - Preload("ProjectFlock"). - Preload("ProjectFlock.ProductCategory") + Preload("ProjectFlockKandang.Kandang"). + Preload("ProjectFlockKandang.ProjectFlock"). + Preload("ProjectFlockKandang.ProjectFlock.Flock"). + Preload("ProjectFlockKandang.ProjectFlock.ProductCategory"). + Preload("ProjectFlockKandang.ProjectFlock.Area"). + Preload("ProjectFlockKandang.ProjectFlock.Fcr"). + Preload("ProjectFlockKandang.ProjectFlock.Location") } @@ -101,25 +109,27 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } // ambil salah satu kandang dari project_floc_id dari kandang repository - kandang, err := s.KandangRepo.GetFirstByProjectFlockID(c.Context(), 1) + projectflockkandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), 1) if err != nil { - s.Log.Errorf("Failed to get kandang: %+v", err) + s.Log.Errorf("Failed to get projectflock kandang: %+v", err) return nil, err } // ambil warehouse dari kandangid - warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), kandang.Id) + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectflockkandang.KandangId) if err != nil { s.Log.Errorf("Failed to get warehouse: %+v", err) return nil, err } + // getprojectflock id with relation projectFlock, err := s.ProjectFlockRepo.GetByID( c.Context(), - req.ProjectFlockId, + projectflockkandang.ProjectFlockId, func(db *gorm.DB) *gorm.DB { return db.Preload("ProductCategory") }, ) + if err != nil { s.Log.Errorf("Failed to get project flock: %+v", err) return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") @@ -153,11 +163,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } newChickin := &entity.ProjectChickin{ - ProjectFlocId: req.ProjectFlockId, - ChickInDate: chickinDate, - Quantity: productWarehouse.Quantity, - Note: "", - CreatedBy: 1, //todo: ganti dengan + ProjectFlocKandangId: projectflockkandang.ProjectFlockId, + ChickInDate: chickinDate, + Quantity: productWarehouse.Quantity, + Note: "", + CreatedBy: 1, //todo: ganti dengan } err = s.Repository.CreateOne(c.Context(), newChickin, nil) @@ -183,7 +193,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit stockAvailability := &entity.StockAvailability{ EntityType: entity.EntityTypeProjectFlockKandang, ReservedQuantity: productWarehouse.Quantity, - EntityId: req.ProjectFlockId, //todo: nanti pakek projct flock kandang id + EntityId: projectflockkandang.Id, ProductId: productWarehouse.ProductId, } @@ -218,7 +228,8 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } } - return nil, nil + + return s.GetOne(c, newChickin.Id) } func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) { @@ -276,7 +287,7 @@ func (s *chickinService) Approve(c *fiber.Ctx, id uint) error { // get stock avaibility untuk di update var stockAvailability entity.StockAvailability err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). - Where("entity_type = ? AND entity_id = ? ", entity.EntityTypeProjectFlockKandang, chickin.ProjectFlocId). + Where("entity_type = ? AND entity_id = ? ", entity.EntityTypeProjectFlockKandang, chickin.ProjectFlocKandangId). First(&stockAvailability).Error if err != nil { s.Log.Errorf("Failed to get stock availability: %+v", err) @@ -288,7 +299,5 @@ func (s *chickinService) Approve(c *fiber.Ctx, id uint) error { newReservedQuantity = 0 } - - return nil } diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index 152b3f22..b57950b0 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -1,8 +1,8 @@ package validation type Create struct { - ProjectFlockId uint `json:"project_flock_id" validate:"required,number,min=1"` - ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` } type Update struct { diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 9b89a399..9999e1a8 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -9,6 +9,7 @@ import ( ) type ProjectFlockKandangRepository interface { + GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) error GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) @@ -62,3 +63,16 @@ func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKand func (r *projectFlockKandangRepositoryImpl) DB() *gorm.DB { return r.db } + +func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { + record := new(entity.ProjectFlockKandang) + if err := r.db.WithContext(ctx). + Preload("ProjectFlock"). + Preload("ProjectFlock.Flock"). + Preload("Kandang"). + Preload("CreatedUser"). + First(record, id).Error; err != nil { + return nil, err + } + return record, nil +} From 7b99b395293d85e56896c8ec236ef60bcb44061e Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 20 Oct 2025 11:25:42 +0700 Subject: [PATCH 17/26] feat(BE-117): implement CRUD endpoints for project --- ...0649_create_project_chick_ins_table.up.sql | 8 +- ...e_project_flock_populations_table.down.sql | 1 + ...ate_project_flock_populations_table.up.sql | 36 +++++ internal/entities/project_chickin.go | 20 +-- internal/entities/project_flock_population.go | 22 +++ .../inventory/transfers/dto/transfer.dto.go | 52 ++++--- .../transfers/services/transfer.service.go | 4 +- .../controllers/chickin.controller.go | 8 +- .../production/chickins/dto/chickin.dto.go | 26 ++-- .../modules/production/chickins/module.go | 3 +- .../chickins/services/chickin.service.go | 130 +++++++++--------- .../validations/chickin.validation.go | 6 +- .../production/project_flocks/module.go | 1 + .../project_flock_population_repository.go | 35 +++++ 14 files changed, 233 insertions(+), 119 deletions(-) create mode 100644 internal/database/migrations/20251020022357_create_project_flock_populations_table.down.sql create mode 100644 internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql create mode 100644 internal/entities/project_flock_population.go create mode 100644 internal/modules/production/project_flocks/repositories/project_flock_population_repository.go diff --git a/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql index 04475e21..25d3476d 100644 --- a/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql +++ b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS project_chickins ( id BIGSERIAL PRIMARY KEY, - project_floc_kandang_id BIGINT NOT NULL, + project_flock_kandang_id BIGINT NOT NULL, chick_in_date DATE NOT NULL, quantity NUMERIC(15, 3) NOT NULL, note TEXT, @@ -15,8 +15,8 @@ DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN ALTER TABLE project_chickins - ADD CONSTRAINT fk_project_floc_kandang_id - FOREIGN KEY (project_floc_kandang_id) + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) REFERENCES project_flock_kandangs(id) ON DELETE RESTRICT ON UPDATE CASCADE; END IF; @@ -31,6 +31,6 @@ BEGIN END $$; -- INDEXES -CREATE INDEX IF NOT EXISTS idx_project_chickins_project_floc_kandang_id ON project_chickins (project_floc_kandang_id); +CREATE INDEX IF NOT EXISTS idx_project_chickins_project_flock_kandang_id ON project_chickins (project_flock_kandang_id); CREATE INDEX IF NOT EXISTS idx_project_chickins_created_by ON project_chickins (created_by); \ No newline at end of file diff --git a/internal/database/migrations/20251020022357_create_project_flock_populations_table.down.sql b/internal/database/migrations/20251020022357_create_project_flock_populations_table.down.sql new file mode 100644 index 00000000..8fa11576 --- /dev/null +++ b/internal/database/migrations/20251020022357_create_project_flock_populations_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_flock_populations; \ No newline at end of file diff --git a/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql b/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql new file mode 100644 index 00000000..82b3e9a7 --- /dev/null +++ b/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS project_flock_populations ( + id BIGSERIAL PRIMARY KEY, + project_flock_kandang_id BIGINT NOT NULL, + initial_quantity NUMERIC(15, 3) NOT NULL, + current_quantity NUMERIC(15, 3) NOT NULL, + reserved_quantity NUMERIC(15, 3), + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + ALTER TABLE project_flock_populations + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE project_flock_populations + ADD CONSTRAINT fk_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_project_flock_populations_project_flock_kandang_id ON project_flock_populations (project_flock_kandang_id); + +CREATE INDEX IF NOT EXISTS idx_project_flock_populations_created_by ON project_flock_populations (created_by); \ No newline at end of file diff --git a/internal/entities/project_chickin.go b/internal/entities/project_chickin.go index 07536187..95a658c8 100644 --- a/internal/entities/project_chickin.go +++ b/internal/entities/project_chickin.go @@ -9,16 +9,16 @@ import ( const () type ProjectChickin struct { - Id uint `gorm:"primaryKey"` - ProjectFlocKandangId uint `gorm:"not null"` - ChickInDate time.Time `gorm:"not null"` - Quantity float64 `gorm:"not null"` - Note string `gorm:"type:text"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Id uint `gorm:"primaryKey"` + ProjectFlockKandangId uint `gorm:"not null"` + ChickInDate time.Time `gorm:"not null"` + Quantity float64 `gorm:"not null"` + Note string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlocKandangId;references:Id"` + ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/project_flock_population.go b/internal/entities/project_flock_population.go new file mode 100644 index 00000000..184ace65 --- /dev/null +++ b/internal/entities/project_flock_population.go @@ -0,0 +1,22 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectFlockPopulation struct { + Id uint `gorm:"primaryKey"` + ProjectFlockKandangId uint `gorm:"not null"` + InitialQuantity float64 `gorm:"type:numeric(15,3);not null"` + CurrentQuantity float64 `gorm:"type:numeric(15,3);not null"` + ReservedQuantity float64 `gorm:"type:numeric(15,3)"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 217e5038..82269852 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -23,6 +23,11 @@ type WarehouseSimpleDTO struct { Name string `json:"name"` } +type ProductSimpleDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + type AreaDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -33,6 +38,11 @@ type LocationDTO struct { Name string `json:"name"` } +type SuplierSimpleDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + type WarehouseDetailDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -57,17 +67,15 @@ type TransferDetailDTO struct { // Detail produk type TransferDetailItemDTO struct { - Id uint64 `json:"id"` - ProductId uint64 `json:"product_id"` - Quantity float64 `json:"quantity"` - BeforeQuantity float64 `json:"before_quantity"` - AfterQuantity float64 `json:"after_quantity"` + Id uint64 `json:"id"` + Proudct ProductSimpleDTO `json:"product"` + Quantity float64 `json:"quantity"` } // Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` - SupplierId uint64 `json:"supplier_id"` + Suplier SuplierSimpleDTO `json:"suplier"` VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` @@ -146,9 +154,12 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ - Id: d.Id, - ProductId: d.ProductId, - Quantity: d.Quantity, + Id: d.Id, + Proudct: ProductSimpleDTO{ + Id: d.Product.Id, + Name: d.Product.Name, + }, + Quantity: d.Quantity, }) } // Map deliveries @@ -164,8 +175,11 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } deliveries = append(deliveries, TransferDeliveryDTO{ - Id: del.Id, - SupplierId: del.SupplierId, + Id: del.Id, + Suplier: SuplierSimpleDTO{ + Id: del.Supplier.Id, + Name: del.Supplier.Name, + }, VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, @@ -198,17 +212,23 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ - Id: d.Id, - ProductId: d.ProductId, - Quantity: d.Quantity, + Id: d.Id, + Proudct: ProductSimpleDTO{ + Id: d.Product.Id, + Name: d.Product.Name, + }, + Quantity: d.Quantity, }) } // Map deliveries var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { deliveries = append(deliveries, TransferDeliveryDTO{ - Id: del.Id, - SupplierId: del.SupplierId, + Id: del.Id, + Suplier: SuplierSimpleDTO{ + Id: del.Supplier.Id, + Name: del.Supplier.Name, + }, VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index bdc8abf6..dbb4694b 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -60,7 +60,9 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("ToWarehouse.Location"). Preload("ToWarehouse.Area"). Preload("Details"). - Preload("Deliveries.Items") + Preload("Details.Product"). + Preload("Deliveries.Items"). + Preload("Deliveries.Supplier") } func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go index 6514f8c8..fadcbc3e 100644 --- a/internal/modules/production/chickins/controllers/chickin.controller.go +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -24,9 +24,9 @@ func NewChickinController(chickinService service.ChickinService) *ChickinControl func (u *ChickinController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), } result, totalResults, err := u.ChickinService.GetAll(c, query) @@ -88,7 +88,7 @@ func (u *ChickinController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create chickin successfully", - Data: result, + Data: dto.ToChickinListDTO(*result), }) } diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index c89caa0e..9fd29f3c 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -68,12 +68,12 @@ type ChickinBaseDTO struct { } type ChickinSimpleDTO struct { - Id uint `json:"id"` - ProjectFlocKandangId uint `json:"project_floc_kandang_id"` - ChickInDate time.Time `json:"chick_in_date"` - Quantity float64 `json:"quantity"` - Note string `json:"note"` - CreatedBy uint `json:"created_by"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ChickInDate time.Time `json:"chick_in_date"` + Quantity float64 `json:"quantity"` + Note string `json:"note"` + CreatedBy uint `json:"created_by"` } type ChickinListDTO struct { @@ -197,7 +197,7 @@ func ToUserBaseDTO(e entity.User) UserBaseDTO { func ToChickinBaseDTO(e entity.ProjectChickin) ChickinBaseDTO { return ChickinBaseDTO{ Id: e.Id, - ProjectFlocKandangId: e.ProjectFlocKandangId, + ProjectFlocKandangId: e.ProjectFlockKandangId, ChickInDate: e.ChickInDate, Quantity: e.Quantity, Note: e.Note, @@ -206,12 +206,12 @@ func ToChickinBaseDTO(e entity.ProjectChickin) ChickinBaseDTO { func ToChickinSimpleDTO(e entity.ProjectChickin) ChickinSimpleDTO { return ChickinSimpleDTO{ - Id: e.Id, - ProjectFlocKandangId: e.ProjectFlocKandangId, - ChickInDate: e.ChickInDate, - Quantity: e.Quantity, - Note: e.Note, - CreatedBy: e.CreatedBy, + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + ChickInDate: e.ChickInDate, + Quantity: e.Quantity, + Note: e.Note, + CreatedBy: e.CreatedBy, } } diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index abfc56ca..116e2fbb 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -26,12 +26,13 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * auditlogrepo := rAuditLog.NewAuditLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db) + projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, auditlogrepo, projectflockkandangrepo, validate) + chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, auditlogrepo, projectflockkandangrepo, projectflockpopulationrepo, validate) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index fbb692fa..a11b21f7 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -31,28 +31,30 @@ type ChickinService interface { } type chickinService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectChickinRepository - KandangRepo KandangRepo.KandangRepository - WarehouseRepo rWarehouse.WarehouseRepository - ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository - ProjectFlockRepo rProjectFlock.ProjectflockRepository - AuditLogRepo AuditLogRepo.AuditLogRepository - ProjectflockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectChickinRepository + KandangRepo KandangRepo.KandangRepository + WarehouseRepo rWarehouse.WarehouseRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockRepo rProjectFlock.ProjectflockRepository + AuditLogRepo AuditLogRepo.AuditLogRepository + ProjectflockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository + ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, auditLogRepo AuditLogRepo.AuditLogRepository, projectflockkandangRepo rProjectFlockKandang.ProjectFlockKandangRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, auditLogRepo AuditLogRepo.AuditLogRepository, projectflockkandangRepo rProjectFlockKandang.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, validate *validator.Validate) ChickinService { return &chickinService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - KandangRepo: kandangRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProjectFlockRepo: projectFlockRepo, - AuditLogRepo: auditLogRepo, - ProjectflockKandangRepo: projectflockkandangRepo, + Log: utils.Log, + Validate: validate, + Repository: repo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockRepo: projectFlockRepo, + AuditLogRepo: auditLogRepo, + ProjectflockKandangRepo: projectflockkandangRepo, + ProjectflockPopulationRepo: projectflockpopulationRepo, } } @@ -78,8 +80,9 @@ func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity chickins, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + + if params.ProjectFlockKandangId != 0 { + return db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -163,11 +166,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } newChickin := &entity.ProjectChickin{ - ProjectFlocKandangId: projectflockkandang.ProjectFlockId, - ChickInDate: chickinDate, - Quantity: productWarehouse.Quantity, - Note: "", - CreatedBy: 1, //todo: ganti dengan + ProjectFlockKandangId: projectflockkandang.ProjectFlockId, + ChickInDate: chickinDate, + Quantity: productWarehouse.Quantity, + Note: "", + CreatedBy: 1, //todo: ganti dengan } err = s.Repository.CreateOne(c.Context(), newChickin, nil) @@ -188,45 +191,37 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) return nil, err } - - // masukan check apakah stock availability ada, jika ada update, jika tidak buat baru - stockAvailability := &entity.StockAvailability{ - EntityType: entity.EntityTypeProjectFlockKandang, - ReservedQuantity: productWarehouse.Quantity, - EntityId: projectflockkandang.Id, - ProductId: productWarehouse.ProductId, + // masukan data nya ke project flock population + // check apakah sudah ada + existingPopulation, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get project flock population: %+v", err) + return nil, err } + if existingPopulation != nil { + // update quantity - var existingStockAvailability entity.StockAvailability - err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). - Where("entity_type = ? AND entity_id = ? AND product_id = ?", stockAvailability.EntityType, stockAvailability.EntityId, stockAvailability.ProductId). - First(&existingStockAvailability).Error - - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // buat baru - stockAvailability.ReservedQuantity = newChickin.Quantity - stockAvailability.Quantity = 0 - err = s.ProductWarehouseRepo.DB().WithContext(c.Context()).Create(stockAvailability).Error - if err != nil { - s.Log.Errorf("Failed to create stock availability: %+v", err) - return nil, err - } - } else { - s.Log.Errorf("Failed to get stock availability: %+v", err) + err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), existingPopulation.Id, map[string]any{ + "reserved_quantity": newChickin.Quantity + existingPopulation.ReservedQuantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update project flock population: %+v", err) return nil, err } } else { - // update existing - newQuantity := existingStockAvailability.ReservedQuantity + newChickin.Quantity - err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). - Model(&existingStockAvailability). - Update("reserved_quantity", newQuantity).Error + // create new population + newPopulation := &entity.ProjectFlockPopulation{ + ProjectFlockKandangId: req.ProjectFlockKandangId, + InitialQuantity: 0, + CurrentQuantity: 0, + ReservedQuantity: newChickin.Quantity, + CreatedBy: 1, // todo: ganti dengan user login + } + err = s.ProjectflockPopulationRepo.CreateOne(c.Context(), newPopulation, nil) if err != nil { - s.Log.Errorf("Failed to update stock availability: %+v", err) + s.Log.Errorf("Failed to create project flock population: %+v", err) return nil, err } - } return s.GetOne(c, newChickin.Id) @@ -283,20 +278,21 @@ func (s *chickinService) Approve(c *fiber.Ctx, id uint) error { return err } - //pindahkan stock dari reserved ke actual stock - // get stock avaibility untuk di update - var stockAvailability entity.StockAvailability - err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). - Where("entity_type = ? AND entity_id = ? ", entity.EntityTypeProjectFlockKandang, chickin.ProjectFlocKandangId). - First(&stockAvailability).Error + //pindahkan stock dari reserved ke actual stock pada table project flock population + population, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), chickin.ProjectFlockKandangId) if err != nil { - s.Log.Errorf("Failed to get stock availability: %+v", err) + s.Log.Errorf("Failed to get project flock population: %+v", err) return err } - newReservedQuantity := stockAvailability.ReservedQuantity - chickin.Quantity - if newReservedQuantity < 0 { - newReservedQuantity = 0 + err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), population.Id, map[string]any{ + "reserved_quantity": population.ReservedQuantity - chickin.Quantity, + "initial_quantity": population.InitialQuantity + chickin.Quantity, + "current_quantity": population.CurrentQuantity + chickin.Quantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update project flock population: %+v", err) + return err } return nil diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index b57950b0..c122c100 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -10,7 +10,7 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` } diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 5f1afbe3..5b91ab13 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -8,6 +8,7 @@ import ( rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go new file mode 100644 index 00000000..cb4b0d5f --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectFlockPopulationRepository interface { + repository.BaseRepository[entity.ProjectFlockPopulation] + GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*entity.ProjectFlockPopulation, error) +} + +type projectFlockPopulationRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlockPopulation] +} + +func NewProjectFlockPopulationRepository(db *gorm.DB) ProjectFlockPopulationRepository { + return &projectFlockPopulationRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockPopulation](db), + } +} + +func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*entity.ProjectFlockPopulation, error) { + var record entity.ProjectFlockPopulation + err := r.DB().WithContext(ctx). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} From 748c959dbe53263998054b441010ff616d18a923 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 20 Oct 2025 11:36:38 +0700 Subject: [PATCH 18/26] FIX[BE]: fix json wrong json field name --- internal/modules/inventory/transfers/dto/transfer.dto.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 82269852..cb85af94 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -38,7 +38,7 @@ type LocationDTO struct { Name string `json:"name"` } -type SuplierSimpleDTO struct { +type SupplierSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` } @@ -75,7 +75,7 @@ type TransferDetailItemDTO struct { // Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` - Suplier SuplierSimpleDTO `json:"suplier"` + Supplier SupplierSimpleDTO `json:"supplier"` VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` @@ -176,7 +176,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { } deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, - Suplier: SuplierSimpleDTO{ + Supplier: SupplierSimpleDTO{ Id: del.Supplier.Id, Name: del.Supplier.Name, }, @@ -225,7 +225,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { for _, del := range e.Deliveries { deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, - Suplier: SuplierSimpleDTO{ + Supplier: SupplierSimpleDTO{ Id: del.Supplier.Id, Name: del.Supplier.Name, }, From a1f579f61651d2e92df7322be1e40a29d488a647 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 20 Oct 2025 12:55:19 +0700 Subject: [PATCH 19/26] feat(BE-119,135): add seeding and API documentation - Implement project data seeding logic - Add API documentation using Hoppscotch --- internal/database/seed/seeder.go | 78 +++++++++++++- .../chickins/services/chickin.service.go | 101 ++++++++++++++---- 2 files changed, 156 insertions(+), 23 deletions(-) diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index afa2a308..aa70b084 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -92,6 +92,9 @@ func Run(db *gorm.DB) error { if err := seedTransferStock(tx, adminID); err != nil { return err } + if err := seedChickin(tx, adminID); err != nil { + return err + } fmt.Println("✅ Master data seeding completed") return nil @@ -981,6 +984,7 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { {ProductID: 1, WarehouseID: 1, Quantity: 100}, {ProductID: 2, WarehouseID: 2, Quantity: 200}, {ProductID: 2, WarehouseID: 1, Quantity: 300}, + {ProductID: 1, WarehouseID: 3, Quantity: 5000}, } for _, seed := range seeds { @@ -1005,8 +1009,7 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { } func seedTransferStock(tx *gorm.DB, createdBy uint) error { - // Seeder Transfer Stock - // 1. Insert StockTransfer (header) + transfer := entity.StockTransfer{ FromWarehouseId: 1, ToWarehouseId: 2, @@ -1019,7 +1022,6 @@ func seedTransferStock(tx *gorm.DB, createdBy uint) error { return err } - // 2. Insert StockTransferDetail (detail) details := []entity.StockTransferDetail{ { StockTransferId: transfer.Id, @@ -1038,7 +1040,6 @@ func seedTransferStock(tx *gorm.DB, createdBy uint) error { } } - // 3. Insert StockTransferDelivery (delivery) deliveries := []entity.StockTransferDelivery{ { StockTransferId: transfer.Id, @@ -1082,6 +1083,75 @@ func seedTransferStock(tx *gorm.DB, createdBy uint) error { return nil } +func seedChickin(tx *gorm.DB, createdBy uint) error { + seeds := []struct { + ProjectFlockKandangId uint + ChickInDate string + Quantity float64 + Note string + }{ + {ProjectFlockKandangId: 1, ChickInDate: "2025-10-20", Quantity: 100, Note: "Seeder chickin 1"}, + {ProjectFlockKandangId: 2, ChickInDate: "2025-10-21", Quantity: 200, Note: "Seeder chickin 2"}, + } + + for _, seed := range seeds { + chickinDate, err := time.Parse("2006-01-02", seed.ChickInDate) + if err != nil { + return err + } + + // Insert ProjectChickin jika belum ada + var chickin entity.ProjectChickin + err = tx.Where("project_flock_kandang_id = ? AND chick_in_date = ?", seed.ProjectFlockKandangId, chickinDate). + First(&chickin).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + chickin = entity.ProjectChickin{ + ProjectFlockKandangId: seed.ProjectFlockKandangId, + ChickInDate: chickinDate, + Quantity: seed.Quantity, + Note: seed.Note, + CreatedBy: createdBy, + } + if err := tx.Create(&chickin).Error; err != nil { + return err + } + } else if err != nil { + return err + } + + // Update/Insert ProjectFlockPopulation + var population entity.ProjectFlockPopulation + err = tx.Where("project_flock_kandang_id = ?", seed.ProjectFlockKandangId).First(&population).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + population = entity.ProjectFlockPopulation{ + ProjectFlockKandangId: seed.ProjectFlockKandangId, + InitialQuantity: seed.Quantity, + CurrentQuantity: seed.Quantity, + ReservedQuantity: 0, + CreatedBy: createdBy, + } + if err := tx.Create(&population).Error; err != nil { + return err + } + } else if err != nil { + return err + } else { + // Update population quantities + if err := tx.Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", population.Id). + Updates(map[string]any{ + "initial_quantity": population.InitialQuantity + seed.Quantity, + "current_quantity": population.CurrentQuantity + seed.Quantity, + "reserved_quantity": 0, + }).Error; err != nil { + return err + } + } + } + + return nil +} + func ptr[T any](v T) *T { return &v } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index a11b21f7..f866e96d 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -77,16 +77,13 @@ func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity } offset := (params.Page - 1) * params.Limit - chickins, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if params.ProjectFlockKandangId != 0 { return db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) } return db.Order("created_at DESC").Order("updated_at DESC") }) - if err != nil { s.Log.Errorf("Failed to get chickins: %+v", err) return nil, 0, err @@ -95,6 +92,7 @@ func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity } func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, error) { + chickin, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") @@ -111,20 +109,18 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - // ambil salah satu kandang dari project_floc_id dari kandang repository projectflockkandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), 1) if err != nil { s.Log.Errorf("Failed to get projectflock kandang: %+v", err) return nil, err } - // ambil warehouse dari kandangid + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectflockkandang.KandangId) if err != nil { s.Log.Errorf("Failed to get warehouse: %+v", err) return nil, err } - // getprojectflock id with relation projectFlock, err := s.ProjectFlockRepo.GetByID( c.Context(), projectflockkandang.ProjectFlockId, @@ -132,20 +128,19 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return db.Preload("ProductCategory") }, ) - if err != nil { s.Log.Errorf("Failed to get project flock: %+v", err) return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") } - // ambil quantity + var productWarehouse entity.ProductWarehouse - err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). + err = s.ProductWarehouseRepo.DB(). + WithContext(c.Context()). Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.ProductCategory.Code, warehouse.Id). Order("created_at DESC"). First(&productWarehouse).Error - if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") @@ -158,13 +153,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient product quantity in warehouse") } - // masukan ke chic in chickinDate, err := utils.ParseDateString(req.ChickInDate) if err != nil { s.Log.Errorf("Failed to parse chickin date: %+v", err) return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid ChickInDate format") } - newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: projectflockkandang.ProjectFlockId, ChickInDate: chickinDate, @@ -172,14 +165,12 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit Note: "", CreatedBy: 1, //todo: ganti dengan } - err = s.Repository.CreateOne(c.Context(), newChickin, nil) if err != nil { s.Log.Errorf("Failed to create chickin: %+v", err) return nil, err } - // Kurangi quantity di product warehouse updatedQuantity := productWarehouse.Quantity - newChickin.Quantity if updatedQuantity < 0 { updatedQuantity = 0 @@ -191,15 +182,13 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) return nil, err } - // masukan data nya ke project flock population - // check apakah sudah ada + existingPopulation, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to get project flock population: %+v", err) return nil, err } if existingPopulation != nil { - // update quantity err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), existingPopulation.Id, map[string]any{ "reserved_quantity": newChickin.Quantity + existingPopulation.ReservedQuantity, @@ -209,7 +198,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } } else { - // create new population newPopulation := &entity.ProjectFlockPopulation{ ProjectFlockKandangId: req.ProjectFlockKandangId, InitialQuantity: 0, @@ -253,6 +241,31 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + // todo: cek apakah chickin sudah di approve atau belum + + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + if err != nil { + s.Log.Errorf("Failed get chickin by id: %+v", err) + return err + } + + population, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), chickin.ProjectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to get project flock population: %+v", err) + return err + } + + err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), population.Id, map[string]any{ + "reserved_quantity": population.ReservedQuantity - chickin.Quantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update project flock population: %+v", err) + return err + } + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") @@ -260,11 +273,62 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to delete chickin: %+v", err) return err } + + projectflockkandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), population.ProjectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to get projectflock kandang: %+v", err) + return err + } + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectflockkandang.KandangId) + if err != nil { + s.Log.Errorf("Failed to get warehouse: %+v", err) + return err + } + + projectFlock, err := s.ProjectFlockRepo.GetByID( + c.Context(), + projectflockkandang.ProjectFlockId, + func(db *gorm.DB) *gorm.DB { + return db.Preload("ProductCategory") + }, + ) + + if err != nil { + s.Log.Errorf("Failed to get project flock: %+v", err) + return fiber.NewError(fiber.StatusNotFound, "Project Flock not found") + } + var productWarehouse entity.ProductWarehouse + err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.ProductCategory.Code, warehouse.Id). + Order("created_at DESC"). + First(&productWarehouse).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") + } + s.Log.Errorf("Failed to get product warehouse: %+v", err) + return err + } + + updatedQuantity := productWarehouse.Quantity + chickin.Quantity + err = s.ProductWarehouseRepo.PatchOne(c.Context(), productWarehouse.Id, map[string]any{ + "quantity": updatedQuantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) + return err + } + return nil } func (s *chickinService) Approve(c *fiber.Ctx, id uint) error { + // todo: ini contoh akhir jika sudah approved + chickin, err := s.Repository.GetByID( c.Context(), id, @@ -278,7 +342,6 @@ func (s *chickinService) Approve(c *fiber.Ctx, id uint) error { return err } - //pindahkan stock dari reserved ke actual stock pada table project flock population population, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), chickin.ProjectFlockKandangId) if err != nil { s.Log.Errorf("Failed to get project flock population: %+v", err) From ee033b8fe6a94ff303292995d378daead4006755 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 20 Oct 2025 16:39:16 +0700 Subject: [PATCH 20/26] FIX[BE]: name duplicate flock,projectflock category change,menerapkan dto seperti warehouse di projectflock --- internal/database/seed/seeder.go | 80 ++++----- internal/entities/projectfloc.go | 37 ++-- .../flocks/repositories/flock.repository.go | 11 +- .../master/flocks/services/flock.service.go | 33 +++- .../project_flocks/dto/projectflock.dto.go | 166 ++++++------------ .../services/projectflock.service.go | 44 ++--- .../validations/projectflock.validation.go | 42 ++--- internal/utils/constant.go | 40 ++++- test/integration/master_data/kandang_test.go | 16 +- .../master_data/project_flock_test.go | 106 ++++++----- 10 files changed, 290 insertions(+), 285 deletions(-) diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index afa2a308..e6f77b15 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -50,7 +50,7 @@ func Run(db *gorm.DB) error { return err } - projectFlocks, err := seedProjectFlocks(tx, adminID, flocks, areas, productCategories, fcrs, locations) + projectFlocks, err := seedProjectFlocks(tx, adminID, flocks, areas, fcrs, locations) if err != nil { return err } @@ -239,33 +239,33 @@ func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCategories, fcrs, locations map[string]uint) (map[string]uint, error) { +func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, fcrs, locations map[string]uint) (map[string]uint, error) { seeds := []struct { - Key string - Flock string - Area string - ProductCategory string - Fcr string - Location string - Period int + Key string + Flock string + Area string + Category utils.ProjectFlockCategory + Fcr string + Location string + Period int }{ { - Key: "Singaparna Period 1", - Flock: "Flock Priangan", - Area: "Priangan", - ProductCategory: "Day Old Chick", - Fcr: "FCR Layer", - Location: "Singaparna", - Period: 1, + Key: "Singaparna Period 1", + Flock: "Flock Priangan", + Area: "Priangan", + Category: utils.ProjectFlockCategoryGrowing, + Fcr: "FCR Layer", + Location: "Singaparna", + Period: 1, }, { - Key: "Cikaum Period 1", - Flock: "Flock Banten", - Area: "Banten", - ProductCategory: "Day Old Chick", - Fcr: "FCR Layer", - Location: "Cikaum", - Period: 1, + Key: "Cikaum Period 1", + Flock: "Flock Banten", + Area: "Banten", + Category: utils.ProjectFlockCategoryGrowing, + Fcr: "FCR Layer", + Location: "Cikaum", + Period: 1, }, } @@ -280,10 +280,6 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCatego if !ok { return nil, fmt.Errorf("area %s not seeded", seed.Area) } - categoryID, ok := productCategories[seed.ProductCategory] - if !ok { - return nil, fmt.Errorf("product category %s not seeded", seed.ProductCategory) - } fcrID, ok := fcrs[seed.Fcr] if !ok { return nil, fmt.Errorf("fcr %s not seeded", seed.Fcr) @@ -294,17 +290,17 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCatego } var projectFlock entity.ProjectFlock - err := tx.Where("flock_id = ? AND area_id = ? AND product_category_id = ? AND fcr_id = ? AND location_id = ? AND period = ?", - flockID, areaID, categoryID, fcrID, locationID, seed.Period).First(&projectFlock).Error + err := tx.Where("flock_id = ? AND area_id = ? AND category = ? AND fcr_id = ? AND location_id = ? AND period = ?", + flockID, areaID, seed.Category, fcrID, locationID, seed.Period).First(&projectFlock).Error if errors.Is(err, gorm.ErrRecordNotFound) { projectFlock = entity.ProjectFlock{ - FlockId: flockID, - AreaId: areaID, - ProductCategoryId: categoryID, - FcrId: fcrID, - LocationId: locationID, - Period: seed.Period, - CreatedBy: createdBy, + FlockId: flockID, + AreaId: areaID, + Category: string(seed.Category), + FcrId: fcrID, + LocationId: locationID, + Period: seed.Period, + CreatedBy: createdBy, } if err := tx.Create(&projectFlock).Error; err != nil { return nil, err @@ -313,12 +309,12 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCatego return nil, err } else { if err := tx.Model(&entity.ProjectFlock{}).Where("id = ?", projectFlock.Id).Updates(map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationID, - "period": seed.Period, + "flock_id": flockID, + "area_id": areaID, + "category": string(seed.Category), + "fcr_id": fcrID, + "location_id": locationID, + "period": seed.Period, }).Error; err != nil { return nil, err } diff --git a/internal/entities/projectfloc.go b/internal/entities/projectfloc.go index 2d581e84..47362d42 100644 --- a/internal/entities/projectfloc.go +++ b/internal/entities/projectfloc.go @@ -7,23 +7,22 @@ import ( ) type ProjectFlock struct { - Id uint `gorm:"primaryKey"` - FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"` - AreaId uint `gorm:"not null"` - ProductCategoryId uint `gorm:"not null"` - FcrId uint `gorm:"not null"` - LocationId uint `gorm:"not null"` - Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Flock Flock `gorm:"foreignKey:FlockId;references:Id"` - Area Area `gorm:"foreignKey:AreaId;references:Id"` - ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` - Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` - KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + Id uint `gorm:"primaryKey"` + FlockId uint `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + AreaId uint `gorm:"not null"` + Category string `gorm:"type:varchar(20);not null"` + FcrId uint `gorm:"not null"` + LocationId uint `gorm:"not null"` + Period int `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Flock Flock `gorm:"foreignKey:FlockId;references:Id"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` + Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` } diff --git a/internal/modules/master/flocks/repositories/flock.repository.go b/internal/modules/master/flocks/repositories/flock.repository.go index 12f269fc..006fe541 100644 --- a/internal/modules/master/flocks/repositories/flock.repository.go +++ b/internal/modules/master/flocks/repositories/flock.repository.go @@ -1,21 +1,30 @@ package repository import ( - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) type FlockRepository interface { repository.BaseRepository[entity.Flock] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) } type FlockRepositoryImpl struct { *repository.BaseRepositoryImpl[entity.Flock] + db *gorm.DB } func NewFlockRepository(db *gorm.DB) FlockRepository { return &FlockRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.Flock](db), + db: db, } } + +func (r *FlockRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Flock](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/flocks/services/flock.service.go b/internal/modules/master/flocks/services/flock.service.go index 4c3c9b26..ad086920 100644 --- a/internal/modules/master/flocks/services/flock.service.go +++ b/internal/modules/master/flocks/services/flock.service.go @@ -2,6 +2,8 @@ package service import ( "errors" + "fmt" + "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" @@ -79,8 +81,22 @@ func (s *flockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity. return nil, err } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Name is required") + } + + exists, err := s.Repository.NameExists(c.Context(), name, nil) + if err != nil { + s.Log.Errorf("Failed to check flock name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check flock name") + } + if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Flock with name %s already exists", name)) + } + createBody := &entity.Flock{ - Name: req.Name, + Name: name, CreatedBy: 1, } @@ -100,7 +116,20 @@ func (s flockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) ( updateBody := make(map[string]any) if req.Name != nil { - updateBody["name"] = *req.Name + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Name cannot be empty") + } + + exists, err := s.Repository.NameExists(c.Context(), name, &id) + if err != nil { + s.Log.Errorf("Failed to check flock name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check flock name") + } + if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Flock with name %s already exists", name)) + } + updateBody["name"] = name } if len(updateBody) == 0 { diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index a42caebf..fcf3d50c 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -4,75 +4,66 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" + flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) type ProjectFlockBaseDTO struct { - Id uint `json:"id"` - // FlockId uint `json:"flock_id"` - // AreaId uint `json:"area_id"` - // ProductCategoryId uint `json:"product_category_id"` - // FcrId uint `json:"fcr_id"` - // LocationId uint `json:"location_id"` - Period int `json:"period"` + Id uint `json:"id"` + Period int `json:"period"` + Category string `json:"category"` + Flock *flockDTO.FlockBaseDTO `json:"flock"` + Area *areaDTO.AreaBaseDTO `json:"area"` + Fcr *fcrDTO.FcrBaseDTO `json:"fcr"` + Location *locationDTO.LocationBaseDTO `json:"location"` } func ToProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { - return ProjectFlockBaseDTO{ - Id: e.Id, - // FlockId: e.FlockId, - // AreaId: e.AreaId, - // ProductCategoryId: e.ProductCategoryId, - // FcrId: e.FcrId, - // LocationId: e.LocationId, - Period: e.Period, + var flock *flockDTO.FlockBaseDTO + if e.Flock.Id != 0 { + mapped := flockDTO.ToFlockBaseDTO(e.Flock) + flock = &mapped } -} -type FlockSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } -type AreaSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} + var fcr *fcrDTO.FcrBaseDTO + if e.Fcr.Id != 0 { + mapped := fcrDTO.ToFcrBaseDTO(e.Fcr) + fcr = &mapped + } -type ProductCategorySummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Code string `json:"code"` -} + var location *locationDTO.LocationBaseDTO + if e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Location) + location = &mapped + } -type FcrSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Address string `json:"address"` -} - -type KandangSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Status string `json:"status"` + return ProjectFlockBaseDTO{ + Id: e.Id, + Period: e.Period, + Category: e.Category, + Flock: flock, + Area: area, + Fcr: fcr, + Location: location, + } } type ProjectFlockListDTO struct { ProjectFlockBaseDTO - Flock *FlockSummaryDTO `json:"flock,omitempty"` - Area *AreaSummaryDTO `json:"area,omitempty"` - ProductCategory *ProductCategorySummaryDTO `json:"product_category,omitempty"` - Fcr *FcrSummaryDTO `json:"fcr,omitempty"` - Location *LocationSummaryDTO `json:"location,omitempty"` - Kandangs []KandangSummaryDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Kandangs []kandangDTO.KandangBaseDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ProjectFlockDetailDTO struct { @@ -80,8 +71,8 @@ type ProjectFlockDetailDTO struct { } type FlockPeriodSummaryDTO struct { - Flock FlockSummaryDTO `json:"flock"` - NextPeriod int `json:"next_period"` + Flock flockDTO.FlockBaseDTO `json:"flock"` + NextPeriod int `json:"next_period"` } func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { @@ -91,62 +82,16 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { createdUser = &mapped } - var flockSummary *FlockSummaryDTO - if e.Flock.Id != 0 { - summary := ToFlockSummaryDTO(e.Flock) - flockSummary = &summary - } - - var areaSummary *AreaSummaryDTO - if e.Area.Id != 0 { - areaSummary = &AreaSummaryDTO{ - Id: e.Area.Id, - Name: e.Area.Name, - } - } - - var categorySummary *ProductCategorySummaryDTO - if e.ProductCategory.Id != 0 { - categorySummary = &ProductCategorySummaryDTO{ - Id: e.ProductCategory.Id, - Name: e.ProductCategory.Name, - Code: e.ProductCategory.Code, - } - } - - var fcrSummary *FcrSummaryDTO - if e.Fcr.Id != 0 { - fcrSummary = &FcrSummaryDTO{ - Id: e.Fcr.Id, - Name: e.Fcr.Name, - } - } - - var locationSummary *LocationSummaryDTO - if e.Location.Id != 0 { - locationSummary = &LocationSummaryDTO{ - Id: e.Location.Id, - Name: e.Location.Name, - Address: e.Location.Address, - } - } - - kandangSummaries := make([]KandangSummaryDTO, len(e.Kandangs)) - for i, kandang := range e.Kandangs { - kandangSummaries[i] = KandangSummaryDTO{ - Id: kandang.Id, - Name: kandang.Name, - Status: kandang.Status, + var kandangSummaries []kandangDTO.KandangBaseDTO + if len(e.Kandangs) > 0 { + kandangSummaries = make([]kandangDTO.KandangBaseDTO, len(e.Kandangs)) + for i, kandang := range e.Kandangs { + kandangSummaries[i] = kandangDTO.ToKandangBaseDTO(kandang) } } return ProjectFlockListDTO{ ProjectFlockBaseDTO: ToProjectFlockBaseDTO(e), - Flock: flockSummary, - Area: areaSummary, - ProductCategory: categorySummary, - Fcr: fcrSummary, - Location: locationSummary, Kandangs: kandangSummaries, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, @@ -168,16 +113,9 @@ func ToProjectFlockDetailDTO(e entity.ProjectFlock) ProjectFlockDetailDTO { } } -func ToFlockSummaryDTO(e entity.Flock) FlockSummaryDTO { - return FlockSummaryDTO{ - Id: e.Id, - Name: e.Name, - } -} - func ToFlockPeriodSummaryDTO(flock entity.Flock, next int) FlockPeriodSummaryDTO { return FlockPeriodSummaryDTO{ - Flock: ToFlockSummaryDTO(flock), + Flock: flockDTO.ToFlockBaseDTO(flock), NextPeriod: next, } } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 8af6e452..18f00b7d 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -67,7 +67,6 @@ func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser"). Preload("Flock"). Preload("Area"). - Preload("ProductCategory"). Preload("Fcr"). Preload("Location"). Preload("Kandangs") @@ -115,15 +114,13 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e db = db. Joins("LEFT JOIN flocks ON flocks.id = project_flocks.flock_id"). Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). - Joins("LEFT JOIN product_categories ON product_categories.id = project_flocks.product_category_id"). Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). Where(` LOWER(flocks.name) LIKE ? OR LOWER(areas.name) LIKE ? - OR LOWER(product_categories.name) LIKE ? - OR LOWER(product_categories.code) LIKE ? + OR LOWER(project_flocks.category) LIKE ? OR LOWER(fcrs.name) LIKE ? OR LOWER(locations.name) LIKE ? OR LOWER(locations.address) LIKE ? @@ -146,7 +143,6 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e likeQuery, likeQuery, likeQuery, - likeQuery, ) } for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { @@ -179,6 +175,11 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } + category, ok := utils.NormalizeProjectFlockCategory(req.Category) + if !ok { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") + } + if len(req.KandangIds) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") } @@ -186,7 +187,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, common.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, - common.RelationCheck{Name: "Product category", ID: &req.ProductCategoryId, Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB())}, common.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, ); err != nil { @@ -224,13 +224,13 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* } createBody := &entity.ProjectFlock{ - FlockId: req.FlockId, - AreaId: req.AreaId, - ProductCategoryId: req.ProductCategoryId, - FcrId: req.FcrId, - LocationId: req.LocationId, - Period: nextPeriod, - CreatedBy: 1, + FlockId: req.FlockId, + AreaId: req.AreaId, + Category: string(category), + FcrId: req.FcrId, + LocationId: req.LocationId, + Period: nextPeriod, + CreatedBy: 1, } if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { @@ -289,13 +289,12 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id Exists: relationExistsChecker[entity.Area](s.Repository.DB()), }) } - if req.ProductCategoryId != nil { - updateBody["product_category_id"] = *req.ProductCategoryId - relationChecks = append(relationChecks, common.RelationCheck{ - Name: "Product category", - ID: req.ProductCategoryId, - Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB()), - }) + if req.Category != nil { + if normalized, ok := utils.NormalizeProjectFlockCategory(*req.Category); ok { + updateBody["category"] = string(normalized) + } else { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") + } } if req.FcrId != nil { updateBody["fcr_id"] = *req.FcrId @@ -541,7 +540,10 @@ func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, pr if err := tx.Model(&entity.Kandang{}). Where("id IN ?", kandangIDs). - Updates(map[string]any{"project_flock_id": projectFlockID}).Error; err != nil { + Updates(map[string]any{ + "project_flock_id": projectFlockID, + "status": string(utils.KandangStatusPengajuan), + }).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 0d8d3a80..bbe957b6 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,32 +1,32 @@ package validation type Create struct { - FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` - AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` - ProductCategoryId uint `json:"product_category_id" validate:"required_strict,number,gt=0"` - FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + Category string `json:"category" validate:"required_strict,oneof=growing laying GROWING LAYING"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` } type Update struct { - FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` - AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` - ProductCategoryId *uint `json:"product_category_id,omitempty" validate:"omitempty,number,gt=0"` - FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - Period *int `json:"period,omitempty" validate:"omitempty,number,gt=0"` - KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` + FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` + AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` + Category *string `json:"category,omitempty" validate:"omitempty,oneof=growing laying GROWING LAYING"` + FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + Period *int `json:"period,omitempty" validate:"omitempty,number,gt=0"` + KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=area location kandangs period"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` - AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` - LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` - Period int `query:"period" validate:"omitempty,number,gt=0"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=area location kandangs period"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` + LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` + Period int `query:"period" validate:"omitempty,number,gt=0"` KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index dbc06660..d780d2ae 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -59,7 +59,6 @@ var allFlagTypes = func() map[FlagType]struct{} { return m }() - func AllFlagTypes() map[FlagType]struct{} { return allFlagTypes } @@ -76,8 +75,6 @@ const ( WarehouseTypeKandang WarehouseType = "KANDANG" ) - - // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- @@ -100,19 +97,29 @@ const ( SupplierCategorySapronak SupplierCategory = "SAPRONAK" ) - - // ------------------------------------------------------------------- -// Kandang Status +// Kandang Status // ------------------------------------------------------------------- type KandangStatus string const ( - KandangStatusNonActive KandangStatus = "NON_ACTIVE" - KandangStatusPengajuan KandangStatus = "PENGAJUAN" - KandangStatusActive KandangStatus = "ACTIVE" + KandangStatusNonActive KandangStatus = "NON_ACTIVE" + KandangStatusPengajuan KandangStatus = "PENGAJUAN" + KandangStatusActive KandangStatus = "ACTIVE" ) + +// ------------------------------------------------------------------- +// ProjectFlockCategory +// ------------------------------------------------------------------- + +type ProjectFlockCategory string + +const ( + ProjectFlockCategoryGrowing ProjectFlockCategory = "GROWING" + ProjectFlockCategoryLaying ProjectFlockCategory = "LAYING" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -223,6 +230,21 @@ func IsValidCustomerSupplierType(v string) bool { return false } +func NormalizeProjectFlockCategory(v string) (ProjectFlockCategory, bool) { + normalized := ProjectFlockCategory(strings.ToUpper(strings.TrimSpace(v))) + switch normalized { + case ProjectFlockCategoryGrowing, ProjectFlockCategoryLaying: + return normalized, true + default: + return "", false + } +} + +func IsValidProjectFlockCategory(v string) bool { + _, ok := NormalizeProjectFlockCategory(v) + return ok +} + func IsValidSupplierCategory(v string) bool { switch SupplierCategory(v) { case SupplierCategoryBOP, SupplierCategorySapronak: diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go index 580196d4..6f7c5ce7 100644 --- a/test/integration/master_data/kandang_test.go +++ b/test/integration/master_data/kandang_test.go @@ -8,6 +8,7 @@ import ( "github.com/gofiber/fiber/v2" "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) func TestKandangIntegration(t *testing.T) { @@ -51,20 +52,19 @@ func TestKandangIntegration(t *testing.T) { }) t.Run("cannot assign project floc with existing active kandang", func(t *testing.T) { - categoryID := createProductCategory(t, app, "DOC Category", "DOC1") fcrID := createFcr(t, app, "FCR For Floc", []map[string]any{ {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, }) flocID := createFlock(t, app, "Floc Test") projectFloc := entities.ProjectFlock{ - FlockId: flocID, - AreaId: areaID, - ProductCategoryId: categoryID, - FcrId: fcrID, - LocationId: locationID, - Period: 1, - CreatedBy: 1, + FlockId: flocID, + AreaId: areaID, + Category: string(utils.ProjectFlockCategoryGrowing), + FcrId: fcrID, + LocationId: locationID, + Period: 1, + CreatedBy: 1, } if err := db.Create(&projectFloc).Error; err != nil { t.Fatalf("failed to seed project floc: %v", err) diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index c5e0442c..fbeb804c 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -19,19 +19,18 @@ func TestProjectFlockSummary(t *testing.T) { areaID := createArea(t, app, "Area Project") locationID := createLocation(t, app, "Location Project", "Address", areaID) flockID := createFlock(t, app, "Flock Summary") - categoryID := createProductCategory(t, app, "DOC Summary", "DOCS") fcrID := createFcr(t, app, "FCR Summary", []map[string]any{ {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, }) kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) createPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{kandangID}, + "flock_id": flockID, + "area_id": areaID, + "category": "growing", + "fcr_id": fcrID, + "location_id": locationID, + "kandang_ids": []uint{kandangID}, } resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) if resp.StatusCode != fiber.StatusCreated { @@ -40,9 +39,10 @@ func TestProjectFlockSummary(t *testing.T) { var createResp struct { Data struct { - Id uint `json:"id"` - Period int `json:"period"` - Flock struct { + Id uint `json:"id"` + Period int `json:"period"` + Category string `json:"category"` + Flock struct { Id uint `json:"id"` Name string `json:"name"` } `json:"flock"` @@ -50,11 +50,6 @@ func TestProjectFlockSummary(t *testing.T) { Id uint `json:"id"` Name string `json:"name"` } `json:"area"` - ProductCategory struct { - Id uint `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - } `json:"product_category"` Fcr struct { Id uint `json:"id"` Name string `json:"name"` @@ -86,19 +81,27 @@ func TestProjectFlockSummary(t *testing.T) { if createResp.Data.Area.Id != areaID || createResp.Data.Area.Name == "" { t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area) } + if createResp.Data.Category != string(utils.ProjectFlockCategoryGrowing) { + t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryGrowing, createResp.Data.Category) + } if createResp.Data.Location.Id != locationID || createResp.Data.Location.Name == "" { t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location) } if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) } - if createResp.Data.Kandangs[0].Status == "" { - t.Fatalf("expected kandang status to be present, got %+v", createResp.Data.Kandangs[0]) + if createResp.Data.Kandangs[0].Status != string(utils.KandangStatusPengajuan) { + t.Fatalf("expected kandang status to be PENGAJUAN, got %s", createResp.Data.Kandangs[0].Status) } if createResp.Data.Period != 1 { t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) } + createdKandang := fetchKandang(t, db, kandangID) + if createdKandang.Status != string(utils.KandangStatusPengajuan) { + t.Fatalf("expected kandang status in DB to be PENGAJUAN, got %s", createdKandang.Status) + } + var pivotRecords []entities.ProjectFlockKandang if err := db.Where("project_flock_id = ?", createResp.Data.Id).Find(&pivotRecords).Error; err != nil { t.Fatalf("failed to fetch pivot records: %v", err) @@ -116,12 +119,12 @@ func TestProjectFlockSummary(t *testing.T) { secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) secondPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{secondKandangID}, + "flock_id": flockID, + "area_id": areaID, + "category": "laying", + "fcr_id": fcrID, + "location_id": locationID, + "kandang_ids": []uint{secondKandangID}, } resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", secondPayload) if resp.StatusCode != fiber.StatusCreated { @@ -129,8 +132,9 @@ func TestProjectFlockSummary(t *testing.T) { } var createRespSecond struct { Data struct { - Id uint `json:"id"` - Period int `json:"period"` + Id uint `json:"id"` + Period int `json:"period"` + Category string `json:"category"` } `json:"data"` } if err := json.Unmarshal(body, &createRespSecond); err != nil { @@ -139,6 +143,9 @@ func TestProjectFlockSummary(t *testing.T) { if createRespSecond.Data.Period != 2 { t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) } + if createRespSecond.Data.Category != string(utils.ProjectFlockCategoryLaying) { + t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryLaying, createRespSecond.Data.Category) + } pivotRecords = nil if err := db.Where("project_flock_id = ?", createRespSecond.Data.Id).Find(&pivotRecords).Error; err != nil { @@ -155,6 +162,11 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected second pivot DetachedAt to be nil, got %v", secondPivotRecord.DetachedAt) } + secondKandang := fetchKandang(t, db, secondKandangID) + if secondKandang.Status != string(utils.KandangStatusPengajuan) { + t.Fatalf("expected second kandang status in DB to be PENGAJUAN, got %s", secondKandang.Status) + } + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) if resp.StatusCode != fiber.StatusOK { t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) @@ -202,7 +214,7 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) } - secondKandang := fetchKandang(t, db, secondKandangID) + secondKandang = fetchKandang(t, db, secondKandangID) if secondKandang.ProjectFlockId != nil { t.Fatalf("expected second project_flock_id to be nil after delete, got %v", *secondKandang.ProjectFlockId) } @@ -245,19 +257,18 @@ func TestProjectFlockSearchByRelatedFields(t *testing.T) { areaID := createArea(t, app, "Area Search Target") locationID := createLocation(t, app, "Location Search Target", "Location Address Target", areaID) flockID := createFlock(t, app, "Flock Search Target") - categoryID := createProductCategory(t, app, "Category Search Target", "CATGT") fcrID := createFcr(t, app, "FCR Search Target", []map[string]any{ {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, }) kandangID := createKandang(t, app, "Kandang Search Target", locationID, 1) createPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{kandangID}, + "flock_id": flockID, + "area_id": areaID, + "category": "growing", + "fcr_id": fcrID, + "location_id": locationID, + "kandang_ids": []uint{kandangID}, } resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) @@ -277,8 +288,8 @@ func TestProjectFlockSearchByRelatedFields(t *testing.T) { searchTerms := []string{ "Flock Search Target", "Area Search Target", - "Category Search Target", - "CATGT", + string(utils.ProjectFlockCategoryGrowing), + "growing", "FCR Search Target", "Kandang Search Target", "Location Search Target", @@ -329,7 +340,6 @@ func TestProjectFlockSorting(t *testing.T) { flockOne := createFlock(t, app, "Flock Sort One") flockTwo := createFlock(t, app, "Flock Sort Two") - categoryID := createProductCategory(t, app, "Category Sort", "CSORT") fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, }) @@ -339,12 +349,12 @@ func TestProjectFlockSorting(t *testing.T) { kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) projectOnePayload := map[string]any{ - "flock_id": flockOne, - "area_id": areaA, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationA, - "kandang_ids": []uint{kandangOne}, + "flock_id": flockOne, + "area_id": areaA, + "category": "growing", + "fcr_id": fcrID, + "location_id": locationA, + "kandang_ids": []uint{kandangOne}, } resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectOnePayload) if resp.StatusCode != fiber.StatusCreated { @@ -353,12 +363,12 @@ func TestProjectFlockSorting(t *testing.T) { projectOneID := parseProjectFlockID(t, body) projectTwoPayload := map[string]any{ - "flock_id": flockTwo, - "area_id": areaB, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationB, - "kandang_ids": []uint{kandangTwo, kandangThree}, + "flock_id": flockTwo, + "area_id": areaB, + "category": "laying", + "fcr_id": fcrID, + "location_id": locationB, + "kandang_ids": []uint{kandangTwo, kandangThree}, } resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectTwoPayload) if resp.StatusCode != fiber.StatusCreated { From 9b2b62429ca5707e9c8a9e085021c6d9c48d432b Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 20 Oct 2025 22:49:30 +0700 Subject: [PATCH 21/26] FIX[BE]: name duplicate flock,projectflock category change,menerapkan dto seperti warehouse di projectflock --- ...tFlock_category_and_period_unique.down.sql | 25 +++++++++++ ...ectFlock_category_and_period_unique.up.sql | 43 +++++++++++++++++++ ...0_add_project_flock_period_unique.down.sql | 1 - ...000_add_project_flock_period_unique.up.sql | 3 -- internal/database/seed/seeder.go | 18 ++++---- internal/entities/projectflock_kandang.go | 9 +--- .../projectflock_kandang.repository.go | 13 +++--- .../services/projectflock.service.go | 10 ++--- .../master_data/project_flock_test.go | 36 ++++++---------- 9 files changed, 101 insertions(+), 57 deletions(-) create mode 100644 internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.down.sql create mode 100644 internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.up.sql delete mode 100644 internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql delete mode 100644 internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql diff --git a/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.down.sql b/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.down.sql new file mode 100644 index 00000000..81c50f3f --- /dev/null +++ b/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.down.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- Recreate legacy columns on project_flock_kandangs +DROP INDEX IF EXISTS idx_project_flock_kandangs_unique; + +ALTER TABLE project_flock_kandangs + ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, + ADD COLUMN IF NOT EXISTS assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandangs_active + ON project_flock_kandangs (project_flock_id, kandang_id) + WHERE detached_at IS NULL; + +-- Restore product_category_id reference and drop category column +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS product_category_id BIGINT REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS category; + +COMMIT; + +DROP INDEX IF EXISTS project_flocks_flock_period_unique; diff --git a/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.up.sql b/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.up.sql new file mode 100644 index 00000000..2341a4cd --- /dev/null +++ b/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.up.sql @@ -0,0 +1,43 @@ +BEGIN; + +-- Add category column to project_flocks and backfill existing rows +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS category VARCHAR(20); + +UPDATE project_flocks +SET category = 'GROWING' +WHERE category IS NULL; + +ALTER TABLE project_flocks + ALTER COLUMN category SET NOT NULL; + +ALTER TABLE project_flocks + ALTER COLUMN category SET DEFAULT 'GROWING'; + +-- Drop legacy foreign key reference and column +ALTER TABLE project_flocks + DROP CONSTRAINT IF EXISTS project_flocks_product_category_id_fkey; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS product_category_id; + +-- Simplify project_flock_kandangs structure +DROP INDEX IF EXISTS idx_project_flock_kandangs_active; + +ALTER TABLE project_flock_kandangs + DROP COLUMN IF EXISTS created_by, + DROP COLUMN IF EXISTS assigned_at, + DROP COLUMN IF EXISTS detached_at, + DROP COLUMN IF EXISTS updated_at; + +ALTER TABLE project_flock_kandangs + ALTER COLUMN created_at SET DEFAULT NOW(); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandangs_unique + ON project_flock_kandangs (project_flock_id, kandang_id); + +COMMIT; + +CREATE UNIQUE INDEX project_flocks_flock_period_unique +ON project_flocks (flock_id, period) +WHERE deleted_at IS NULL; diff --git a/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql b/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql deleted file mode 100644 index f3cb3ddf..00000000 --- a/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX IF EXISTS project_flocks_flock_period_unique; diff --git a/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql b/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql deleted file mode 100644 index 40cebe2d..00000000 --- a/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE UNIQUE INDEX project_flocks_flock_period_unique -ON project_flocks (flock_id, period) -WHERE deleted_at IS NULL; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index e6f77b15..f718cde9 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -374,7 +374,7 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users if err := tx.Create(&kandang).Error; err != nil { return nil, err } - if err := syncPivotRelation(tx, projectFlockID, kandang.Id, createdBy); err != nil { + if err := syncPivotRelation(tx, projectFlockID, kandang.Id); err != nil { return nil, err } } else if err != nil { @@ -393,7 +393,7 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { return nil, err } - if err := syncPivotRelation(tx, projectFlockID, kandang.Id, createdBy); err != nil { + if err := syncPivotRelation(tx, projectFlockID, kandang.Id); err != nil { return nil, err } } @@ -403,25 +403,24 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return result, nil } -func syncPivotRelation(tx *gorm.DB, projectFlockID *uint, kandangID uint, createdBy uint) error { +func syncPivotRelation(tx *gorm.DB, projectFlockID *uint, kandangID uint) error { if err := detachActivePivot(tx, kandangID); err != nil { return err } if projectFlockID == nil { return nil } - return ensureActivePivot(tx, *projectFlockID, kandangID, createdBy) + return ensureActivePivot(tx, *projectFlockID, kandangID) } func detachActivePivot(tx *gorm.DB, kandangID uint) error { - return tx.Model(&entity.ProjectFlockKandang{}). - Where("kandang_id = ? AND detached_at IS NULL", kandangID). - Updates(map[string]any{"detached_at": time.Now()}).Error + return tx.Where("kandang_id = ?", kandangID). + Delete(&entity.ProjectFlockKandang{}).Error } -func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID, createdBy uint) error { +func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID uint) error { var pivot entity.ProjectFlockKandang - err := tx.Where("project_flock_id = ? AND kandang_id = ? AND detached_at IS NULL", projectFlockID, kandangID). + err := tx.Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). First(&pivot).Error if err == nil { return nil @@ -432,7 +431,6 @@ func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID, createdBy uint) e newRecord := entity.ProjectFlockKandang{ ProjectFlockId: projectFlockID, KandangId: kandangID, - CreatedBy: createdBy, } return tx.Create(&newRecord).Error } diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index 0014a815..1c29c22e 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -4,14 +4,9 @@ import "time" type ProjectFlockKandang struct { Id uint `gorm:"primaryKey"` - ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_active,priority:1,where:detached_at IS NULL"` - KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_active,priority:2,where:detached_at IS NULL"` - CreatedBy uint `gorm:"not null"` - AssignedAt time.Time `gorm:"autoCreateTime"` - DetachedAt *time.Time `gorm:"index"` + ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` + KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 9b89a399..b5ce21a4 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -2,7 +2,6 @@ package repository import ( "context" - "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -10,7 +9,7 @@ import ( type ProjectFlockKandangRepository interface { CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error - MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) error + DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository DB() *gorm.DB @@ -31,14 +30,13 @@ func (r *projectFlockKandangRepositoryImpl) CreateMany(ctx context.Context, reco return r.db.WithContext(ctx).Create(&records).Error } -func (r *projectFlockKandangRepositoryImpl) MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) error { +func (r *projectFlockKandangRepositoryImpl) DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error { if len(kandangIDs) == 0 { return nil } return r.db.WithContext(ctx). - Model(&entity.ProjectFlockKandang{}). - Where("project_flock_id = ? AND kandang_id IN ? AND detached_at IS NULL", projectFlockID, kandangIDs). - Updates(map[string]any{"detached_at": detachedAt}).Error + Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). + Delete(&entity.ProjectFlockKandang{}).Error } func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) { @@ -47,8 +45,7 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entit Preload("ProjectFlock"). Preload("ProjectFlock.Flock"). Preload("Kandang"). - Preload("CreatedUser"). - Order("project_flock_id ASC, assigned_at ASC"). + Order("project_flock_id ASC, created_at ASC"). Find(&records).Error; err != nil { return nil, err } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 18f00b7d..21941826 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strings" - "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -242,7 +241,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } - if err := s.attachKandangs(c.Context(), tx, createBody.Id, kandangIDs, createBody.CreatedBy); err != nil { + if err := s.attachKandangs(c.Context(), tx, createBody.Id, kandangIDs); err != nil { tx.Rollback() s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", createBody.Id, err) return nil, err @@ -395,7 +394,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if len(toAttach) > 0 { - if err := s.attachKandangs(c.Context(), tx, id, toAttach, existing.CreatedBy); err != nil { + if err := s.attachKandangs(c.Context(), tx, id, toAttach); err != nil { tx.Rollback() s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", id, err) return nil, err @@ -533,7 +532,7 @@ func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []s } } -func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint, createdBy uint) error { +func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint) error { if len(kandangIDs) == 0 { return nil } @@ -553,7 +552,6 @@ func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, pr records[i] = &entity.ProjectFlockKandang{ ProjectFlockId: projectFlockID, KandangId: id, - CreatedBy: createdBy, } } if err := pivotRepo.CreateMany(ctx, records); err != nil { @@ -578,7 +576,7 @@ func (s projectflockService) detachKandangs(ctx context.Context, tx *gorm.DB, pr return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } - if err := s.pivotRepoWithTx(tx).MarkDetached(ctx, projectFlockID, kandangIDs, time.Now()); err != nil { + if err := s.pivotRepoWithTx(tx).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index fbeb804c..60bb2d90 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -113,9 +113,6 @@ func TestProjectFlockSummary(t *testing.T) { if firstPivotRecord.KandangId != kandangID { t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) } - if firstPivotRecord.DetachedAt != nil { - t.Fatalf("expected pivot DetachedAt to be nil for active assignment, got %v", firstPivotRecord.DetachedAt) - } secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) secondPayload := map[string]any{ @@ -158,9 +155,6 @@ func TestProjectFlockSummary(t *testing.T) { if secondPivotRecord.KandangId != secondKandangID { t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) } - if secondPivotRecord.DetachedAt != nil { - t.Fatalf("expected second pivot DetachedAt to be nil, got %v", secondPivotRecord.DetachedAt) - } secondKandang := fetchKandang(t, db, secondKandangID) if secondKandang.Status != string(utils.KandangStatusPengajuan) { @@ -198,15 +192,14 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) } - var firstPivot entities.ProjectFlockKandang - if err := db.First(&firstPivot, firstPivotRecord.Id).Error; err != nil { - t.Fatalf("failed to reload first pivot record: %v", err) + var remainingFirst int64 + if err := db.Model(&entities.ProjectFlockKandang{}). + Where("project_flock_id = ? AND kandang_id = ?", createResp.Data.Id, kandangID). + Count(&remainingFirst).Error; err != nil { + t.Fatalf("failed to count first pivot records after delete: %v", err) } - if firstPivot.DetachedAt == nil { - t.Fatalf("expected first pivot DetachedAt to be set after delete") - } - if firstPivot.ProjectFlockId != createResp.Data.Id { - t.Fatalf("expected first pivot project_flock_id %d, got %d", createResp.Data.Id, firstPivot.ProjectFlockId) + if remainingFirst != 0 { + t.Fatalf("expected no pivot records remaining after delete, found %d", remainingFirst) } resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) @@ -222,15 +215,14 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) } - var secondPivot entities.ProjectFlockKandang - if err := db.First(&secondPivot, secondPivotRecord.Id).Error; err != nil { - t.Fatalf("failed to reload second pivot record: %v", err) + var remainingSecond int64 + if err := db.Model(&entities.ProjectFlockKandang{}). + Where("project_flock_id = ? AND kandang_id = ?", createRespSecond.Data.Id, secondKandangID). + Count(&remainingSecond).Error; err != nil { + t.Fatalf("failed to count second pivot records after delete: %v", err) } - if secondPivot.DetachedAt == nil { - t.Fatalf("expected second pivot DetachedAt to be set after delete") - } - if secondPivot.ProjectFlockId != createRespSecond.Data.Id { - t.Fatalf("expected second pivot project_flock_id %d, got %d", createRespSecond.Data.Id, secondPivot.ProjectFlockId) + if remainingSecond != 0 { + t.Fatalf("expected no second pivot records remaining after delete, found %d", remainingSecond) } resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) From 542e5033606c74acc43688f7c0eb0b10aa5775aa Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 21 Oct 2025 09:01:02 +0700 Subject: [PATCH 22/26] fix[BE]: change dummy document path on transfer create --- .../modules/inventory/transfers/services/transfer.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index dbb4694b..90642f6c 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -210,7 +210,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: "dummy duls", // todo: tunggu ada aws baru proses + DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", // todo: tunggu ada aws baru proses ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostTotal: delivery.DeliveryCost, }) From 1afbdea4ffb2a66471f167f27b7d32d9436eff50 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 21 Oct 2025 10:20:34 +0700 Subject: [PATCH 23/26] fix[BE]: fix logic pengambilan quatity untuk chick in dan penggunaan helper common --- ...create_stock_availabilities_table.down.sql | 1 - ...6_create_stock_availabilities_table.up.sql | 15 ------ ...019141014_create_audit_logs_table.down.sql | 1 - ...51019141014_create_audit_logs_table.up.sql | 13 ----- .../controllers/adjustment.controller.go | 4 +- .../services/adjustment.service.go | 28 +++-------- .../validations/adjustment.validation.go | 4 +- .../chickins/services/chickin.service.go | 49 ++++++++++--------- 8 files changed, 39 insertions(+), 76 deletions(-) delete mode 100644 internal/database/migrations/20251019040246_create_stock_availabilities_table.down.sql delete mode 100644 internal/database/migrations/20251019040246_create_stock_availabilities_table.up.sql delete mode 100644 internal/database/migrations/20251019141014_create_audit_logs_table.down.sql delete mode 100644 internal/database/migrations/20251019141014_create_audit_logs_table.up.sql diff --git a/internal/database/migrations/20251019040246_create_stock_availabilities_table.down.sql b/internal/database/migrations/20251019040246_create_stock_availabilities_table.down.sql deleted file mode 100644 index 1d50d98b..00000000 --- a/internal/database/migrations/20251019040246_create_stock_availabilities_table.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS stock_availabilities; \ No newline at end of file diff --git a/internal/database/migrations/20251019040246_create_stock_availabilities_table.up.sql b/internal/database/migrations/20251019040246_create_stock_availabilities_table.up.sql deleted file mode 100644 index bce6f7e6..00000000 --- a/internal/database/migrations/20251019040246_create_stock_availabilities_table.up.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE stock_availabilities ( - id BIGSERIAL PRIMARY KEY, - entity_type VARCHAR(50) NOT NULL, - entity_id BIGINT NOT NULL, - product_id BIGINT, - quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, - reserved_quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, - unit VARCHAR(20), - last_updated TIMESTAMPTZ DEFAULT now(), - created_at TIMESTAMPTZ DEFAULT now(), - deleted_at TIMESTAMPTZ -); - -ALTER TABLE stock_availabilities -ADD CONSTRAINT fk_product_id FOREIGN KEY (product_id) REFERENCES products (id); \ No newline at end of file diff --git a/internal/database/migrations/20251019141014_create_audit_logs_table.down.sql b/internal/database/migrations/20251019141014_create_audit_logs_table.down.sql deleted file mode 100644 index 4cf6b411..00000000 --- a/internal/database/migrations/20251019141014_create_audit_logs_table.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS audit_logs; \ No newline at end of file diff --git a/internal/database/migrations/20251019141014_create_audit_logs_table.up.sql b/internal/database/migrations/20251019141014_create_audit_logs_table.up.sql deleted file mode 100644 index 13731dcc..00000000 --- a/internal/database/migrations/20251019141014_create_audit_logs_table.up.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE audit_logs ( - id BIGSERIAL PRIMARY KEY, - table_name VARCHAR(100) NOT NULL, - record_id BIGINT NOT NULL, - action VARCHAR(30) NOT NULL, - before_data JSONB, - after_data JSONB, - changed_by BIGINT, - created_at TIMESTAMPTZ DEFAULT now() -); - -ALTER TABLE audit_logs -ADD CONSTRAINT fk_changed_by FOREIGN KEY (changed_by) REFERENCES users (id); \ No newline at end of file diff --git a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go index dc3df0a9..617a1b5f 100644 --- a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go +++ b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go @@ -49,8 +49,8 @@ func (u *AdjustmentController) AdjustmentHistory(c *fiber.Ctx) error { query := &validation.Query{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), - ProductID: c.QueryInt("product_id", 0), - WarehouseID: c.QueryInt("warehouse_id", 0), + ProductID: uint(c.QueryInt("product_id", 0)), + WarehouseID: uint(c.QueryInt("warehouse_id", 0)), TransactionType: c.Query("transaction_type", ""), } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 69654b85..7a2d06bc 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -4,6 +4,8 @@ import ( "errors" "strings" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -77,22 +79,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } ctx := c.Context() - isProductExist, err := s.ProductRepo.IdExists(c.Context(), uint(req.ProductID)) - if err != nil { - s.Log.Errorf("Failed to check product existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") - } - if !isProductExist { - return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") - } - - isWarehouseExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(req.WarehouseID)) - if err != nil { - s.Log.Errorf("Failed to check warehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") - } - if !isWarehouseExist { - return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, + common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, + ); err != nil { + return nil, err } if req.Quantity <= 0 { @@ -118,6 +109,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e Quantity: 0, CreatedBy: 1, // TODO: should Get from auth middleware } + if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { s.Log.Errorf("Failed to create product warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse") @@ -126,7 +118,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) if err != nil { s.Log.Errorf("Failed to get product warehouse: %+v", err) @@ -159,14 +150,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e s.Log.Errorf("Failed to create stock log: %+v", err) return err } - s.Log.Infof("Stock log created: %+v", newLog.Id) productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) return err } - s.Log.Infof("Product warehouse quantity updated: %+v", productWarehouse.Id) createdLogId = newLog.Id return nil @@ -184,7 +173,6 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu if err := s.Validate.Struct(query); err != nil { return nil, 0, err } - offset := (query.Page - 1) * query.Limit isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) diff --git a/internal/modules/inventory/adjustments/validations/adjustment.validation.go b/internal/modules/inventory/adjustments/validations/adjustment.validation.go index 7d2385cc..2e7259f2 100644 --- a/internal/modules/inventory/adjustments/validations/adjustment.validation.go +++ b/internal/modules/inventory/adjustments/validations/adjustment.validation.go @@ -11,7 +11,7 @@ type Create struct { type Query struct { Page int `query:"page" validate:"omitempty,min=1"` Limit int `query:"limit" validate:"omitempty,min=1,max=100"` - ProductID int `query:"product_id" validate:"omitempty,min=0"` - WarehouseID int `query:"warehouse_id" validate:"omitempty,min=0"` + ProductID uint `query:"product_id" validate:"omitempty,min=0"` + WarehouseID uint `query:"warehouse_id" validate:"omitempty,min=0"` TransactionType string `query:"transaction_type" validate:"omitempty,oneof=increase decrease"` } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index f866e96d..64fe1e97 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -132,27 +132,33 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit s.Log.Errorf("Failed to get project flock: %+v", err) return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") } - - var productWarehouse entity.ProductWarehouse + var productWarehouses []entity.ProductWarehouse err = s.ProductWarehouseRepo.DB(). WithContext(c.Context()). Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.ProductCategory.Code, warehouse.Id). Order("created_at DESC"). - First(&productWarehouse).Error + Find(&productWarehouses).Error if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") - } - s.Log.Errorf("Failed to get product warehouse: %+v", err) + s.Log.Errorf("Failed to get product warehouses: %+v", err) return nil, err } - - if productWarehouse.Quantity < 1 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient product quantity in warehouse") + if len(productWarehouses) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") } + // Jumlahkan semua quantity DOC + totalQuantity := 0.0 + for _, pw := range productWarehouses { + totalQuantity += pw.Quantity + } + + if totalQuantity < 1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") + } + + // Buat satu chickin dengan total quantity chickinDate, err := utils.ParseDateString(req.ChickInDate) if err != nil { s.Log.Errorf("Failed to parse chickin date: %+v", err) @@ -161,9 +167,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: projectflockkandang.ProjectFlockId, ChickInDate: chickinDate, - Quantity: productWarehouse.Quantity, + Quantity: totalQuantity, Note: "", - CreatedBy: 1, //todo: ganti dengan + CreatedBy: 1, //todo: ganti dengan user login } err = s.Repository.CreateOne(c.Context(), newChickin, nil) if err != nil { @@ -171,16 +177,15 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - updatedQuantity := productWarehouse.Quantity - newChickin.Quantity - if updatedQuantity < 0 { - updatedQuantity = 0 - } - err = s.ProductWarehouseRepo.PatchOne(c.Context(), productWarehouse.Id, map[string]any{ - "quantity": updatedQuantity, - }, nil) - if err != nil { - s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) - return nil, err + // Update semua product warehouse: set quantity jadi 0 + for _, pw := range productWarehouses { + err = s.ProductWarehouseRepo.PatchOne(c.Context(), pw.Id, map[string]any{ + "quantity": 0, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) + return nil, err + } } existingPopulation, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) From b1b63d266a343aeb16094eef6020e7a228c1b480 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 21 Oct 2025 11:48:54 +0700 Subject: [PATCH 24/26] fix[BE]: menggunakan base dto dari dto utama entity ketimbang buat simple dto baru --- .../production/chickins/dto/chickin.dto.go | 178 +++++++----------- .../chickins/services/chickin.service.go | 6 +- 2 files changed, 73 insertions(+), 111 deletions(-) diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 9fd29f3c..96115b58 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -4,68 +4,42 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + areaBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + fcrBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" + flockBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + kandangBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + locationBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + productCategoryBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" + userBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) // === DTO Structs (ordered) === -type FlockDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type KandangDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type ProductCategoryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type AreaDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type FcrDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` +type ChickinBaseDTO struct { + Id uint `json:"id"` + ProjectFlockKandang *ProjectFlockKandangDTO `json:"project_flock_kandang"` + ChickInDate time.Time `json:"chick_in_date"` + Quantity float64 `json:"quantity"` + Note string `json:"note"` } type ProjectFlockDTO struct { - Id uint `json:"id"` - Period int `json:"period"` - Flock *FlockDTO `json:"flock"` - ProductCategory *ProductCategoryDTO `json:"product_category"` - Area *AreaDTO `json:"area"` - Fcr *FcrDTO `json:"fcr"` - Location *LocationDTO `json:"location"` + Id uint `json:"id"` + Period int `json:"period"` + Flock *flockBaseDTO.FlockBaseDTO `json:"flock"` + ProductCategory *productCategoryBaseDTO.ProductCategoryBaseDTO `json:"product_category"` + Area *areaBaseDTO.AreaBaseDTO `json:"area"` + Fcr *fcrBaseDTO.FcrBaseDTO `json:"fcr"` + Location *locationBaseDTO.LocationBaseDTO `json:"location"` } type ProjectFlockKandangDTO struct { - Id uint `json:"id"` - ProjectFlock *ProjectFlockDTO `json:"project_flock"` - Kandang *KandangDTO `json:"kandang"` + Id uint `json:"id"` + ProjectFlock *ProjectFlockDTO `json:"project_flock"` + Kandang *kandangBaseDTO.KandangBaseDTO `json:"kandang"` } -type UserBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type ChickinBaseDTO struct { - Id uint `json:"id"` - ProjectFlocKandangId uint `json:"project_floc_kandang_id"` - ChickInDate time.Time `json:"chick_in_date"` - Quantity float64 `json:"quantity"` - Note string `json:"note"` -} +// gunakan base DTO dari package users type ChickinSimpleDTO struct { Id uint `json:"id"` @@ -78,10 +52,10 @@ type ChickinSimpleDTO struct { type ChickinListDTO struct { ChickinBaseDTO - ProjectFlockKandang *ProjectFlockKandangDTO `json:"project_flock_kandang"` - CreatedUser *UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ProjectFlockKandang *ProjectFlockKandangDTO `json:"project_flock_kandang"` + CreatedUser *userBaseDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ChickinDetailDTO struct { @@ -90,72 +64,58 @@ type ChickinDetailDTO struct { // === Mapper Functions (ordered) === -func ToFlockDTO(e entity.Flock) FlockDTO { - return FlockDTO{ - Id: e.Id, - Name: e.Name, - } +func ToFlockDTO(e entity.Flock) flockBaseDTO.FlockBaseDTO { + return flockBaseDTO.ToFlockBaseDTO(e) } -func ToKandangDTO(e entity.Kandang) KandangDTO { - return KandangDTO{ - Id: e.Id, - Name: e.Name, - } +func ToKandangDTO(e entity.Kandang) kandangBaseDTO.KandangBaseDTO { + return kandangBaseDTO.ToKandangBaseDTO(e) } -func ToProductCategoryDTO(e entity.ProductCategory) ProductCategoryDTO { - return ProductCategoryDTO{ - Id: e.Id, - Name: e.Name, - } +func ToProductCategoryDTO(e entity.ProductCategory) productCategoryBaseDTO.ProductCategoryBaseDTO { + return productCategoryBaseDTO.ToProductCategoryBaseDTO(e) } -func ToAreaDTO(e entity.Area) AreaDTO { - return AreaDTO{ - Id: e.Id, - Name: e.Name, - } +func ToAreaDTO(e entity.Area) areaBaseDTO.AreaBaseDTO { + return areaBaseDTO.ToAreaBaseDTO(e) } -func ToFcrDTO(e entity.Fcr) FcrDTO { - return FcrDTO{ - Id: e.Id, - Name: e.Name, - } +func ToFcrDTO(e entity.Fcr) fcrBaseDTO.FcrBaseDTO { + return fcrBaseDTO.ToFcrBaseDTO(e) } -func ToLocationDTO(e entity.Location) LocationDTO { - return LocationDTO{ - Id: e.Id, - Name: e.Name, - } +func ToLocationDTO(e entity.Location) locationBaseDTO.LocationBaseDTO { + return locationBaseDTO.ToLocationBaseDTO(e) +} + +func ToUserBaseDTO(e entity.User) userBaseDTO.UserBaseDTO { + return userBaseDTO.ToUserBaseDTO(e) } func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { - var flock *FlockDTO + var flock *flockBaseDTO.FlockBaseDTO if e.Flock.Id != 0 { - mapped := ToFlockDTO(e.Flock) + mapped := flockBaseDTO.ToFlockBaseDTO(e.Flock) flock = &mapped } - var productCategory *ProductCategoryDTO + var productCategory *productCategoryBaseDTO.ProductCategoryBaseDTO if e.ProductCategory.Id != 0 { - mapped := ToProductCategoryDTO(e.ProductCategory) + mapped := productCategoryBaseDTO.ToProductCategoryBaseDTO(e.ProductCategory) productCategory = &mapped } - var area *AreaDTO + var area *areaBaseDTO.AreaBaseDTO if e.Area.Id != 0 { - mapped := ToAreaDTO(e.Area) + mapped := areaBaseDTO.ToAreaBaseDTO(e.Area) area = &mapped } - var fcr *FcrDTO + var fcr *fcrBaseDTO.FcrBaseDTO if e.Fcr.Id != 0 { - mapped := ToFcrDTO(e.Fcr) + mapped := fcrBaseDTO.ToFcrBaseDTO(e.Fcr) fcr = &mapped } - var location *LocationDTO + var location *locationBaseDTO.LocationBaseDTO if e.Location.Id != 0 { - mapped := ToLocationDTO(e.Location) + mapped := locationBaseDTO.ToLocationBaseDTO(e.Location) location = &mapped } return ProjectFlockDTO{ @@ -175,9 +135,9 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD mapped := ToProjectFlockDTO(e.ProjectFlock) pf = &mapped } - var kandang *KandangDTO + var kandang *kandangBaseDTO.KandangBaseDTO if e.Kandang.Id != 0 { - mapped := ToKandangDTO(e.Kandang) + mapped := kandangBaseDTO.ToKandangBaseDTO(e.Kandang) kandang = &mapped } return ProjectFlockKandangDTO{ @@ -187,20 +147,18 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD } } -func ToUserBaseDTO(e entity.User) UserBaseDTO { - return UserBaseDTO{ - Id: e.Id, - Name: e.Name, - } -} - func ToChickinBaseDTO(e entity.ProjectChickin) ChickinBaseDTO { + var pfk *ProjectFlockKandangDTO + if e.ProjectFlockKandang.Id != 0 { + mapped := ToProjectFlockKandangDTO(e.ProjectFlockKandang) + pfk = &mapped + } return ChickinBaseDTO{ - Id: e.Id, - ProjectFlocKandangId: e.ProjectFlockKandangId, - ChickInDate: e.ChickInDate, - Quantity: e.Quantity, - Note: e.Note, + Id: e.Id, + ProjectFlockKandang: pfk, + ChickInDate: e.ChickInDate, + Quantity: e.Quantity, + Note: e.Note, } } @@ -216,9 +174,9 @@ func ToChickinSimpleDTO(e entity.ProjectChickin) ChickinSimpleDTO { } func ToChickinListDTO(e entity.ProjectChickin) ChickinListDTO { - var createdUser *UserBaseDTO + var createdUser *userBaseDTO.UserBaseDTO if e.CreatedUser.Id != 0 { - mapped := ToUserBaseDTO(e.CreatedUser) + mapped := userBaseDTO.ToUserBaseDTO(e.CreatedUser) createdUser = &mapped } var pfk *ProjectFlockKandangDTO diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 64fe1e97..46bc8069 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -62,12 +62,16 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("ProjectFlockKandang.Kandang"). + Preload("ProjectFlockKandang.Kandang.Location"). + Preload("ProjectFlockKandang.Kandang.Location.Area"). + Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.Flock"). Preload("ProjectFlockKandang.ProjectFlock.ProductCategory"). Preload("ProjectFlockKandang.ProjectFlock.Area"). Preload("ProjectFlockKandang.ProjectFlock.Fcr"). - Preload("ProjectFlockKandang.ProjectFlock.Location") + Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.ProjectFlock.Location.Area") } From 55b14f5fc7ec0e0a7f165a35f7eac4411bf075ef Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Tue, 21 Oct 2025 13:56:30 +0700 Subject: [PATCH 25/26] feat(BE): approval_workflow, adjusment project_flocks, common, and migration --- .../repository/common.approval.repository..go | 106 +++++++ ...epository.go => common.base.repository.go} | 0 ...helpers.go => common.exists.repository.go} | 0 .../common/service/common.approval.service.go | 234 ++++++++++++++++ ...relation.go => common.relation.service.go} | 0 ...idation.go => common.custom.validation.go} | 0 ...1015162158_create_approvals_table.down.sql | 2 + ...251015162158_create_approvals_table.up.sql | 12 + ..._rename_approval_status_to_action.down.sql | 18 ++ ...55_rename_approval_status_to_action.up.sql | 14 + internal/database/seed/seeder.go | 57 ++++ internal/entities/approval.go | 28 ++ internal/entities/projectfloc.go | 28 -- internal/entities/projectflock.go | 29 ++ .../controllers/approval.controller.go | 100 +++++++ .../modules/approvals/dto/approval.dto.go | 122 ++++++++ internal/modules/approvals/module.go | 25 ++ internal/modules/approvals/route.go | 19 ++ .../validations/approval.validation.go | 10 + .../repositories/constant.repository.go | 49 ++++ .../controllers/projectflock.controller.go | 27 ++ .../project_flocks/dto/projectflock.dto.go | 107 ++++--- .../production/project_flocks/module.go | 13 +- .../production/project_flocks/route.go | 1 + .../services/projectflock.service.go | 261 ++++++++++++------ .../validations/projectflock.validation.go | 5 + internal/route/route.go | 2 + .../utils/approvals/util.approval_workflow.go | 243 ++++++++++++++++ internal/utils/constant.go | 22 +- tools/templates/validation.tmpl | 4 +- 30 files changed, 1379 insertions(+), 159 deletions(-) create mode 100644 internal/common/repository/common.approval.repository..go rename internal/common/repository/{repository.go => common.base.repository.go} (100%) rename internal/common/repository/{helpers.go => common.exists.repository.go} (100%) create mode 100644 internal/common/service/common.approval.service.go rename internal/common/service/{relation.go => common.relation.service.go} (100%) rename internal/common/validation/{custom_validation.go => common.custom.validation.go} (100%) create mode 100644 internal/database/migrations/20251015162158_create_approvals_table.down.sql create mode 100644 internal/database/migrations/20251015162158_create_approvals_table.up.sql create mode 100644 internal/database/migrations/20251017071755_rename_approval_status_to_action.down.sql create mode 100644 internal/database/migrations/20251017071755_rename_approval_status_to_action.up.sql create mode 100644 internal/entities/approval.go delete mode 100644 internal/entities/projectfloc.go create mode 100644 internal/entities/projectflock.go create mode 100644 internal/modules/approvals/controllers/approval.controller.go create mode 100644 internal/modules/approvals/dto/approval.dto.go create mode 100644 internal/modules/approvals/module.go create mode 100644 internal/modules/approvals/route.go create mode 100644 internal/modules/approvals/validations/approval.validation.go create mode 100644 internal/utils/approvals/util.approval_workflow.go diff --git a/internal/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go new file mode 100644 index 00000000..7f1c27ae --- /dev/null +++ b/internal/common/repository/common.approval.repository..go @@ -0,0 +1,106 @@ +package repository + +import ( + "context" + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ApprovalRepository interface { + BaseRepository[entity.Approval] + FindByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) + LatestByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) + LatestByTargets(ctx context.Context, workflow string, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]entity.Approval, error) +} + +type approvalRepositoryImpl struct { + *BaseRepositoryImpl[entity.Approval] +} + +func NewApprovalRepository(db *gorm.DB) ApprovalRepository { + return &approvalRepositoryImpl{ + BaseRepositoryImpl: NewBaseRepository[entity.Approval](db), + } +} + +func (r *approvalRepositoryImpl) FindByTarget( + ctx context.Context, + workflow string, + approvableID uint, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.Approval, error) { + var approvals []entity.Approval + + q := r.DB().WithContext(ctx).Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID) + if modifier != nil { + q = modifier(q) + } + + if err := q.Order("action_at ASC").Find(&approvals).Error; err != nil { + return nil, err + } + return approvals, nil +} + +func (r *approvalRepositoryImpl) LatestByTarget( + ctx context.Context, + workflow string, + approvableID uint, + modifier func(*gorm.DB) *gorm.DB, +) (*entity.Approval, error) { + var approval entity.Approval + + q := r.DB().WithContext(ctx). + Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID). + Order("action_at DESC") + + if modifier != nil { + q = modifier(q) + } + + if err := q.Limit(1).First(&approval).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return &approval, nil +} + +func (r *approvalRepositoryImpl) LatestByTargets( + ctx context.Context, + workflow string, + approvableIDs []uint, + modifier func(*gorm.DB) *gorm.DB, +) (map[uint]entity.Approval, error) { + if len(approvableIDs) == 0 { + return nil, nil + } + + result := make(map[uint]entity.Approval, len(approvableIDs)) + + q := r.DB().WithContext(ctx). + Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). + Order("action_at DESC") + + if modifier != nil { + q = modifier(q) + } + + var approvals []entity.Approval + if err := q.Find(&approvals).Error; err != nil { + return nil, err + } + + for _, approval := range approvals { + if _, exists := result[approval.ApprovableId]; exists { + continue + } + result[approval.ApprovableId] = approval + } + + return result, nil +} diff --git a/internal/common/repository/repository.go b/internal/common/repository/common.base.repository.go similarity index 100% rename from internal/common/repository/repository.go rename to internal/common/repository/common.base.repository.go diff --git a/internal/common/repository/helpers.go b/internal/common/repository/common.exists.repository.go similarity index 100% rename from internal/common/repository/helpers.go rename to internal/common/repository/common.exists.repository.go diff --git a/internal/common/service/common.approval.service.go b/internal/common/service/common.approval.service.go new file mode 100644 index 00000000..569a7cc6 --- /dev/null +++ b/internal/common/service/common.approval.service.go @@ -0,0 +1,234 @@ +package service + +import ( + "context" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gorm.io/gorm" +) + +type ApprovalService interface { + RegisterWorkflowSteps(workflow approvalutils.ApprovalWorkflowKey, steps map[approvalutils.ApprovalStep]string) error + WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string + WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) + CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error) + List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error) + ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) + LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) + LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error) +} + +type approvalService struct { + repo commonRepo.ApprovalRepository +} + +func NewApprovalService(repo commonRepo.ApprovalRepository) ApprovalService { + return &approvalService{repo: repo} +} + +func (s *approvalService) RegisterWorkflowSteps(workflow approvalutils.ApprovalWorkflowKey, steps map[approvalutils.ApprovalStep]string) error { + return approvalutils.RegisterWorkflowSteps(workflow, steps) +} + +func (s *approvalService) WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string { + return approvalutils.WorkflowSteps(workflow) +} + +func (s *approvalService) WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) { + return approvalutils.ApprovalStepName(workflow, step) +} + +func (s *approvalService) CreateApproval( + ctx context.Context, + workflow approvalutils.ApprovalWorkflowKey, + approvableID uint, + step approvalutils.ApprovalStep, + action *entity.ApprovalAction, + actorID uint, + note *string, +) (*entity.Approval, error) { + record, err := approvalutils.NewApproval(workflow, approvableID, step, action, actorID, note) + if err != nil { + return nil, err + } + + if err := s.repo.CreateOne(ctx, record, nil); err != nil { + return nil, err + } + + s.decorateApproval(workflow, record) + + return record, nil +} + +func (s *approvalService) List( + ctx context.Context, + module string, + approvableID *uint, + page, limit int, + search string, +) ([]entity.Approval, int64, error) { + module = strings.TrimSpace(strings.ToUpper(module)) + search = strings.TrimSpace(search) + + if limit <= 0 { + limit = 10 + } + if page <= 0 { + page = 1 + } + + offset := (page - 1) * limit + + records, total, err := s.repo.GetAll( + ctx, + offset, + limit, + func(db *gorm.DB) *gorm.DB { + query := db. + Where("approvable_type = ?", module). + Order("action_at DESC"). + Preload("ActionUser") + + if approvableID != nil { + query = query.Where("approvable_id = ?", *approvableID) + } + + if search != "" { + like := "%" + strings.ToLower(search) + "%" + query = query.Where("(LOWER(step_name) LIKE ? OR LOWER(action) LIKE ? OR LOWER(notes) LIKE ?)", like, like, like) + } + + return query + }, + ) + if err != nil { + if s.isApprovalTableMissing(err) { + return nil, 0, nil + } + return nil, 0, err + } + if len(records) == 0 { + return nil, total, nil + } + + workflow := approvalutils.ApprovalWorkflowKey(module) + for i := range records { + s.decorateApproval(workflow, &records[i]) + } + + return records, total, nil +} + +func (s *approvalService) ListByTarget( + ctx context.Context, + workflow approvalutils.ApprovalWorkflowKey, + approvableID uint, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.Approval, error) { + records, err := s.repo.FindByTarget(ctx, workflow.String(), approvableID, modifier) + if err != nil { + if s.isApprovalTableMissing(err) { + return nil, nil + } + return nil, err + } + + for i := range records { + s.decorateApproval(workflow, &records[i]) + } + + return records, nil +} + +func (s *approvalService) LatestByTarget( + ctx context.Context, + workflow approvalutils.ApprovalWorkflowKey, + approvableID uint, + modifier func(*gorm.DB) *gorm.DB, +) (*entity.Approval, error) { + record, err := s.repo.LatestByTarget(ctx, workflow.String(), approvableID, modifier) + if err != nil { + if s.isApprovalTableMissing(err) { + return nil, nil + } + return nil, err + } + if record == nil { + return nil, nil + } + s.decorateApproval(workflow, record) + return record, nil +} + +func (s *approvalService) LatestByTargets( + ctx context.Context, + workflow approvalutils.ApprovalWorkflowKey, + approvableIDs []uint, + modifier func(*gorm.DB) *gorm.DB, +) (map[uint]*entity.Approval, error) { + records, err := s.repo.LatestByTargets(ctx, workflow.String(), approvableIDs, modifier) + if err != nil { + if s.isApprovalTableMissing(err) { + return nil, nil + } + return nil, err + } + if len(records) == 0 { + return nil, nil + } + + result := make(map[uint]*entity.Approval, len(records)) + for approvableID, approval := range records { + approvalCopy := approval + s.decorateApproval(workflow, &approvalCopy) + result[approvableID] = &approvalCopy + } + + return result, nil +} + +func (s *approvalService) decorateApproval(workflow approvalutils.ApprovalWorkflowKey, approval *entity.Approval) { + if approval == nil { + return + } + currentName := strings.TrimSpace(approval.StepName) + if currentName == "" { + if name, ok := approvalutils.ApprovalStepName(workflow, approvalutils.ApprovalStep(approval.StepNumber)); ok { + approval.StepName = name + } + } else { + approval.StepName = currentName + } +} + +func (s *approvalService) isApprovalTableMissing(err error) bool { + if err == nil { + return false + } + + errMsg := strings.ToLower(err.Error()) + + if strings.Contains(errMsg, "no such table: approvals") { + return true + } + + schemaIssues := []string{ + `relation "approvals" does not exist`, + `column "step_name" does not exist`, + `column "step_number" does not exist`, + `column "action" does not exist`, + `column "status" does not exist`, + `column "step" does not exist`, + } + for _, issue := range schemaIssues { + if strings.Contains(errMsg, issue) { + return true + } + } + + return false +} diff --git a/internal/common/service/relation.go b/internal/common/service/common.relation.service.go similarity index 100% rename from internal/common/service/relation.go rename to internal/common/service/common.relation.service.go diff --git a/internal/common/validation/custom_validation.go b/internal/common/validation/common.custom.validation.go similarity index 100% rename from internal/common/validation/custom_validation.go rename to internal/common/validation/common.custom.validation.go diff --git a/internal/database/migrations/20251015162158_create_approvals_table.down.sql b/internal/database/migrations/20251015162158_create_approvals_table.down.sql new file mode 100644 index 00000000..0ad38d2b --- /dev/null +++ b/internal/database/migrations/20251015162158_create_approvals_table.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS approvals_approvable_lookup; +DROP TABLE IF EXISTS approvals; diff --git a/internal/database/migrations/20251015162158_create_approvals_table.up.sql b/internal/database/migrations/20251015162158_create_approvals_table.up.sql new file mode 100644 index 00000000..50154f33 --- /dev/null +++ b/internal/database/migrations/20251015162158_create_approvals_table.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE approvals ( + id BIGSERIAL PRIMARY KEY, + approvable_type VARCHAR(50) NOT NULL, + approvable_id BIGINT NOT NULL, + step SMALLINT NOT NULL, + status VARCHAR(20) NOT NULL, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + action_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE INDEX approvals_approvable_lookup ON approvals (approvable_type, approvable_id); diff --git a/internal/database/migrations/20251017071755_rename_approval_status_to_action.down.sql b/internal/database/migrations/20251017071755_rename_approval_status_to_action.down.sql new file mode 100644 index 00000000..cca2f08b --- /dev/null +++ b/internal/database/migrations/20251017071755_rename_approval_status_to_action.down.sql @@ -0,0 +1,18 @@ +ALTER TABLE approvals + RENAME COLUMN action TO status; + +UPDATE approvals +SET status = 'PENDING' +WHERE status IS NULL; + +ALTER TABLE approvals + ALTER COLUMN status SET NOT NULL; + +ALTER TABLE approvals + RENAME COLUMN step_number TO step; + +ALTER TABLE approvals + DROP COLUMN step_name; + +ALTER TABLE approvals + RENAME COLUMN action_at TO created_at; diff --git a/internal/database/migrations/20251017071755_rename_approval_status_to_action.up.sql b/internal/database/migrations/20251017071755_rename_approval_status_to_action.up.sql new file mode 100644 index 00000000..4d27cd27 --- /dev/null +++ b/internal/database/migrations/20251017071755_rename_approval_status_to_action.up.sql @@ -0,0 +1,14 @@ +ALTER TABLE approvals + RENAME COLUMN status TO action; + +ALTER TABLE approvals + ALTER COLUMN action DROP NOT NULL; + +ALTER TABLE approvals + RENAME COLUMN step TO step_number; + +ALTER TABLE approvals + ADD COLUMN step_name VARCHAR NOT NULL; + +ALTER TABLE approvals + RENAME COLUMN created_at TO action_at; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index e3a0b8bc..7bebf4f3 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -8,6 +8,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gorm.io/gorm" ) @@ -322,12 +323,68 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, fcrs, locatio return nil, err } } + + if err := ensureProjectFlockApprovals(tx, projectFlock.Id, createdBy); err != nil { + return nil, err + } result[seed.Key] = projectFlock.Id } return result, nil } +func ensureProjectFlockApprovals(tx *gorm.DB, projectFlockID uint, actorID uint) error { + if projectFlockID == 0 || actorID == 0 { + return nil + } + + workflow := utils.ApprovalWorkflowProjectFlock.String() + + steps := []struct { + step approvalutils.ApprovalStep + action entity.ApprovalAction + }{ + {step: utils.ProjectFlockStepPengajuan, action: entity.ApprovalActionCreated}, + {step: utils.ProjectFlockStepAktif, action: entity.ApprovalActionApproved}, + } + + for _, cfg := range steps { + var count int64 + if err := tx.Model(&entity.Approval{}). + Where("approvable_type = ? AND approvable_id = ? AND step_number = ?", workflow, projectFlockID, uint16(cfg.step)). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + continue + } + + stepName, ok := utils.ProjectFlockApprovalSteps[cfg.step] + if !ok || strings.TrimSpace(stepName) == "" { + stepName = fmt.Sprintf("Step %d", cfg.step) + } + + var actionPtr *entity.ApprovalAction + action := cfg.action + actionPtr = &action + + record := entity.Approval{ + ApprovableType: workflow, + ApprovableId: projectFlockID, + StepNumber: uint16(cfg.step), + StepName: stepName, + Action: actionPtr, + ActionBy: uintPtr(actorID), + } + + if err := tx.Create(&record).Error; err != nil { + return err + } + } + + return nil +} + func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint, projectFlocks map[string]uint) (map[string]uint, error) { seeds := []struct { Name string diff --git a/internal/entities/approval.go b/internal/entities/approval.go new file mode 100644 index 00000000..87dc7b0a --- /dev/null +++ b/internal/entities/approval.go @@ -0,0 +1,28 @@ +package entities + +import ( + "time" +) + +type ApprovalAction string + +const ( + ApprovalActionApproved ApprovalAction = "APPROVED" + ApprovalActionRejected ApprovalAction = "REJECTED" + ApprovalActionCreated ApprovalAction = "CREATED" + ApprovalActionUpdated ApprovalAction = "UPDATED" +) + +type Approval struct { + Id uint `gorm:"primaryKey"` + ApprovableType string `gorm:"size:50;not null;index:approvals_approvable_lookup,priority:1"` + ApprovableId uint `gorm:"not null;index:approvals_approvable_lookup,priority:2"` + StepNumber uint16 `gorm:"not null"` + StepName string `gorm:"not null"` + Action *ApprovalAction `gorm:"type:VARCHAR(20)"` + Notes *string `gorm:"type:text"` + ActionAt time.Time `gorm:"autoCreateTime"` + ActionBy *uint `gorm:"index"` + + ActionUser *User `gorm:"foreignKey:ActionBy;references:Id"` +} diff --git a/internal/entities/projectfloc.go b/internal/entities/projectfloc.go deleted file mode 100644 index 47362d42..00000000 --- a/internal/entities/projectfloc.go +++ /dev/null @@ -1,28 +0,0 @@ -package entities - -import ( - "time" - - "gorm.io/gorm" -) - -type ProjectFlock struct { - Id uint `gorm:"primaryKey"` - FlockId uint `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` - AreaId uint `gorm:"not null"` - Category string `gorm:"type:varchar(20);not null"` - FcrId uint `gorm:"not null"` - LocationId uint `gorm:"not null"` - Period int `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Flock Flock `gorm:"foreignKey:FlockId;references:Id"` - Area Area `gorm:"foreignKey:AreaId;references:Id"` - Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` - KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` -} diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go new file mode 100644 index 00000000..e5c9ea82 --- /dev/null +++ b/internal/entities/projectflock.go @@ -0,0 +1,29 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectFlock struct { + Id uint `gorm:"primaryKey"` + FlockId uint `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + AreaId uint `gorm:"not null"` + Category string `gorm:"type:varchar(20);not null"` + FcrId uint `gorm:"not null"` + LocationId uint `gorm:"not null"` + Period int `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Flock Flock `gorm:"foreignKey:FlockId;references:Id"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` + Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` +} diff --git a/internal/modules/approvals/controllers/approval.controller.go b/internal/modules/approvals/controllers/approval.controller.go new file mode 100644 index 00000000..fd0baa6e --- /dev/null +++ b/internal/modules/approvals/controllers/approval.controller.go @@ -0,0 +1,100 @@ +package controller + +import ( + "math" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" +) + +type ApprovalController struct { + ApprovalService common.ApprovalService +} + +func NewApprovalController(approvalService common.ApprovalService) *ApprovalController { + return &ApprovalController{ + ApprovalService: approvalService, + } +} + +func (u *ApprovalController) GetAll(c *fiber.Ctx) error { + moduleName := strings.TrimSpace(c.Query("module_name", "")) + if moduleName == "" { + return fiber.NewError(fiber.StatusBadRequest, "`module_name` is required") + } + + moduleIDParam := strings.TrimSpace(c.Query("module_id", "")) + var moduleID *uint + if moduleIDParam != "" { + value, err := strconv.ParseUint(moduleIDParam, 10, 64) + if err != nil || value == 0 { + return fiber.NewError(fiber.StatusBadRequest, "module_id must be a positive integer") + } + id := uint(value) + moduleID = &id + } + + groupByStep := c.QueryBool("group_step_number", false) + + page := c.QueryInt("page", 1) + limit := c.QueryInt("limit", 10) + search := strings.TrimSpace(c.Query("search", "")) + + query := &validation.Query{ + ModuleName: moduleName, + ModuleId: moduleID, + GroupByStep: groupByStep, + Page: page, + Limit: limit, + Search: search, + } + + records, totalResults, err := u.ApprovalService.List( + c.Context(), + query.ModuleName, + query.ModuleId, + query.Page, + query.Limit, + query.Search, + ) + if err != nil { + return err + } + + if query.GroupByStep { + data := dto.ToApprovalGroupDTOs(records) + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ApprovalGroupDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get All approvals successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: data, + }) + } + + flat := dto.ToApprovalDTOs(records) + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ApprovalBaseDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get All approvals successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: flat, + }) +} diff --git a/internal/modules/approvals/dto/approval.dto.go b/internal/modules/approvals/dto/approval.dto.go new file mode 100644 index 00000000..085c367c --- /dev/null +++ b/internal/modules/approvals/dto/approval.dto.go @@ -0,0 +1,122 @@ +package dto + +import ( + "sort" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" +) + +type ApprovalBaseDTO struct { + StepNumber uint16 `json:"step_number"` + StepName string `json:"step_name"` + Action *string `json:"action"` + Notes *string `json:"notes"` + ActionBy userDTO.UserBaseDTO `json:"action_by"` + ActionAt time.Time `json:"action_at"` +} + +type ApprovalGroupDTO struct { + StepNumber uint16 `json:"step_number"` + StepName string `json:"step_name"` + Approvals []ApprovalBaseDTO `json:"approvals"` +} + +func ToApprovalDTO(e entity.Approval) ApprovalBaseDTO { + dto := ApprovalBaseDTO{ + Notes: e.Notes, + } + + if e.StepNumber > 0 { + stepCopy := uint16(e.StepNumber) + dto.StepNumber = stepCopy + } + + stepName := strings.TrimSpace(e.StepName) + if stepName == "" && e.ApprovableType != "" && e.StepNumber > 0 { + if label, ok := approvalutils.ApprovalStepName(approvalutils.ApprovalWorkflowKey(e.ApprovableType), approvalutils.ApprovalStep(e.StepNumber)); ok { + stepName = label + } + } + dto.StepName = stepName + + if e.Action != nil { + value := strings.TrimSpace(string(*e.Action)) + if value != "" { + valueCopy := value + dto.Action = &valueCopy + } + } + + if e.ActionUser != nil && e.ActionUser.Id != 0 { + user := userDTO.ToUserBaseDTO(*e.ActionUser) + dto.ActionBy = user + } else if e.ActionBy != nil && *e.ActionBy != 0 { + dto.ActionBy = userDTO.UserBaseDTO{ + Id: *e.ActionBy, + IdUser: int64(*e.ActionBy), + } + } + + if !e.ActionAt.IsZero() { + at := e.ActionAt + dto.ActionAt = at + } + + return dto +} + +func ToApprovalDTOs(items []entity.Approval) []ApprovalBaseDTO { + result := make([]ApprovalBaseDTO, len(items)) + for i, item := range items { + result[i] = ToApprovalDTO(item) + } + return result +} + +func ToApprovalGroupDTOs(items []entity.Approval) []ApprovalGroupDTO { + if len(items) == 0 { + return nil + } + + type groupAccumulator struct { + StepName string + Approvals []ApprovalBaseDTO + } + + groups := make(map[uint16]*groupAccumulator) + order := make([]uint16, 0) + for _, item := range items { + step := item.StepNumber + acc, exists := groups[step] + if !exists { + stepName := strings.TrimSpace(item.StepName) + if stepName == "" && item.ApprovableType != "" && item.StepNumber > 0 { + if label, ok := approvalutils.ApprovalStepName(approvalutils.ApprovalWorkflowKey(item.ApprovableType), approvalutils.ApprovalStep(item.StepNumber)); ok { + stepName = label + } + } + acc = &groupAccumulator{StepName: stepName} + groups[step] = acc + order = append(order, step) + } + acc.Approvals = append(acc.Approvals, ToApprovalDTO(item)) + } + + sort.Slice(order, func(i, j int) bool { return order[i] < order[j] }) + + result := make([]ApprovalGroupDTO, len(order)) + for i, step := range order { + acc := groups[step] + result[i] = ApprovalGroupDTO{ + StepNumber: step, + StepName: acc.StepName, + Approvals: acc.Approvals, + } + } + + return result +} diff --git a/internal/modules/approvals/module.go b/internal/modules/approvals/module.go new file mode 100644 index 00000000..8cf52f73 --- /dev/null +++ b/internal/modules/approvals/module.go @@ -0,0 +1,25 @@ +package approvals + +import ( + "github.com/go-playground/validator/v10" + "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" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ApprovalModule struct{} + +func (ApprovalModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + approvalRepo := commonRepo.NewApprovalRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalService := commonSvc.NewApprovalService(approvalRepo) + userService := sUser.NewUserService(userRepo, validate) + + ApprovalRoutes(router, userService, approvalService) +} diff --git a/internal/modules/approvals/route.go b/internal/modules/approvals/route.go new file mode 100644 index 00000000..b7d66abd --- /dev/null +++ b/internal/modules/approvals/route.go @@ -0,0 +1,19 @@ +package approvals + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + "github.com/gofiber/fiber/v2" + + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/controllers" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +func ApprovalRoutes(v1 fiber.Router, u user.UserService, s common.ApprovalService) { + _ = u + ctrl := controller.NewApprovalController(s) + + route := v1.Group("/approvals") + + route.Get("/", ctrl.GetAll) +} diff --git a/internal/modules/approvals/validations/approval.validation.go b/internal/modules/approvals/validations/approval.validation.go new file mode 100644 index 00000000..7338550e --- /dev/null +++ b/internal/modules/approvals/validations/approval.validation.go @@ -0,0 +1,10 @@ +package validation + +type Query struct { + ModuleName string `json:"module_name" validate:"required_strict"` + ModuleId *uint `json:"module_id,omitempty" validate:"omitempty,gt=0"` + GroupByStep bool `json:"group_by_step"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go index 7b85ce20..4b44d553 100644 --- a/internal/modules/constants/repositories/constant.repository.go +++ b/internal/modules/constants/repositories/constant.repository.go @@ -1,9 +1,13 @@ package repository import ( + "sort" + "strconv" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gorm.io/gorm" ) @@ -26,6 +30,50 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { for f := range utils.AllFlagTypes() { flagList = append(flagList, string(f)) } + sort.Strings(flagList) + + type approvalStepConstant struct { + StepNumber uint16 `json:"step_number"` + StepName string `json:"step_name"` + } + + workflowConstants := approvalutils.WorkflowConstants() + workflowKeys := make([]string, 0, len(workflowConstants)) + for key := range workflowConstants { + workflowKeys = append(workflowKeys, key) + } + sort.Strings(workflowKeys) + + approvalWorkflows := make([]map[string]interface{}, 0, len(workflowKeys)) + for _, key := range workflowKeys { + stepMap := workflowConstants[key] + if len(stepMap) == 0 { + continue + } + + stepList := make([]approvalStepConstant, 0, len(stepMap)) + for stepStr, label := range stepMap { + stepNum, err := strconv.ParseUint(stepStr, 10, 16) + if err != nil || stepNum == 0 { + continue + } + stepList = append(stepList, approvalStepConstant{ + StepNumber: uint16(stepNum), + StepName: label, + }) + } + if len(stepList) == 0 { + continue + } + sort.Slice(stepList, func(i, j int) bool { + return stepList[i].StepNumber < stepList[j].StepNumber + }) + + approvalWorkflows = append(approvalWorkflows, map[string]interface{}{ + "key": key, + "steps": stepList, + }) + } return map[string]interface{}{ "flags": flagList, @@ -42,5 +90,6 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { "BISNIS", "INDIVIDUAL", }, + "approval_workflows": approvalWorkflows, } } diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index a1f2e263..31d0b9f0 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -190,6 +190,33 @@ func (u *ProjectflockController) DeleteOne(c *fiber.Ctx) error { }) } +func (u *ProjectflockController) Approval(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + req := new(validation.Approve) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProjectflockService.Approval(c, uint(id), req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Submit projectflock approval successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} + func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { param := c.Params("flock_id") diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index fcf3d50c..e58c13ac 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -4,12 +4,15 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" ) type ProjectFlockBaseDTO struct { @@ -22,55 +25,25 @@ type ProjectFlockBaseDTO struct { Location *locationDTO.LocationBaseDTO `json:"location"` } -func ToProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { - var flock *flockDTO.FlockBaseDTO - if e.Flock.Id != 0 { - mapped := flockDTO.ToFlockBaseDTO(e.Flock) - flock = &mapped - } - - var area *areaDTO.AreaBaseDTO - if e.Area.Id != 0 { - mapped := areaDTO.ToAreaBaseDTO(e.Area) - area = &mapped - } - - var fcr *fcrDTO.FcrBaseDTO - if e.Fcr.Id != 0 { - mapped := fcrDTO.ToFcrBaseDTO(e.Fcr) - fcr = &mapped - } - - var location *locationDTO.LocationBaseDTO - if e.Location.Id != 0 { - mapped := locationDTO.ToLocationBaseDTO(e.Location) - location = &mapped - } - - return ProjectFlockBaseDTO{ - Id: e.Id, - Period: e.Period, - Category: e.Category, - Flock: flock, - Area: area, - Fcr: fcr, - Location: location, - } -} - type ProjectFlockListDTO struct { ProjectFlockBaseDTO - Kandangs []kandangDTO.KandangBaseDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` + Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` + Kandangs []kandangDTO.KandangBaseDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalBaseDTO `json:"approval"` } type ProjectFlockDetailDTO struct { ProjectFlockListDTO } -type FlockPeriodSummaryDTO struct { +type FlockPeriodDTO struct { Flock flockDTO.FlockBaseDTO `json:"flock"` NextPeriod int `json:"next_period"` } @@ -90,12 +63,19 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { } } + latestApproval := defaultProjectFlockLatestApproval(e) + if e.LatestApproval != nil { + snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = snapshot + } + return ProjectFlockListDTO{ ProjectFlockBaseDTO: ToProjectFlockBaseDTO(e), Kandangs: kandangSummaries, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, + Approval: latestApproval, } } @@ -113,9 +93,48 @@ func ToProjectFlockDetailDTO(e entity.ProjectFlock) ProjectFlockDetailDTO { } } -func ToFlockPeriodSummaryDTO(flock entity.Flock, next int) FlockPeriodSummaryDTO { - return FlockPeriodSummaryDTO{ - Flock: flockDTO.ToFlockBaseDTO(flock), +func defaultProjectFlockLatestApproval(e entity.ProjectFlock) approvalDTO.ApprovalBaseDTO { + result := approvalDTO.ApprovalBaseDTO{} + + step := utils.ProjectFlockStepPengajuan + if step > 0 { + result.StepNumber = uint16(step) + if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowProjectFlock, step); ok { + result.StepName = label + } else if label, ok := utils.ProjectFlockApprovalSteps[step]; ok { + result.StepName = label + } + } + if result.StepName == "" { + result.StepName = "Pengajuan" + } + + if !e.CreatedAt.IsZero() { + result.ActionAt = e.CreatedAt + } + + if e.CreatedUser.Id != 0 { + result.ActionBy = userDTO.ToUserBaseDTO(e.CreatedUser) + } else if e.CreatedBy != 0 { + result.ActionBy = userDTO.UserBaseDTO{ + Id: e.CreatedBy, + IdUser: int64(e.CreatedBy), + } + } + + return result +} + +func ToFlockSummaryDTO(e entity.Flock) flockDTO.FlockBaseDTO { + return flockDTO.FlockBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToFlockPeriodSummaryDTO(flock entity.Flock, next int) FlockPeriodDTO { + return FlockPeriodDTO{ + Flock: ToFlockSummaryDTO(flock), NextPeriod: next, } } diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 5b91ab13..994eb4a4 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -1,8 +1,12 @@ package project_flocks import ( + "fmt" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gorm.io/gorm" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" @@ -10,6 +14,7 @@ import ( rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -24,7 +29,13 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, validate) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlock, utils.ProjectFlockApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) + } + + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index e5dbb48a..7282c020 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -25,5 +25,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) + route.Post("/:id/approvals", ctrl.Approval) route.Get("/flocks/:flock_id/periods", ctrl.GetFlockPeriodSummary) } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 21941826..1a7526be 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -7,13 +7,14 @@ import ( "strings" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" - "gitlab.com/mbugroup/lti-api.git/internal/utils" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -28,15 +29,18 @@ type ProjectflockService interface { UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) + Approval(ctx *fiber.Ctx, id uint, req *validation.Approve) (*entity.ProjectFlock, error) } type projectflockService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectflockRepository - FlockRepo flockRepository.FlockRepository - KandangRepo kandangRepository.KandangRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository PivotRepo repository.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type FlockPeriodSummary struct { @@ -49,15 +53,18 @@ func NewProjectflockService( flockRepo flockRepository.FlockRepository, kandangRepo kandangRepository.KandangRepository, pivotRepo repository.ProjectFlockKandangRepository, + approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) ProjectflockService { return &projectflockService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - FlockRepo: flockRepo, - KandangRepo: kandangRepo, + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, PivotRepo: pivotRepo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } } @@ -68,7 +75,7 @@ func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { Preload("Area"). Preload("Fcr"). Preload("Location"). - Preload("Kandangs") + Preload("Kandangs.Location") } func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { @@ -154,6 +161,27 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e s.Log.Errorf("Failed to get projectflocks: %+v", err) return nil, 0, err } + + if s.ApprovalSvc != nil && len(projectflocks) > 0 { + ids := make([]uint, len(projectflocks)) + for i, item := range projectflocks { + ids[i] = item.Id + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), s.approvalWorkflow, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for projectflocks: %+v", err) + } else if len(latestMap) > 0 { + for i := range projectflocks { + if approval, ok := latestMap[projectflocks[i].Id]; ok { + projectflocks[i].LatestApproval = approval + } + } + } + } + return projectflocks, total, nil } @@ -166,6 +194,23 @@ func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock s.Log.Errorf("Failed get projectflock by id: %+v", err) return nil, err } + + if s.ApprovalSvc != nil { + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load approvals for projectflock %d: %+v", id, err) + } else if len(approvals) > 0 { + if projectflock.LatestApproval == nil { + latest := approvals[len(approvals)-1] + projectflock.LatestApproval = &latest + } + } else { + projectflock.LatestApproval = nil + } + } + return projectflock, nil } @@ -183,11 +228,11 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") } - if err := common.EnsureRelations(c.Context(), - common.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, - common.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, - common.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, - common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, + commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, + commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, + commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, ); err != nil { return nil, err } @@ -209,19 +254,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* } } - tx := s.Repository.DB().Begin() - if tx.Error != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") - } - - projectRepo := repository.NewProjectflockRepository(tx) - nextPeriod, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) - if err != nil { - tx.Rollback() - s.Log.Errorf("Failed to determine next period for flock %d: %+v", req.FlockId, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine next period") - } - createBody := &entity.ProjectFlock{ FlockId: req.FlockId, AreaId: req.AreaId, @@ -232,8 +264,60 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* CreatedBy: 1, } - if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { - tx.Rollback() + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + projectRepo := repository.NewProjectflockRepository(tx) + // kandangRepo := kandangRepository.NewKandangRepository(tx) + + period, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) + if err != nil { + return err + } + createBody.Period = period + + if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + // kandangUpdates := make([]*entity.Kandang, len(kandangs)) + // for i := range kandangs { + // kandangs[i].ProjectFlockId = &createBody.Id + // kandangUpdates[i] = &kandangs[i] + // } + // if err := kandangRepo.UpdateMany( + // c.Context(), + // kandangUpdates, + // func(db *gorm.DB) *gorm.DB { + // return db.Select("project_flock_id") + // }, + // ); err != nil { + // return err + // } + + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Updates(map[string]any{ + "project_flock_id": createBody.Id, + "status": string(utils.KandangStatusPengajuan), + }).Error; err != nil { + return err + } + + actorID := uint(1) //TODO: Change From Auth + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + _, err = approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + createBody.Id, + utils.ProjectFlockStepPengajuan, + &action, + actorID, + nil, + ) + return err + }) + + if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") } @@ -268,13 +352,14 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - updateBody := make(map[string]any) - var relationChecks []common.RelationCheck + hasBodyChanges := false + var relationChecks []commonSvc.RelationCheck if req.FlockId != nil { updateBody["flock_id"] = *req.FlockId - relationChecks = append(relationChecks, common.RelationCheck{ + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Flock", ID: req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), @@ -282,7 +367,8 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if req.AreaId != nil { updateBody["area_id"] = *req.AreaId - relationChecks = append(relationChecks, common.RelationCheck{ + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Area", ID: req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB()), @@ -297,7 +383,8 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if req.FcrId != nil { updateBody["fcr_id"] = *req.FcrId - relationChecks = append(relationChecks, common.RelationCheck{ + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "FCR", ID: req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), @@ -305,7 +392,8 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if req.LocationId != nil { updateBody["location_id"] = *req.LocationId - relationChecks = append(relationChecks, common.RelationCheck{ + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Location", ID: req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB()), @@ -313,16 +401,19 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if req.Period != nil { updateBody["period"] = *req.Period + hasBodyChanges = true } if len(relationChecks) > 0 { - if err := common.EnsureRelations(c.Context(), relationChecks...); err != nil { + if err := commonSvc.EnsureRelations(c.Context(), relationChecks...); err != nil { return nil, err } } var newKandangIDs []uint + hasKandangChanges := false if req.KandangIds != nil { + hasKandangChanges = true if len(req.KandangIds) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids cannot be empty") } @@ -344,46 +435,47 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } } - tx := s.Repository.DB().Begin() - if tx.Error != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + hasChanges := hasBodyChanges || hasKandangChanges + if !hasChanges { + return s.GetOne(c, id) } - projectRepo := repository.NewProjectflockRepository(tx) - if len(updateBody) > 0 { - if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { - tx.Rollback() - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + projectRepo := repository.NewProjectflockRepository(tx) + + if len(updateBody) > 0 { + if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return err } - s.Log.Errorf("Failed to update projectflock: %+v", err) - return nil, err - } - } - - if req.KandangIds != nil { - existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) - for _, k := range existing.Kandangs { - existingIDs[k.Id] = struct{}{} - } - newSet := make(map[uint]struct{}, len(newKandangIDs)) - for _, id := range newKandangIDs { - newSet[id] = struct{}{} - } - - var toDetach []uint - for id := range existingIDs { - if _, ok := newSet[id]; !ok { - toDetach = append(toDetach, id) + } else { + if _, err := projectRepo.GetByID(c.Context(), id, nil); err != nil { + return err } } - var toAttach []uint - for id := range newSet { - if _, ok := existingIDs[id]; !ok { - toAttach = append(toAttach, id) + if req.KandangIds != nil { + existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) + for _, k := range existing.Kandangs { + existingIDs[k.Id] = struct{}{} + } + newSet := make(map[uint]struct{}, len(newKandangIDs)) + for _, kid := range newKandangIDs { + newSet[kid] = struct{}{} + } + + var toDetach []uint + for kid := range existingIDs { + if _, ok := newSet[kid]; !ok { + toDetach = append(toDetach, kid) + } + } + + var toAttach []uint + for kid := range newSet { + if _, ok := existingIDs[kid]; !ok { + toAttach = append(toAttach, kid) + } } - } if len(toDetach) > 0 { if err := s.detachKandangs(c.Context(), tx, id, toDetach, false); err != nil { @@ -437,18 +529,21 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { } } - if err := repository.NewProjectflockRepository(tx).DeleteOne(c.Context(), id); err != nil { - tx.Rollback() - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + if err := repository.NewProjectflockRepository(tx).DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + return err } - s.Log.Errorf("Failed to delete projectflock: %+v", err) - return err - } - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr + } + s.Log.Errorf("Failed to delete projectflock %d: %+v", id, err) + return err } return nil diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index bbe957b6..d2ce7331 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -30,3 +30,8 @@ type Query struct { Period int `query:"period" validate:"omitempty,number,gt=0"` KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` } + +type Approve struct { + Action string `json:"action" validate:"required_strict"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} diff --git a/internal/route/route.go b/internal/route/route.go index b1cd62a4..60f0fe26 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -13,6 +13,7 @@ import ( master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" + approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" // MODULE IMPORTS ) @@ -28,6 +29,7 @@ func Routes(app *fiber.App, db *gorm.DB) { constants.ConstantModule{}, inventory.InventoryModule{}, production.ProductionModule{}, + approvals.ApprovalModule{}, // MODULE REGISTRY } diff --git a/internal/utils/approvals/util.approval_workflow.go b/internal/utils/approvals/util.approval_workflow.go new file mode 100644 index 00000000..78f1de8e --- /dev/null +++ b/internal/utils/approvals/util.approval_workflow.go @@ -0,0 +1,243 @@ +package approvals + +import ( + "errors" + "fmt" + "strings" + "sync" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type ApprovalStep uint16 + +type ApprovalWorkflowKey string + +func (k ApprovalWorkflowKey) String() string { + return string(k) +} + +type NextStepCallback func(current ApprovalStep, decision entity.ApprovalAction) (ApprovalStep, bool) + +var ( + approvalActions = map[entity.ApprovalAction]struct{}{ + entity.ApprovalActionApproved: {}, + entity.ApprovalActionRejected: {}, + entity.ApprovalActionCreated: {}, + entity.ApprovalActionUpdated: {}, + } + + approvalWorkflows = make(map[ApprovalWorkflowKey]map[ApprovalStep]string) + approvalWorkflowsMu sync.RWMutex +) + +// WorkflowConstants prepares the registered workflows for exposure via constants endpoints. +func WorkflowConstants() map[string]map[string]string { + approvalWorkflowsMu.RLock() + defer approvalWorkflowsMu.RUnlock() + + if len(approvalWorkflows) == 0 { + return nil + } + + result := make(map[string]map[string]string, len(approvalWorkflows)) + for workflow, steps := range approvalWorkflows { + if len(steps) == 0 { + continue + } + stepMap := make(map[string]string, len(steps)) + for step, label := range steps { + stepMap[fmt.Sprintf("%d", step)] = label + } + result[workflow.String()] = stepMap + } + if len(result) == 0 { + return nil + } + return result +} + +// RegisterWorkflowSteps stores the available steps for a workflow key (usually matching approvable type). +func RegisterWorkflowSteps(workflow ApprovalWorkflowKey, steps map[ApprovalStep]string) error { + workflowStr := strings.TrimSpace(workflow.String()) + if workflowStr == "" { + return errors.New("workflow key is required") + } + if len(steps) == 0 { + return fmt.Errorf("no steps defined for workflow %q", workflowStr) + } + + copied := make(map[ApprovalStep]string, len(steps)) + for step, label := range steps { + if step == 0 { + return fmt.Errorf("workflow %q contains step 0 which is not allowed", workflowStr) + } + trimmed := strings.TrimSpace(label) + if trimmed == "" { + return fmt.Errorf("workflow %q contains empty label for step %d", workflowStr, step) + } + copied[step] = trimmed + } + + approvalWorkflowsMu.Lock() + defer approvalWorkflowsMu.Unlock() + approvalWorkflows[ApprovalWorkflowKey(workflowStr)] = copied + return nil +} + +// WorkflowSteps returns the steps registered for the given workflow key. +func WorkflowSteps(workflow ApprovalWorkflowKey) map[ApprovalStep]string { + approvalWorkflowsMu.RLock() + defer approvalWorkflowsMu.RUnlock() + + workflowStr := strings.TrimSpace(workflow.String()) + if workflowStr == "" { + return nil + } + + steps, ok := approvalWorkflows[ApprovalWorkflowKey(workflowStr)] + if !ok || len(steps) == 0 { + return nil + } + + copied := make(map[ApprovalStep]string, len(steps)) + for step, label := range steps { + copied[step] = label + } + return copied +} + +// ApprovalStepName fetches the label for the target step inside the workflow. +func ApprovalStepName(workflow ApprovalWorkflowKey, step ApprovalStep) (string, bool) { + steps := WorkflowSteps(workflow) + if len(steps) == 0 { + return "", false + } + label, ok := steps[step] + return label, ok +} + +// ValidateApprovalStep ensures the workflow contains the provided step. +func ValidateApprovalStep(workflow ApprovalWorkflowKey, step ApprovalStep) error { + if _, ok := ApprovalStepName(workflow, step); ok { + return nil + } + return fmt.Errorf("invalid approval step %d for workflow %s", step, workflow) +} + +// IsValidApprovalAction reports whether the action is supported. +func IsValidApprovalAction(action entity.ApprovalAction) bool { + _, ok := approvalActions[action] + return ok +} + +// NewApproval creates an approval record for the given approvable target. +func NewApproval(workflow ApprovalWorkflowKey, approvableId uint, step ApprovalStep, action *entity.ApprovalAction, actorId uint, note *string) (*entity.Approval, error) { + if approvableId == 0 { + return nil, errors.New("approvable id is required") + } + + workflowStr := strings.TrimSpace(workflow.String()) + if workflowStr == "" { + return nil, errors.New("approval workflow key is required") + } + + key := ApprovalWorkflowKey(workflowStr) + + if err := ValidateApprovalStep(key, step); err != nil { + return nil, err + } + + var actionPtr *entity.ApprovalAction + if action != nil { + if !IsValidApprovalAction(*action) { + return nil, fmt.Errorf("invalid approval action %q", *action) + } + actionCopy := *action + actionPtr = &actionCopy + } + + if actorId == 0 { + return nil, errors.New("actor id is required") + } + + var notes *string + if note != nil { + trimmed := strings.TrimSpace(*note) + if trimmed != "" { + notes = &trimmed + } + } + + actor := actorId + var stepName string + if label, ok := ApprovalStepName(key, step); ok { + labelCopy := label + stepName = labelCopy + } + + return &entity.Approval{ + ApprovableType: workflowStr, + ApprovableId: approvableId, + StepNumber: uint16(step), + StepName: stepName, + Action: actionPtr, + Notes: notes, + ActionBy: &actor, + }, nil +} + +// SetApprovalAction updates the approval action, notes, and optionally advances to another step. +func SetApprovalAction(approval *entity.Approval, action entity.ApprovalAction, actorId uint, note *string, nextStep NextStepCallback) error { + if approval == nil { + return errors.New("approval is nil") + } + if !IsValidApprovalAction(action) { + return fmt.Errorf("invalid approval action %q", action) + } + if actorId == 0 { + return errors.New("actor id is required for approval decision") + } + + act := action + approval.Action = &act + approval.ActionBy = &actorId + + if note != nil { + trimmed := strings.TrimSpace(*note) + if trimmed == "" { + approval.Notes = nil + } else { + approval.Notes = &trimmed + } + } else { + approval.Notes = nil + } + + if nextStep != nil { + current := ApprovalStep(approval.StepNumber) + if proposed, ok := nextStep(current, action); ok { + if err := ValidateApprovalStep(ApprovalWorkflowKey(approval.ApprovableType), proposed); err != nil { + return err + } + approval.StepNumber = uint16(proposed) + } + } + + if label, ok := ApprovalStepName(ApprovalWorkflowKey(approval.ApprovableType), ApprovalStep(approval.StepNumber)); ok { + labelCopy := label + approval.StepName = labelCopy + } + + return nil +} + +// Approve marks the approval as approved by the given actor, applying the optional step callback. +func Approve(approval *entity.Approval, actorId uint, note *string, nextStep NextStepCallback) error { + return SetApprovalAction(approval, entity.ApprovalActionApproved, actorId, note, nextStep) +} + +// Reject marks the approval as rejected by the given actor, applying the optional step callback. +func Reject(approval *entity.Approval, actorId uint, note *string, nextStep NextStepCallback) error { + return SetApprovalAction(approval, entity.ApprovalActionRejected, actorId, note, nextStep) +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go index d780d2ae..5ab236b0 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -1,6 +1,10 @@ package utils -import "strings" +import ( + "strings" + + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" +) // ------------------------------------------------------------------- // FlagType & Groups @@ -120,6 +124,22 @@ const ( ProjectFlockCategoryLaying ProjectFlockCategory = "LAYING" ) +// ------------------------------------------------------------------- +// Project Flock Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowProjectFlock approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCKS") + ProjectFlockStepPengajuan approvalutils.ApprovalStep = 1 + ProjectFlockStepAktif approvalutils.ApprovalStep = 2 +) + +// projectFlockApprovalSteps keeps the workflow step definitions for project flock approvals. +var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ + ProjectFlockStepPengajuan: "Pengajuan", + ProjectFlockStepAktif: "Aktif", +} + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- diff --git a/tools/templates/validation.tmpl b/tools/templates/validation.tmpl index 3aa587eb..031b76c5 100644 --- a/tools/templates/validation.tmpl +++ b/tools/templates/validation.tmpl @@ -9,8 +9,8 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + 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"` } {{end}} From e4799fa2ddb4256ae241ae836dda99cb53400d8e Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Tue, 21 Oct 2025 15:11:04 +0700 Subject: [PATCH 26/26] fix(BE): merge conflict --- internal/database/seed/seeder.go | 4 +- internal/entities/projectflock.go | 1 + .../repositories/kandang.repository.go | 8 + .../production/chickins/dto/chickin.dto.go | 39 ++- .../chickins/services/chickin.service.go | 9 +- .../project_flocks/dto/projectflock.dto.go | 38 ++- .../services/projectflock.service.go | 233 +++++++++++------- .../validations/projectflock.validation.go | 1 - 8 files changed, 216 insertions(+), 117 deletions(-) diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 7bebf4f3..32c3b310 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -394,9 +394,9 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users ProjectFlockKey *string }{ {Name: "Singaparna 1", Status: utils.KandangStatusActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, - {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, + {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, {Name: "Cikaum 1", Status: utils.KandangStatusActive, Location: "Cikaum", PicKey: "admin", ProjectFlockKey: strPtr("Cikaum Period 1")}, - {Name: "Cikaum 2", Status: utils.KandangStatusPengajuan, Location: "Cikaum", PicKey: "admin"}, + {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, } result := make(map[string]uint, len(seeds)) diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index e5c9ea82..c840892f 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -26,4 +26,5 @@ type ProjectFlock struct { CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index bcb03854..22546339 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -17,6 +17,7 @@ type KandangRepository interface { ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) + UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error } type KandangRepositoryImpl struct { @@ -81,3 +82,10 @@ func (r *KandangRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, pr } return kandang, nil } + +func (r *KandangRepositoryImpl) UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error { + return r.db.WithContext(ctx). + Model(&entity.Kandang{}). + Where("project_flock_id = ?", projectFlockID). + Update("status", string(status)).Error +} diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 96115b58..193257b6 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -9,7 +9,6 @@ import ( flockBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" - productCategoryBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" userBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -24,13 +23,13 @@ type ChickinBaseDTO struct { } type ProjectFlockDTO struct { - Id uint `json:"id"` - Period int `json:"period"` - Flock *flockBaseDTO.FlockBaseDTO `json:"flock"` - ProductCategory *productCategoryBaseDTO.ProductCategoryBaseDTO `json:"product_category"` - Area *areaBaseDTO.AreaBaseDTO `json:"area"` - Fcr *fcrBaseDTO.FcrBaseDTO `json:"fcr"` - Location *locationBaseDTO.LocationBaseDTO `json:"location"` + Id uint `json:"id"` + Period int `json:"period"` + Category string `json:"category"` + Flock *flockBaseDTO.FlockBaseDTO `json:"flock"` + Area *areaBaseDTO.AreaBaseDTO `json:"area"` + Fcr *fcrBaseDTO.FcrBaseDTO `json:"fcr"` + Location *locationBaseDTO.LocationBaseDTO `json:"location"` } type ProjectFlockKandangDTO struct { @@ -71,11 +70,6 @@ func ToFlockDTO(e entity.Flock) flockBaseDTO.FlockBaseDTO { func ToKandangDTO(e entity.Kandang) kandangBaseDTO.KandangBaseDTO { return kandangBaseDTO.ToKandangBaseDTO(e) } - -func ToProductCategoryDTO(e entity.ProductCategory) productCategoryBaseDTO.ProductCategoryBaseDTO { - return productCategoryBaseDTO.ToProductCategoryBaseDTO(e) -} - func ToAreaDTO(e entity.Area) areaBaseDTO.AreaBaseDTO { return areaBaseDTO.ToAreaBaseDTO(e) } @@ -98,11 +92,6 @@ func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { mapped := flockBaseDTO.ToFlockBaseDTO(e.Flock) flock = &mapped } - var productCategory *productCategoryBaseDTO.ProductCategoryBaseDTO - if e.ProductCategory.Id != 0 { - mapped := productCategoryBaseDTO.ToProductCategoryBaseDTO(e.ProductCategory) - productCategory = &mapped - } var area *areaBaseDTO.AreaBaseDTO if e.Area.Id != 0 { mapped := areaBaseDTO.ToAreaBaseDTO(e.Area) @@ -119,13 +108,13 @@ func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { location = &mapped } return ProjectFlockDTO{ - Id: e.Id, - Period: e.Period, - Flock: flock, - ProductCategory: productCategory, - Area: area, - Fcr: fcr, - Location: location, + Id: e.Id, + Period: e.Period, + Category: e.Category, + Flock: flock, + Area: area, + Fcr: fcr, + Location: location, } } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 46bc8069..43105374 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -67,7 +67,6 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.Flock"). - Preload("ProjectFlockKandang.ProjectFlock.ProductCategory"). Preload("ProjectFlockKandang.ProjectFlock.Area"). Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("ProjectFlockKandang.ProjectFlock.Location"). @@ -129,7 +128,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit c.Context(), projectflockkandang.ProjectFlockId, func(db *gorm.DB) *gorm.DB { - return db.Preload("ProductCategory") + return db }, ) if err != nil { @@ -141,7 +140,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit WithContext(c.Context()). Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.ProductCategory.Code, warehouse.Id). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.Category, warehouse.Id). Order("created_at DESC"). Find(&productWarehouses).Error if err != nil { @@ -298,7 +297,7 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { c.Context(), projectflockkandang.ProjectFlockId, func(db *gorm.DB) *gorm.DB { - return db.Preload("ProductCategory") + return db }, ) @@ -310,7 +309,7 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.ProductCategory.Code, warehouse.Id). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.Category, warehouse.Id). Order("created_at DESC"). First(&productWarehouse).Error diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index e58c13ac..cb35eb0f 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -70,7 +70,7 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { } return ProjectFlockListDTO{ - ProjectFlockBaseDTO: ToProjectFlockBaseDTO(e), + ProjectFlockBaseDTO: createProjectFlockBaseDTO(e), Kandangs: kandangSummaries, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, @@ -125,6 +125,42 @@ func defaultProjectFlockLatestApproval(e entity.ProjectFlock) approvalDTO.Approv return result } +func createProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { + var flock *flockDTO.FlockBaseDTO + if e.Flock.Id != 0 { + mapped := flockDTO.ToFlockBaseDTO(e.Flock) + flock = &mapped + } + + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + + var fcr *fcrDTO.FcrBaseDTO + if e.Fcr.Id != 0 { + mapped := fcrDTO.ToFcrBaseDTO(e.Fcr) + fcr = &mapped + } + + var location *locationDTO.LocationBaseDTO + if e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Location) + location = &mapped + } + + return ProjectFlockBaseDTO{ + Id: e.Id, + Period: e.Period, + Category: e.Category, + Flock: flock, + Area: area, + Fcr: fcr, + Location: location, + } +} + func ToFlockSummaryDTO(e entity.Flock) flockDTO.FlockBaseDTO { return flockDTO.FlockBaseDTO{ Id: e.Id, diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 1a7526be..49401dd4 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -38,7 +38,7 @@ type projectflockService struct { Repository repository.ProjectflockRepository FlockRepo flockRepository.FlockRepository KandangRepo kandangRepository.KandangRepository - PivotRepo repository.ProjectFlockKandangRepository + PivotRepo repository.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -62,7 +62,7 @@ func NewProjectflockService( Repository: repo, FlockRepo: flockRepo, KandangRepo: kandangRepo, - PivotRepo: pivotRepo, + PivotRepo: pivotRepo, ApprovalSvc: approvalSvc, approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } @@ -260,13 +260,11 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* Category: string(category), FcrId: req.FcrId, LocationId: req.LocationId, - Period: nextPeriod, CreatedBy: 1, } - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { - projectRepo := repository.NewProjectflockRepository(tx) - // kandangRepo := kandangRepository.NewKandangRepository(tx) + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + projectRepo := repository.NewProjectflockRepository(dbTransaction) period, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) if err != nil { @@ -278,33 +276,13 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } - // kandangUpdates := make([]*entity.Kandang, len(kandangs)) - // for i := range kandangs { - // kandangs[i].ProjectFlockId = &createBody.Id - // kandangUpdates[i] = &kandangs[i] - // } - // if err := kandangRepo.UpdateMany( - // c.Context(), - // kandangUpdates, - // func(db *gorm.DB) *gorm.DB { - // return db.Select("project_flock_id") - // }, - // ); err != nil { - // return err - // } - - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", kandangIDs). - Updates(map[string]any{ - "project_flock_id": createBody.Id, - "status": string(utils.KandangStatusPengajuan), - }).Error; err != nil { + if err := s.attachKandangs(c.Context(), dbTransaction, createBody.Id, kandangIDs); err != nil { return err } actorID := uint(1) //TODO: Change From Auth action := entity.ApprovalActionCreated - approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) _, err = approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowProjectFlock, @@ -325,17 +303,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } - if err := s.attachKandangs(c.Context(), tx, createBody.Id, kandangIDs); err != nil { - tx.Rollback() - s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", createBody.Id, err) - return nil, err - } - - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") - } - return s.GetOne(c, createBody.Id) } @@ -399,10 +366,6 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id Exists: relationExistsChecker[entity.Location](s.Repository.DB()), }) } - if req.Period != nil { - updateBody["period"] = *req.Period - hasBodyChanges = true - } if len(relationChecks) > 0 { if err := commonSvc.EnsureRelations(c.Context(), relationChecks...); err != nil { @@ -440,8 +403,8 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return s.GetOne(c, id) } - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { - projectRepo := repository.NewProjectflockRepository(tx) + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + projectRepo := repository.NewProjectflockRepository(dbTransaction) if len(updateBody) > 0 { if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { @@ -477,26 +440,136 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } } - if len(toDetach) > 0 { - if err := s.detachKandangs(c.Context(), tx, id, toDetach, false); err != nil { - tx.Rollback() - s.Log.Errorf("Failed to detach kandangs from projectflock %d: %+v", id, err) - return nil, err + if len(toDetach) > 0 { + if err := s.detachKandangs(c.Context(), dbTransaction, id, toDetach, true); err != nil { + return err + } + } + + if len(toAttach) > 0 { + if err := s.attachKandangs(c.Context(), dbTransaction, id, toAttach); err != nil { + return err + } } } - if len(toAttach) > 0 { - if err := s.attachKandangs(c.Context(), tx, id, toAttach); err != nil { - tx.Rollback() - s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", id, err) - return nil, err + if hasChanges { + actorID := uint(1) //TODO: Change From Auth + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + if approvalSvc != nil { + latestBeforeReset, err := approvalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, nil) + if err != nil { + return err + } + shouldRecordUpdate := latestBeforeReset == nil || + latestBeforeReset.StepNumber != uint16(utils.ProjectFlockStepPengajuan) || + latestBeforeReset.Action == nil || + (latestBeforeReset.Action != nil && *latestBeforeReset.Action != entity.ApprovalActionUpdated) + + if shouldRecordUpdate { + action := entity.ApprovalActionUpdated + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + id, + utils.ProjectFlockStepPengajuan, + &action, + actorID, + nil, + ); err != nil { + return err + } + } } } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to update projectflock %d: %+v", id, err) + return nil, err } - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + return s.GetOne(c, id) +} + +func (s projectflockService) Approval(c *fiber.Ctx, id uint, req *validation.Approve) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + project, err := s.GetOne(c, id) + if err != nil { + s.Log.Errorf("Failed to fetch projectflock %d before approval: %+v", id, err) + return nil, err + } + + actorID := uint(1) // TODO: change from auth context + var action entity.ApprovalAction + switch strings.ToUpper(strings.TrimSpace(req.Action)) { + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + step := utils.ProjectFlockStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.ProjectFlockStepAktif + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + project.Id, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + + kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) + switch action { + case entity.ApprovalActionApproved: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + project.Id, + utils.KandangStatusActive, + ); err != nil { + return err + } + case entity.ApprovalActionRejected: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + project.Id, + utils.KandangStatusNonActive, + ); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to record approval for projectflock %d: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") } return s.GetOne(c, id) @@ -512,24 +585,18 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - tx := s.Repository.DB().Begin() - if tx.Error != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") - } - - if len(existing.Kandangs) > 0 { - ids := make([]uint, len(existing.Kandangs)) - for i, k := range existing.Kandangs { - ids[i] = k.Id + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + if len(existing.Kandangs) > 0 { + ids := make([]uint, len(existing.Kandangs)) + for i, k := range existing.Kandangs { + ids[i] = k.Id + } + if err := s.detachKandangs(c.Context(), dbTransaction, id, ids, true); err != nil { + return err + } } - if err := s.detachKandangs(c.Context(), tx, id, ids, true); err != nil { - tx.Rollback() - s.Log.Errorf("Failed to detach kandangs before deleting projectflock %d: %+v", id, err) - return err - } - } - if err := repository.NewProjectflockRepository(tx).DeleteOne(c.Context(), id); err != nil { + if err := repository.NewProjectflockRepository(dbTransaction).DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } @@ -627,12 +694,12 @@ func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []s } } -func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint) error { +func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint) error { if len(kandangIDs) == 0 { return nil } - if err := tx.Model(&entity.Kandang{}). + if err := dbTransaction.Model(&entity.Kandang{}). Where("id IN ?", kandangIDs). Updates(map[string]any{ "project_flock_id": projectFlockID, @@ -641,7 +708,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, pr return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } - pivotRepo := s.pivotRepoWithTx(tx) + pivotRepo := s.pivotRepoWithTx(dbTransaction) records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) for i, id := range kandangIDs { records[i] = &entity.ProjectFlockKandang{ @@ -655,7 +722,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, pr return nil } -func (s projectflockService) detachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint, resetStatus bool) error { +func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint, resetStatus bool) error { if len(kandangIDs) == 0 { return nil } @@ -665,21 +732,21 @@ func (s projectflockService) detachKandangs(ctx context.Context, tx *gorm.DB, pr updates["status"] = string(utils.KandangStatusNonActive) } - if err := tx.Model(&entity.Kandang{}). + if err := dbTransaction.Model(&entity.Kandang{}). Where("id IN ?", kandangIDs). Updates(updates).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } - if err := s.pivotRepoWithTx(tx).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { + if err := s.pivotRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil } -func (s projectflockService) pivotRepoWithTx(tx *gorm.DB) repository.ProjectFlockKandangRepository { +func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { if s.PivotRepo == nil { - return repository.NewProjectFlockKandangRepository(tx) + return repository.NewProjectFlockKandangRepository(dbTransaction) } - return s.PivotRepo.WithTx(tx) + return s.PivotRepo.WithTx(dbTransaction) } diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index d2ce7331..00c9eab8 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -15,7 +15,6 @@ type Update struct { Category *string `json:"category,omitempty" validate:"omitempty,oneof=growing laying GROWING LAYING"` FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - Period *int `json:"period,omitempty" validate:"omitempty,number,gt=0"` KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` }